Complete preliminary SCIM fns, add permissioning to SCIM, add docs for SCIM
6
backend/src/@types/knex.d.ts
vendored
@@ -265,11 +265,7 @@ declare module "knex/types/tables" {
|
||||
TIdentityProjectMembershipsInsert,
|
||||
TIdentityProjectMembershipsUpdate
|
||||
>;
|
||||
[TableName.ScimToken]: Knex.CompositeTableType<
|
||||
TScimTokens,
|
||||
TScimTokensInsert,
|
||||
TScimTokensUpdate
|
||||
>;
|
||||
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
||||
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
||||
TSecretApprovalPolicies,
|
||||
TSecretApprovalPoliciesInsert,
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.ScimToken))) {
|
||||
await knex.schema.createTable(TableName.ScimToken, (t) => {
|
||||
t.string("id", 36).primary().defaultTo(knex.fn.uuid());
|
||||
t.bigInteger("ttl").defaultTo(15552000).notNullable(); // 180 days second
|
||||
t.bigInteger("ttlDays").defaultTo(365).notNullable();
|
||||
t.string("description").notNullable();
|
||||
t.uuid("orgId").notNullable();
|
||||
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||
@@ -15,10 +15,17 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||
t.boolean("scimEnabled").defaultTo(false);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.ScimToken);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.ScimToken);
|
||||
await dropOnUpdateTrigger(knex, TableName.ScimToken);
|
||||
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||
t.dropColumn("scimEnabled");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export const OrganizationsSchema = z.object({
|
||||
slug: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
authEnforced: z.boolean().default(false).nullable().optional()
|
||||
authEnforced: z.boolean().default(false).nullable().optional(),
|
||||
scimEnabled: z.boolean().default(false).nullable().optional()
|
||||
});
|
||||
|
||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||
|
||||
@@ -9,11 +9,11 @@ import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const ScimTokensSchema = z.object({
|
||||
id: z.string(),
|
||||
ttl: z.coerce.number().default(15552000),
|
||||
ttlDays: z.coerce.number().default(365),
|
||||
description: z.string(),
|
||||
orgId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TScimTokens = z.infer<typeof ScimTokensSchema>;
|
||||
|
||||
@@ -1,195 +1,19 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ScimTokensSchema } from "@app/db/schemas";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type";
|
||||
|
||||
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
schema: {
|
||||
params: z.object({}),
|
||||
response: {
|
||||
200: z.object({})
|
||||
}
|
||||
},
|
||||
// onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async () => {
|
||||
return {
|
||||
hello: "world"
|
||||
};
|
||||
}
|
||||
});
|
||||
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) {
|
||||
try {
|
||||
const strBody = body instanceof Buffer ? body.toString() : body;
|
||||
|
||||
server.route({
|
||||
url: "/Users",
|
||||
method: "GET",
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
startIndex: z.coerce.number().default(1),
|
||||
count: z.coerce.number().default(20),
|
||||
filter: z.string().trim().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ // TODO: audit the response
|
||||
Resources: z.array(z.object({
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})),
|
||||
itemsPerPage: z.number(),
|
||||
schemas: z.array(z.string()),
|
||||
startIndex: z.number(),
|
||||
totalResults: z.number(),
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const res = await req.server.services.scim.listUsers({
|
||||
offset: req.query.startIndex,
|
||||
limit: req.query.count,
|
||||
filter: req.query.filter
|
||||
});
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users/:userId",
|
||||
method: "GET",
|
||||
schema: {
|
||||
params: z.object({
|
||||
userId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
201: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const res = await req.server.services.scim.getUser(req.params.userId);
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})),
|
||||
displayName: z.string().trim(),
|
||||
// locale: z.string().trim(),
|
||||
// externalId: z.string().trim(),
|
||||
// groups: z.array(z.object({
|
||||
// value: z.string().trim()
|
||||
// })),
|
||||
// password: z.string().trim(),
|
||||
active: z.boolean()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req, reply) => {
|
||||
const user = await req.server.services.scim.createUser({
|
||||
email: req.body.emails[0].value,
|
||||
firstName: req.body.name.givenName,
|
||||
lastName: req.body.name.familyName,
|
||||
orgId: req.permission.orgId as string
|
||||
});
|
||||
|
||||
reply.code(201);
|
||||
return user;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users/:userId",
|
||||
method: "PATCH",
|
||||
schema: {
|
||||
body: z.object({}),
|
||||
response: {
|
||||
200: z.object({})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
// TODO: update a user's attr
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users/:userId",
|
||||
method: "PUT",
|
||||
schema: {
|
||||
body: z.object({}),
|
||||
response: {
|
||||
200: z.object({})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
// TODO: update a user's profile
|
||||
return {};
|
||||
const json: unknown = JSON.parse(strBody); // TODO: update
|
||||
done(null, json);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
done(error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,7 +25,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
body: z.object({
|
||||
organizationId: z.string().trim(),
|
||||
description: z.string().trim().default(""),
|
||||
ttl: z.number().min(0).default(0)
|
||||
ttlDays: z.number().min(0).default(0)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -211,9 +35,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { scimToken } = await server.services.scim.createScimToken({
|
||||
organizationId: req.body.organizationId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
orgId: req.body.organizationId,
|
||||
description: req.body.description,
|
||||
ttl: req.body.ttl
|
||||
ttlDays: req.body.ttlDays
|
||||
});
|
||||
|
||||
return { scimToken };
|
||||
@@ -235,7 +62,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const scimTokens = await server.services.scim.getScimTokens(req.query.organizationId);
|
||||
const scimTokens = await server.services.scim.listScimTokens({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
orgId: req.query.organizationId
|
||||
});
|
||||
|
||||
return { scimTokens };
|
||||
}
|
||||
});
|
||||
@@ -255,8 +88,267 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const scimToken = await server.services.scim.deleteScimToken(req.params.scimTokenId);
|
||||
const scimToken = await server.services.scim.deleteScimToken({
|
||||
scimTokenId: req.params.scimTokenId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
return { scimToken };
|
||||
}
|
||||
});
|
||||
|
||||
// SCIM server endpoints
|
||||
server.route({
|
||||
url: "/Users",
|
||||
method: "GET",
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
startIndex: z.coerce.number().default(1),
|
||||
count: z.coerce.number().default(20),
|
||||
filter: z.string().trim().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
Resources: z.array(
|
||||
z.object({
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(
|
||||
z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})
|
||||
),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})
|
||||
),
|
||||
itemsPerPage: z.number(),
|
||||
schemas: z.array(z.string()),
|
||||
startIndex: z.number(),
|
||||
totalResults: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const users = await req.server.services.scim.listScimUsers({
|
||||
offset: req.query.startIndex,
|
||||
limit: req.query.count,
|
||||
filter: req.query.filter,
|
||||
orgId: req.permission.orgId as string
|
||||
});
|
||||
return users;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users/:userId",
|
||||
method: "GET",
|
||||
schema: {
|
||||
params: z.object({
|
||||
userId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
201: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(
|
||||
z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})
|
||||
),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const user = await req.server.services.scim.getScimUser({
|
||||
userId: req.params.userId,
|
||||
orgId: req.permission.orgId as string
|
||||
});
|
||||
return user;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
userName: z.string().trim().email(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
// emails: z.array( // optional?
|
||||
// z.object({
|
||||
// primary: z.boolean(),
|
||||
// value: z.string().email(),
|
||||
// type: z.string().trim()
|
||||
// })
|
||||
// ),
|
||||
// displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim().email(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(
|
||||
z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})
|
||||
),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const user = await req.server.services.scim.createScimUser({
|
||||
email: req.body.userName,
|
||||
firstName: req.body.name.givenName,
|
||||
lastName: req.body.name.familyName,
|
||||
orgId: req.permission.orgId as string
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users/:userId",
|
||||
method: "PATCH",
|
||||
schema: {
|
||||
params: z.object({
|
||||
userId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
Operations: z.array(
|
||||
z.object({
|
||||
op: z.string().trim(),
|
||||
path: z.string().trim().optional(),
|
||||
value: z.union([
|
||||
z.object({
|
||||
active: z.boolean()
|
||||
}),
|
||||
z.string().trim()
|
||||
])
|
||||
})
|
||||
)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const user = await req.server.services.scim.updateScimUser({
|
||||
userId: req.params.userId,
|
||||
orgId: req.permission.orgId as string,
|
||||
operations: req.body.Operations
|
||||
});
|
||||
return user;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/Users/:userId",
|
||||
method: "PUT",
|
||||
schema: {
|
||||
params: z.object({
|
||||
userId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
// emails: z.array(
|
||||
// z.object({
|
||||
// primary: z.boolean(),
|
||||
// value: z.string().email(),
|
||||
// type: z.string().trim()
|
||||
// })
|
||||
// ),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z.object({
|
||||
familyName: z.string().trim(),
|
||||
givenName: z.string().trim()
|
||||
}),
|
||||
emails: z.array(
|
||||
z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string().email(),
|
||||
type: z.string().trim()
|
||||
})
|
||||
),
|
||||
displayName: z.string().trim(),
|
||||
active: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const user = await req.server.services.scim.replaceScimUser({
|
||||
userId: req.params.userId,
|
||||
orgId: req.permission.orgId as string,
|
||||
active: req.body.active
|
||||
});
|
||||
return user;
|
||||
}
|
||||
});
|
||||
|
||||
// server.route({
|
||||
// url: "/Users/:userId",
|
||||
// method: "DELETE",
|
||||
// schema: {
|
||||
// body: z.object({}),
|
||||
// response: {
|
||||
// 200: z.object({})
|
||||
// }
|
||||
// },
|
||||
// onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||
// handler: () => {
|
||||
// // TODO: update a user's profile
|
||||
// return {};
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
|
||||
|
||||
export type TCreateAuditLogDTO = {
|
||||
event: Event;
|
||||
actor: UserActor | IdentityActor | ServiceActor | ScimIdpActor;
|
||||
actor: UserActor | IdentityActor | ServiceActor;
|
||||
orgId?: string;
|
||||
projectId?: string;
|
||||
} & BaseAuthData;
|
||||
|
||||
@@ -23,8 +23,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
customAlerts: false,
|
||||
auditLogs: false,
|
||||
auditLogsRetentionDays: 0,
|
||||
samlSSO: true,
|
||||
scim: true,
|
||||
samlSSO: false,
|
||||
scim: false,
|
||||
status: null,
|
||||
trial_end: null,
|
||||
has_used_trial: true,
|
||||
|
||||
@@ -24,8 +24,8 @@ export type TFeatureSet = {
|
||||
customAlerts: false;
|
||||
auditLogs: false;
|
||||
auditLogsRetentionDays: 0;
|
||||
samlSSO: true;
|
||||
scim: true;
|
||||
samlSSO: false;
|
||||
scim: false;
|
||||
status: null;
|
||||
trial_end: null;
|
||||
has_used_trial: true;
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum OrgPermissionSubjects {
|
||||
Settings = "settings",
|
||||
IncidentAccount = "incident-contact",
|
||||
Sso = "sso",
|
||||
Scim = "scim",
|
||||
Billing = "billing",
|
||||
SecretScanning = "secret-scanning",
|
||||
Identity = "identity"
|
||||
@@ -29,6 +30,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
|
||||
@@ -69,6 +71,11 @@ const buildAdminPermission = () => {
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
|
||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
||||
|
||||
@@ -195,7 +195,7 @@ export const samlConfigServiceFactory = ({
|
||||
updateQuery.certTag = certTag;
|
||||
}
|
||||
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
|
||||
await orgDAL.updateById(orgId, { authEnforced: false });
|
||||
await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false });
|
||||
|
||||
return ssoConfig;
|
||||
};
|
||||
|
||||
58
backend/src/ee/services/scim/scim-fns.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TListScimUsers, TScimUser } from "./scim-types";
|
||||
|
||||
export const buildScimUserList = ({
|
||||
scimUsers,
|
||||
offset,
|
||||
limit
|
||||
}: {
|
||||
scimUsers: TScimUser[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
}): TListScimUsers => {
|
||||
return {
|
||||
Resources: scimUsers,
|
||||
itemsPerPage: limit,
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
startIndex: offset,
|
||||
totalResults: scimUsers.length
|
||||
};
|
||||
};
|
||||
|
||||
export const buildScimUser = ({
|
||||
userId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
active
|
||||
}: {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
}): TScimUser => {
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: userId,
|
||||
userName: email,
|
||||
displayName: `${firstName} ${lastName}`,
|
||||
name: {
|
||||
givenName: firstName,
|
||||
middleName: null,
|
||||
familyName: lastName
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
primary: true,
|
||||
value: email,
|
||||
type: "work"
|
||||
}
|
||||
],
|
||||
active,
|
||||
groups: [],
|
||||
meta: {
|
||||
resourceType: "User",
|
||||
location: null
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,61 +1,71 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import {
|
||||
OrgMembershipRole,
|
||||
OrgMembershipStatus
|
||||
} from "@app/db/schemas";
|
||||
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
|
||||
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { TOrgPermission } from "@app/lib/types";
|
||||
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { deleteOrgMembership } from "@app/services/org/org-fns";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { buildScimUser, buildScimUserList } from "./scim-fns";
|
||||
import {
|
||||
TCreateScimTokenDTO,
|
||||
TListScimUsersDTO,
|
||||
TListScimUsersRes,
|
||||
TCreateScimUserDTO,
|
||||
TScimTokenJwtPayload
|
||||
TDeleteScimTokenDTO,
|
||||
TGetScimUserDTO,
|
||||
TListScimUsers,
|
||||
TListScimUsersDTO,
|
||||
TReplaceScimUserDTO,
|
||||
TScimTokenJwtPayload,
|
||||
TUpdateScimUserDTO
|
||||
} from "./scim-types";
|
||||
import {
|
||||
createScimUser,
|
||||
TScimUser,
|
||||
} from "@app/lib/scim";
|
||||
import { UnauthorizedError, ScimRequestError } from "@app/lib/errors";
|
||||
|
||||
type TScimServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
// TODO: pick types
|
||||
scimDAL: TScimDALFactory; // TODO: pick
|
||||
userDAL: TUserDALFactory; // TODO: pick
|
||||
orgDAL: TOrgDALFactory; // TODO: pick
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
smtpService: TSmtpService;
|
||||
};
|
||||
|
||||
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
|
||||
|
||||
export const scimServiceFactory = ({
|
||||
export const scimServiceFactory = ({
|
||||
licenseService,
|
||||
scimDAL,
|
||||
userDAL,
|
||||
orgDAL,
|
||||
permissionService
|
||||
permissionService,
|
||||
smtpService
|
||||
}: TScimServiceFactoryDep) => {
|
||||
const createScimToken = async ({
|
||||
organizationId,
|
||||
description,
|
||||
ttl
|
||||
}: TCreateScimTokenDTO) => {
|
||||
const createScimToken = async ({ actor, actorId, actorOrgId, orgId, description, ttlDays }: TCreateScimTokenDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (!plan.scim)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to create a SCIM token due to plan restriction. Upgrade plan to create a SCIM token."
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
|
||||
// TODO: permission stuff
|
||||
|
||||
const scimTokenData = await scimDAL.create({
|
||||
orgId: organizationId,
|
||||
orgId,
|
||||
description,
|
||||
ttl
|
||||
ttlDays
|
||||
});
|
||||
|
||||
|
||||
const scimToken = jwt.sign(
|
||||
{
|
||||
scimTokenId: scimTokenData.id,
|
||||
@@ -63,167 +73,346 @@ export const scimServiceFactory = ({
|
||||
},
|
||||
appCfg.AUTH_SECRET
|
||||
);
|
||||
|
||||
return { scimToken }
|
||||
}
|
||||
|
||||
const getScimTokens = async (organizationId: string) => {
|
||||
const scimTokens = await scimDAL.find({ orgId: organizationId });
|
||||
return { scimToken };
|
||||
};
|
||||
|
||||
const listScimTokens = async ({ actor, actorId, actorOrgId, orgId }: TOrgPermission) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (!plan.scim)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to get SCIM tokens due to plan restriction. Upgrade plan to get SCIM tokens."
|
||||
});
|
||||
|
||||
const scimTokens = await scimDAL.find({ orgId });
|
||||
return scimTokens;
|
||||
}
|
||||
|
||||
const deleteScimToken = async (scimTokenId: string) => {
|
||||
const scimToken = await scimDAL.deleteById(scimTokenId);
|
||||
return scimToken;
|
||||
}
|
||||
|
||||
// scim server endpoints
|
||||
};
|
||||
|
||||
const deleteScimToken = async ({ scimTokenId, actor, actorId, actorOrgId }: TDeleteScimTokenDTO) => {
|
||||
let scimToken = await scimDAL.findById(scimTokenId);
|
||||
if (!scimToken) throw new BadRequestError({ message: "Failed to find SCIM token to delete" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, scimToken.orgId, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
|
||||
|
||||
const plan = await licenseService.getPlan(scimToken.orgId);
|
||||
if (!plan.scim)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to delete the SCIM token due to plan restriction. Upgrade plan to delete the SCIM token."
|
||||
});
|
||||
|
||||
scimToken = await scimDAL.deleteById(scimTokenId);
|
||||
|
||||
return scimToken;
|
||||
};
|
||||
|
||||
// SCIM server endpoints
|
||||
const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise<TListScimUsers> => {
|
||||
const org = await orgDAL.findById(orgId);
|
||||
|
||||
if (!org.scimEnabled)
|
||||
throw new ScimRequestError({
|
||||
detail: "SCIM is disabled for the organization",
|
||||
status: 403
|
||||
});
|
||||
|
||||
const parseFilter = (filterToParse: string | undefined) => {
|
||||
if (!filterToParse) return {};
|
||||
const [parsedName, parsedValue] = filterToParse.split("eq").map((s) => s.trim());
|
||||
|
||||
const listUsers = async ({
|
||||
offset,
|
||||
limit,
|
||||
filter
|
||||
}: TListScimUsersDTO): Promise<TListScimUsersRes> => {
|
||||
|
||||
const parseFilter = (filter: string | undefined) => {
|
||||
if (!filter) return {};
|
||||
const [parsedName, parsedValue] = filter.split("eq").map(s => s.trim());
|
||||
|
||||
let attributeName = parsedName;
|
||||
if (parsedName === "userName") { // note
|
||||
if (parsedName === "userName") {
|
||||
attributeName = "email";
|
||||
}
|
||||
|
||||
|
||||
return { [attributeName]: parsedValue };
|
||||
};
|
||||
|
||||
|
||||
const findOpts = {
|
||||
...(offset && { offset }),
|
||||
...(limit && { limit }),
|
||||
...(limit && { limit })
|
||||
};
|
||||
|
||||
const users = await userDAL.find(parseFilter(filter), findOpts);
|
||||
|
||||
let resources: TScimUser[] = [];
|
||||
|
||||
let scimResource: TListScimUsersRes = { // note: type
|
||||
Resources: [],
|
||||
itemsPerPage: limit,
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
startIndex: offset,
|
||||
totalResults: users.length
|
||||
};
|
||||
|
||||
users.forEach((user) => {
|
||||
let scimUser = createScimUser({
|
||||
userId: user.id,
|
||||
firstName: user.firstName as string,
|
||||
lastName: user.lastName as string,
|
||||
email: user.email
|
||||
});
|
||||
resources.push(scimUser);
|
||||
|
||||
const users = await orgDAL.findMembership(
|
||||
{
|
||||
orgId,
|
||||
...parseFilter(filter)
|
||||
},
|
||||
findOpts
|
||||
);
|
||||
|
||||
const scimUsers = users.map(({ userId, firstName, lastName, email }) =>
|
||||
buildScimUser({
|
||||
userId: userId ?? "",
|
||||
firstName: firstName ?? "",
|
||||
lastName: lastName ?? "",
|
||||
email,
|
||||
active: true
|
||||
})
|
||||
);
|
||||
|
||||
return buildScimUserList({
|
||||
scimUsers,
|
||||
offset,
|
||||
limit
|
||||
});
|
||||
};
|
||||
|
||||
scimResource.Resources = resources;
|
||||
|
||||
return scimResource;
|
||||
}
|
||||
|
||||
const getUser = async (userId: string) => {
|
||||
// TODO: check out SCIM-specific errors
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await userDAL.findById(userId);
|
||||
} catch (error) {
|
||||
const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => {
|
||||
const [membership] = await orgDAL
|
||||
.findMembership({
|
||||
userId,
|
||||
orgId
|
||||
})
|
||||
.catch(() => {
|
||||
throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
});
|
||||
});
|
||||
|
||||
interface PostgresError extends Error {
|
||||
error: {
|
||||
code: string;
|
||||
}
|
||||
}
|
||||
|
||||
const dbError = error as PostgresError;
|
||||
|
||||
if (dbError.error.code === "22P02") throw new ScimRequestError({
|
||||
if (!membership)
|
||||
throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!user) throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
|
||||
if (!membership.scimEnabled)
|
||||
throw new ScimRequestError({
|
||||
detail: "SCIM is disabled for the organization",
|
||||
status: 403
|
||||
});
|
||||
|
||||
return buildScimUser({
|
||||
userId: membership.userId as string,
|
||||
firstName: membership.firstName as string,
|
||||
lastName: membership.lastName as string,
|
||||
email: membership.email,
|
||||
active: true
|
||||
});
|
||||
|
||||
return createScimUser({
|
||||
userId: user.id,
|
||||
firstName: user.firstName as string,
|
||||
lastName: user.lastName as string,
|
||||
email: user.email
|
||||
});
|
||||
}
|
||||
|
||||
const createUser = async ({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
orgId
|
||||
}: TCreateScimUserDTO) => {
|
||||
};
|
||||
|
||||
const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => {
|
||||
const org = await orgDAL.findById(orgId);
|
||||
|
||||
if (!org)
|
||||
throw new ScimRequestError({
|
||||
detail: "Organization not found",
|
||||
status: 404
|
||||
});
|
||||
|
||||
if (!org.scimEnabled)
|
||||
throw new ScimRequestError({
|
||||
detail: "SCIM is disabled for the organization",
|
||||
status: 403
|
||||
});
|
||||
|
||||
let user = await userDAL.findOne({
|
||||
email
|
||||
});
|
||||
|
||||
if (user) throw new ScimRequestError({
|
||||
detail: "User already exists in the database",
|
||||
status: 409
|
||||
|
||||
if (user) {
|
||||
await userDAL.transaction(async (tx) => {
|
||||
const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx });
|
||||
if (orgMembership)
|
||||
throw new ScimRequestError({
|
||||
detail: "User already exists in the database",
|
||||
status: 409
|
||||
});
|
||||
|
||||
if (!orgMembership) {
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
userId: user.id,
|
||||
orgId,
|
||||
inviteEmail: email,
|
||||
role: OrgMembershipRole.Member,
|
||||
status: OrgMembershipStatus.Invited
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
user = await userDAL.transaction(async (tx) => {
|
||||
const newUser = await userDAL.create(
|
||||
{
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
authMethods: [AuthMethod.EMAIL]
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
inviteEmail: email,
|
||||
orgId,
|
||||
userId: newUser.id,
|
||||
role: OrgMembershipRole.Member,
|
||||
status: OrgMembershipStatus.Invited
|
||||
},
|
||||
tx
|
||||
);
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
|
||||
const appCfg = getConfig();
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.ScimUserProvisioned,
|
||||
subjectLine: "Infisical organization invitation",
|
||||
recipients: [email],
|
||||
substitutions: {
|
||||
organizationName: org.name,
|
||||
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
|
||||
}
|
||||
});
|
||||
|
||||
user = await userDAL.transaction(async (tx) => {
|
||||
const newUser = await userDAL.create(
|
||||
{
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
authMethods: [AuthMethod.EMAIL]
|
||||
},
|
||||
tx
|
||||
);
|
||||
await orgDAL.createMembership({
|
||||
inviteEmail: email,
|
||||
orgId,
|
||||
role: OrgMembershipRole.Member,
|
||||
status: OrgMembershipStatus.Invited
|
||||
});
|
||||
return newUser;
|
||||
});
|
||||
|
||||
return createScimUser({
|
||||
return buildScimUser({
|
||||
userId: user.id,
|
||||
firstName: user.firstName as string,
|
||||
lastName: user.lastName as string,
|
||||
email: user.email
|
||||
email: user.email,
|
||||
active: true
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => {
|
||||
const [membership] = await orgDAL
|
||||
.findMembership({
|
||||
userId,
|
||||
orgId
|
||||
})
|
||||
.catch(() => {
|
||||
throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
});
|
||||
});
|
||||
|
||||
if (!membership)
|
||||
throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
});
|
||||
|
||||
if (!membership.scimEnabled)
|
||||
throw new ScimRequestError({
|
||||
detail: "SCIM is disabled for the organization",
|
||||
status: 403
|
||||
});
|
||||
|
||||
let active = true;
|
||||
|
||||
operations.forEach((operation) => {
|
||||
if (operation.op.toLowerCase() === "replace") {
|
||||
if (operation.path === "active" && operation.value === "False") {
|
||||
// azure scim op format
|
||||
active = false;
|
||||
} else if (typeof operation.value === "object" && operation.value.active === false) {
|
||||
// okta scim op format
|
||||
active = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!active) {
|
||||
await deleteOrgMembership({
|
||||
orgMembershipId: membership.id,
|
||||
orgId: membership.orgId,
|
||||
orgDAL
|
||||
});
|
||||
}
|
||||
|
||||
return buildScimUser({
|
||||
userId: membership.userId as string,
|
||||
firstName: membership.firstName as string,
|
||||
lastName: membership.lastName as string,
|
||||
email: membership.email,
|
||||
active
|
||||
});
|
||||
};
|
||||
|
||||
const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => {
|
||||
const [membership] = await orgDAL
|
||||
.findMembership({
|
||||
userId,
|
||||
orgId
|
||||
})
|
||||
.catch(() => {
|
||||
throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
});
|
||||
});
|
||||
|
||||
if (!membership)
|
||||
throw new ScimRequestError({
|
||||
detail: "User not found",
|
||||
status: 404
|
||||
});
|
||||
|
||||
if (!membership.scimEnabled)
|
||||
throw new ScimRequestError({
|
||||
detail: "SCIM is disabled for the organization",
|
||||
status: 403
|
||||
});
|
||||
|
||||
if (!active) {
|
||||
// tx
|
||||
await deleteOrgMembership({
|
||||
orgMembershipId: membership.id,
|
||||
orgId: membership.orgId,
|
||||
orgDAL
|
||||
});
|
||||
}
|
||||
|
||||
return buildScimUser({
|
||||
userId: membership.userId as string,
|
||||
firstName: membership.firstName as string,
|
||||
lastName: membership.lastName as string,
|
||||
email: membership.email,
|
||||
active
|
||||
});
|
||||
};
|
||||
|
||||
const fnValidateScimToken = async (token: TScimTokenJwtPayload) => {
|
||||
// TODO: check expiry
|
||||
|
||||
const scimToken = await scimDAL.findById(token.scimTokenId);
|
||||
if (!scimToken) throw new UnauthorizedError();
|
||||
|
||||
|
||||
const { ttlDays, createdAt } = scimToken;
|
||||
|
||||
// ttl check
|
||||
if (Number(ttlDays) > 0) {
|
||||
const currentDate = new Date();
|
||||
const scimTokenCreatedAt = new Date(createdAt);
|
||||
const ttlInMilliseconds = Number(scimToken.ttlDays) * 86400;
|
||||
const expirationDate = new Date(scimTokenCreatedAt.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationDate)
|
||||
throw new ScimRequestError({
|
||||
detail: "The access token expired",
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
return { scimTokenId: scimToken.id, orgId: scimToken.orgId };
|
||||
}
|
||||
|
||||
return {
|
||||
};
|
||||
|
||||
return {
|
||||
createScimToken,
|
||||
getScimTokens,
|
||||
listScimTokens,
|
||||
deleteScimToken,
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
listScimUsers,
|
||||
getScimUser,
|
||||
createScimUser,
|
||||
updateScimUser,
|
||||
replaceScimUser,
|
||||
fnValidateScimToken
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,41 +1,87 @@
|
||||
import { TOrgPermission } from "@app/lib/types";
|
||||
import { TScimUser } from "@app/lib/scim";
|
||||
|
||||
export type TCreateScimTokenDTO = {
|
||||
organizationId: string;
|
||||
description: string;
|
||||
ttl: number;
|
||||
}
|
||||
description: string;
|
||||
ttlDays: number;
|
||||
} & TOrgPermission;
|
||||
|
||||
// TODO: add org permissions
|
||||
// & Omit<TOrgPermission, "orgId">;
|
||||
export type TDeleteScimTokenDTO = {
|
||||
scimTokenId: string;
|
||||
} & Omit<TOrgPermission, "orgId">;
|
||||
|
||||
// SCIM server endpoint types
|
||||
|
||||
export type TListScimUsersDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
filter?: string;
|
||||
}
|
||||
offset: number;
|
||||
limit: number;
|
||||
filter?: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TListScimUsersRes = { // check naming here
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
|
||||
totalResults: number;
|
||||
Resources: TScimUser[];
|
||||
itemsPerPage: number;
|
||||
startIndex: number;
|
||||
}
|
||||
export type TListScimUsers = {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
|
||||
totalResults: number;
|
||||
Resources: TScimUser[];
|
||||
itemsPerPage: number;
|
||||
startIndex: number;
|
||||
};
|
||||
|
||||
export type TGetScimUserDTO = {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TCreateScimUserDTO = {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
orgId: string;
|
||||
}
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TCreateScimUserRes = {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
}
|
||||
export type TUpdateScimUserDTO = {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
operations: {
|
||||
op: string;
|
||||
path?: string;
|
||||
value?:
|
||||
| string
|
||||
| {
|
||||
active: boolean;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TReplaceScimUserDTO = {
|
||||
userId: string;
|
||||
active: boolean;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TScimTokenJwtPayload = {
|
||||
scimTokenId: string;
|
||||
authTokenType: string;
|
||||
};
|
||||
scimTokenId: string;
|
||||
authTokenType: string;
|
||||
};
|
||||
|
||||
export type TScimUser = {
|
||||
schemas: string[];
|
||||
id: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
name: {
|
||||
givenName: string;
|
||||
middleName: null;
|
||||
familyName: string;
|
||||
};
|
||||
emails: {
|
||||
primary: boolean;
|
||||
value: string;
|
||||
type: string;
|
||||
}[];
|
||||
active: boolean;
|
||||
groups: string[];
|
||||
meta: {
|
||||
resourceType: string;
|
||||
location: null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,12 +61,27 @@ export class BadRequestError extends Error {
|
||||
|
||||
export class ScimRequestError extends Error {
|
||||
name: string;
|
||||
|
||||
schemas: string[];
|
||||
|
||||
detail: string;
|
||||
|
||||
status: number;
|
||||
|
||||
error: unknown;
|
||||
|
||||
constructor({ name, error, detail, status }: { message?: string; name?: string; error?: unknown, detail: string, status: number }) {
|
||||
constructor({
|
||||
name,
|
||||
error,
|
||||
detail,
|
||||
status
|
||||
}: {
|
||||
message?: string;
|
||||
name?: string;
|
||||
error?: unknown;
|
||||
detail: string;
|
||||
status: number;
|
||||
}) {
|
||||
super(detail ?? "The request is invalid");
|
||||
this.name = name || "ScimRequestError";
|
||||
this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"];
|
||||
@@ -74,4 +89,4 @@ export class ScimRequestError extends Error {
|
||||
this.detail = detail;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { TScimUser } from "./types";
|
||||
|
||||
export const createScimUser = ({
|
||||
userId,
|
||||
firstName,
|
||||
lastName,
|
||||
email
|
||||
}: {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
}): TScimUser => {
|
||||
let scimUser = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": userId,
|
||||
"userName": email,
|
||||
"displayName": `${firstName} ${lastName}`,
|
||||
"name": {
|
||||
"givenName": firstName,
|
||||
"middleName": null,
|
||||
"familyName": lastName
|
||||
},
|
||||
"emails":
|
||||
[{
|
||||
"primary": true,
|
||||
"value": email,
|
||||
"type": "work"
|
||||
}],
|
||||
"active": true,
|
||||
"groups": [],
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"location": null
|
||||
}
|
||||
};
|
||||
|
||||
return scimUser;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { TScimUser } from "./types";
|
||||
export {
|
||||
createScimUser
|
||||
} from "./fns";
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
export type TScimUser = {
|
||||
schemas: string[];
|
||||
id: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
name: {
|
||||
givenName: string;
|
||||
middleName: null;
|
||||
familyName: string;
|
||||
};
|
||||
emails: {
|
||||
primary: boolean;
|
||||
value: string;
|
||||
type: string;
|
||||
}[];
|
||||
active: boolean;
|
||||
groups: string[];
|
||||
meta: {
|
||||
resourceType: string;
|
||||
location: null;
|
||||
};
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import fp from "fastify-plugin";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
|
||||
import { TServiceTokens, TUsers } from "@app/db/schemas";
|
||||
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { UnauthorizedError } from "@app/lib/errors";
|
||||
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
|
||||
import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types";
|
||||
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
|
||||
|
||||
export type TAuthMode =
|
||||
| {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { ForbiddenError } from "@casl/ability";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError, ScimRequestError } from "@app/lib/errors";
|
||||
import {
|
||||
BadRequestError,
|
||||
DatabaseError,
|
||||
InternalServerError,
|
||||
ScimRequestError,
|
||||
UnauthorizedError
|
||||
} from "@app/lib/errors";
|
||||
|
||||
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
|
||||
server.setErrorHandler((error, req, res) => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal
|
||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
||||
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
||||
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||
import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
|
||||
import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal";
|
||||
import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal";
|
||||
import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||
@@ -32,8 +34,6 @@ import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snaps
|
||||
import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
|
||||
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
|
||||
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
|
||||
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||
import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { TQueueServiceFactory } from "@app/queue";
|
||||
import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal";
|
||||
@@ -191,13 +191,7 @@ export const registerRoutes = async (
|
||||
trustedIpDAL,
|
||||
permissionService
|
||||
});
|
||||
const scimService = scimServiceFactory({
|
||||
licenseService,
|
||||
scimDAL,
|
||||
userDAL,
|
||||
orgDAL,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const auditLogQueue = auditLogQueueServiceFactory({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
@@ -220,6 +214,14 @@ export const registerRoutes = async (
|
||||
samlConfigDAL,
|
||||
licenseService
|
||||
});
|
||||
const scimService = scimServiceFactory({
|
||||
licenseService,
|
||||
scimDAL,
|
||||
userDAL,
|
||||
orgDAL,
|
||||
permissionService,
|
||||
smtpService
|
||||
});
|
||||
|
||||
const telemetryService = telemetryServiceFactory();
|
||||
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
||||
|
||||
@@ -93,7 +93,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
.trim()
|
||||
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
||||
.optional(),
|
||||
authEnforced: z.boolean().optional()
|
||||
authEnforced: z.boolean().optional(),
|
||||
scimEnabled: z.boolean().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -165,7 +165,14 @@ export const orgDALFactory = (db: TDbClient) => {
|
||||
// eslint-disable-next-line
|
||||
.where(buildFindFilter(filter))
|
||||
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
|
||||
.select(selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users));
|
||||
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
|
||||
.select(
|
||||
selectAllTableCols(TableName.OrgMembership),
|
||||
db.ref("email").withSchema(TableName.Users),
|
||||
db.ref("firstName").withSchema(TableName.Users),
|
||||
db.ref("lastName").withSchema(TableName.Users),
|
||||
db.ref("scimEnabled").withSchema(TableName.Organization)
|
||||
);
|
||||
if (limit) void query.limit(limit);
|
||||
if (offset) void query.offset(offset);
|
||||
if (sort) {
|
||||
|
||||
21
backend/src/services/org/org-fns.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
|
||||
type TDeleteOrgMembership = {
|
||||
orgMembershipId: string;
|
||||
orgId: string;
|
||||
orgDAL: TOrgDALFactory;
|
||||
};
|
||||
|
||||
export const deleteOrgMembership = async ({ orgMembershipId, orgId, orgDAL }: TDeleteOrgMembership) => {
|
||||
// TODO: improve this implementation
|
||||
|
||||
// delete
|
||||
const m2 = await orgDAL.transaction(async (tx) => {
|
||||
const m1 = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx);
|
||||
// const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
|
||||
// delete project memberships
|
||||
return m1;
|
||||
});
|
||||
|
||||
return m2;
|
||||
};
|
||||
@@ -126,16 +126,32 @@ export const orgServiceFactory = ({
|
||||
actorId,
|
||||
actorOrgId,
|
||||
orgId,
|
||||
data: { name, slug, authEnforced }
|
||||
data: { name, slug, authEnforced, scimEnabled }
|
||||
}: TUpdateOrgDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
|
||||
if (authEnforced !== undefined) {
|
||||
if (!plan?.samlSSO)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to enforce/un-enforce SAML SSO due to plan restriction. Upgrade plan to enforce/un-enforce SAML SSO."
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||
}
|
||||
|
||||
if (authEnforced) {
|
||||
if (scimEnabled !== undefined) {
|
||||
if (!plan?.scim)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
|
||||
}
|
||||
|
||||
if (authEnforced || scimEnabled) {
|
||||
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
|
||||
if (!samlCfg)
|
||||
throw new BadRequestError({
|
||||
@@ -147,7 +163,8 @@ export const orgServiceFactory = ({
|
||||
const org = await orgDAL.updateById(orgId, {
|
||||
name,
|
||||
slug: slug ? slugify(slug) : undefined,
|
||||
authEnforced
|
||||
authEnforced,
|
||||
scimEnabled
|
||||
});
|
||||
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
||||
return org;
|
||||
|
||||
@@ -38,5 +38,5 @@ export type TFindAllWorkspacesDTO = {
|
||||
};
|
||||
|
||||
export type TUpdateOrgDTO = {
|
||||
data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
|
||||
data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>;
|
||||
} & TOrgPermission;
|
||||
|
||||
@@ -25,7 +25,8 @@ export enum SmtpTemplates {
|
||||
OrgInvite = "organizationInvitation.handlebars",
|
||||
ResetPassword = "passwordReset.handlebars",
|
||||
SecretLeakIncident = "secretLeakIncident.handlebars",
|
||||
WorkspaceInvite = "workspaceInvitation.handlebars"
|
||||
WorkspaceInvite = "workspaceInvitation.handlebars",
|
||||
ScimUserProvisioned = "scimUserProvisioned.handlebars"
|
||||
}
|
||||
|
||||
export enum SmtpHost {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Organization Invitation</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Join your organization on Infisical</h2>
|
||||
<p>You've been invited to join the Infisical organization — {{organizationName}}</p>
|
||||
<a href="{{callback_url}}">Join now</a>
|
||||
<h3>What is Infisical?</h3>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||
</body>
|
||||
</html>
|
||||
74
docs/documentation/platform/scim/azure.mdx
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "Azure SCIM"
|
||||
description: "Configure SCIM provisioning with Azure for Infisical"
|
||||
---
|
||||
|
||||
<Info>
|
||||
Azure SCIM provisioning is a paid feature.
|
||||
|
||||
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license to use it.
|
||||
</Info>
|
||||
|
||||
Prerequisites:
|
||||
- [Configure Azure SAML for Infisical](/documentation/platform/sso/azure)
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a SCIM token in Infisical">
|
||||
In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and
|
||||
press the **Enable SCIM provisioning** toggle to allow Azure to provision/deprovision users for your organization.
|
||||
|
||||

|
||||
|
||||
Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Azure.
|
||||
|
||||

|
||||
|
||||
Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Azure.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Configure SCIM in Azure">
|
||||
In Azure, head to your Enterprise Application > Provisioning > Overview and press **Get started**.
|
||||
|
||||

|
||||
|
||||
Next, set the following fields:
|
||||
|
||||
- Provisioning Mode: Select **Automatic**.
|
||||
- Tenant URL: Input **SCIM URL** from Step 1.
|
||||
- Secret Token: Input the **New SCIM Token** from Step 1.
|
||||
|
||||
Afterwards, press the **Test Connection** button to check that SCIM is configured properly.
|
||||
|
||||

|
||||
|
||||
After you hit **Save**, select **Provision Microsoft Entra ID Users** under the **Mappings** subsection.
|
||||
|
||||

|
||||
|
||||
Next, adjust the mappings so you have them configured as below:
|
||||
|
||||

|
||||
|
||||
Finally, head to your Enterprise Application > Provisioning and set the **Provisioning Status** to **On**.
|
||||
|
||||

|
||||
|
||||
Alternatively, you can go to **Overview** and press **Start provisioning** to have Azure start provisioning/deprovisioning users to Infisical.
|
||||
|
||||

|
||||
|
||||
Now Azure can provision/deprovision users to/from your organization in Infisical.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
**FAQ**
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||
Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
|
||||
|
||||
For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
64
docs/documentation/platform/scim/jumpcloud.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "JumpCloud SCIM"
|
||||
description: "Configure SCIM provisioning with JumpCloud for Infisical"
|
||||
---
|
||||
|
||||
<Info>
|
||||
JumpCloud SCIM provisioning is a paid feature.
|
||||
|
||||
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license to use it.
|
||||
</Info>
|
||||
|
||||
Prerequisites:
|
||||
- [Configure JumpCloud SAML for Infisical](/documentation/platform/sso/jumpcloud)
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a SCIM token in Infisical">
|
||||
In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and
|
||||
press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users for your organization.
|
||||
|
||||

|
||||
|
||||
Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for JumpCloud.
|
||||
|
||||

|
||||
|
||||
Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in JumpCloud.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Configure SCIM in JumpCloud">
|
||||
In JumpCloud, head to your Application > Identity Management > Configuration settings and make sure that
|
||||
**API Type** is set to **SCIM API** and **SCIM Version** is set to **SCIM 2.0**.
|
||||
|
||||

|
||||
|
||||
Next, set the following SCIM connection fields:
|
||||
|
||||
- Base URL: Input the **SCIM URL** from Step 1.
|
||||
- Token Key: Input the **New SCIM Token** from Step 1.
|
||||
- Test User Email: Input a test user email to be used by JumpCloud for testing the SCIM connection.
|
||||
|
||||
Alos, under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1.
|
||||
|
||||

|
||||
|
||||
Next, press **Test Connection** to check that SCIM is configured properly. Finally, press **Activate**
|
||||
to have JumpCloud start provisioning/deprovisioning users to Infisical.
|
||||
|
||||

|
||||
|
||||
Now JumpCloud can provision/deprovision users to/from your organization in Infisical.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
**FAQ**
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||
Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
|
||||
|
||||
For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
70
docs/documentation/platform/scim/okta.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Okta SCIM"
|
||||
description: "Configure SCIM provisioning with Okta for Infisical"
|
||||
---
|
||||
|
||||
<Info>
|
||||
Okta SCIM provisioning is a paid feature.
|
||||
|
||||
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license to use it.
|
||||
</Info>
|
||||
|
||||
Prerequisites:
|
||||
- [Configure Okta SAML for Infisical](/documentation/platform/sso/okta)
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a SCIM token in Infisical">
|
||||
In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and
|
||||
press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users for your organization.
|
||||
|
||||

|
||||
|
||||
Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Okta.
|
||||
|
||||

|
||||
|
||||
Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Okta.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Configure SCIM in Okta">
|
||||
In Okta, head to your Application > General > App Settings. Next, select **Edit** and check the box
|
||||
labled **Enable SCIM provisioning**.
|
||||
|
||||

|
||||
|
||||
Next, head to Provisioning > Integration and set the following SCIM connection fields:
|
||||
|
||||
- SCIM connector base URL: Input the **SCIM URL** from Step 1.
|
||||
- Unique identifier field for users: Input `email`.
|
||||
- Supported provisioning actions: Select **Push New Users** and **Push Profile Updates**.
|
||||
- Authentication Mode: `HTTP Header`.
|
||||
|
||||

|
||||
|
||||
Under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1.
|
||||
|
||||

|
||||
|
||||
Next, press **Test Connector Configuration** to check that SCIM is configured properly.
|
||||
|
||||

|
||||
|
||||
Next, head to Provisioning > To App and check the boxes labeled **Enable** for **Create Users**, **Update User Attributes**, and **Deactivate Users**.
|
||||
|
||||

|
||||
|
||||
Now Okta can provision/deprovision users to/from your organization in Infisical.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
**FAQ**
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||
Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
|
||||
|
||||
For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
32
docs/documentation/platform/scim/overview.mdx
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: "SCIM Overview"
|
||||
description: "Provision users for Infisical via SCIM"
|
||||
---
|
||||
|
||||
<Info>
|
||||
SCIM provisioning is a paid feature.
|
||||
|
||||
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license to use it.
|
||||
</Info>
|
||||
|
||||
You can configure your organization in Infisical to have members be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc.
|
||||
|
||||
- Provisioning: The SCIM provider pushes user information to Infisical. If the user exists in Infisical, Infisical sends an email invitation to add them to the relevant organization in Infisical; if not, Infisical initializes a new user and sends them an email invitation to finish setting up their account in the organization.
|
||||
- Deprovisioning: The SCIM provider instructs Infisical to remove user(s) from an organization in Infisical.
|
||||
|
||||
SCIM providers:
|
||||
|
||||
- [Okta SCIM](/documentation/platform/scim/okta)
|
||||
- [Azure SCIM](/documentation/platform/scim/azure)
|
||||
- [JumpCloud SCIM](/documentation/platform/scim/jumpcloud)
|
||||
|
||||
**FAQ**
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||
Infisical's SCIM implementation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
|
||||
|
||||
For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -12,7 +12,7 @@ description: "Configure Azure SAML for Infisical SSO"
|
||||
|
||||
<Steps>
|
||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||
In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
|
||||
Next, copy the **Reply URL (Assertion Consumer Service URL)** and **Identifier (Entity ID)** to use when configuring the Azure SAML application.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ description: "Configure JumpCloud SAML for Infisical SSO"
|
||||
|
||||
<Steps>
|
||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||
In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
|
||||
Next, copy the **ACS URL** and **SP Entity ID** to use when configuring the JumpCloud SAML application.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO"
|
||||
|
||||
<Steps>
|
||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||
In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
|
||||
Next, copy the **Single sign-on URL** and **Audience URI (SP Entity ID)** to use when configuring the Okta SAML 2.0 application.
|
||||

|
||||
|
||||
@@ -3,13 +3,13 @@ title: "SSO Overview"
|
||||
description: "Log in to Infisical via SSO protocols"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
<Info>
|
||||
Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted.
|
||||
|
||||
Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier
|
||||
or via enterprise license on self-hosted instances of Infisical. On this front, we support industry-leading providers including
|
||||
Okta, Azure AD, and JumpCloud; with any questions, please reach out to [sales@infisical.com](mailto:sales@infisical.com).
|
||||
</Warning>
|
||||
Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com.
|
||||
</Info>
|
||||
|
||||
You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0).
|
||||
|
||||
|
||||
BIN
docs/images/platform/scim/azure/scim-azure-config.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
docs/images/platform/scim/azure/scim-azure-get-started.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/images/platform/scim/azure/scim-azure-user-mappings.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png
Normal file
|
After Width: | Height: | Size: 513 KiB |
BIN
docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png
Normal file
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 440 KiB |
BIN
docs/images/platform/scim/okta/scim-okta-app-settings.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
docs/images/platform/scim/okta/scim-okta-auth.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
docs/images/platform/scim/okta/scim-okta-config.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
docs/images/platform/scim/okta/scim-okta-enable-provisioning.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
docs/images/platform/scim/okta/scim-okta-test.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
docs/images/platform/scim/scim-copy-token.png
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
docs/images/platform/scim/scim-create-token.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
docs/images/platform/scim/scim-enable-provisioning.png
Normal file
|
After Width: | Height: | Size: 618 KiB |
@@ -148,6 +148,15 @@
|
||||
"documentation/platform/sso/azure",
|
||||
"documentation/platform/sso/jumpcloud"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "SCIM",
|
||||
"pages": [
|
||||
"documentation/platform/scim/overview",
|
||||
"documentation/platform/scim/okta",
|
||||
"documentation/platform/scim/azure",
|
||||
"documentation/platform/scim/jumpcloud"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum OrgPermissionSubjects {
|
||||
Member = "member",
|
||||
Settings = "settings",
|
||||
IncidentAccount = "incident-contact",
|
||||
Scim = "scim",
|
||||
Sso = "sso",
|
||||
Billing = "billing",
|
||||
SecretScanning = "secret-scanning",
|
||||
@@ -26,6 +27,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Member]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
|
||||
@@ -71,12 +71,14 @@ export const useUpdateOrg = () => {
|
||||
mutationFn: ({
|
||||
name,
|
||||
authEnforced,
|
||||
scimEnabled,
|
||||
slug,
|
||||
orgId
|
||||
}) => {
|
||||
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
||||
name,
|
||||
authEnforced,
|
||||
scimEnabled,
|
||||
slug
|
||||
});
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export type Organization = {
|
||||
createAt: string;
|
||||
updatedAt: string;
|
||||
authEnforced: boolean;
|
||||
scimEnabled: boolean;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
@@ -11,6 +12,7 @@ export type UpdateOrgDTO = {
|
||||
orgId: string;
|
||||
name?: string;
|
||||
authEnforced?: boolean;
|
||||
scimEnabled?: boolean;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ export const useCreateScimToken = () => {
|
||||
mutationFn: async ({
|
||||
organizationId,
|
||||
description,
|
||||
ttl
|
||||
ttlDays
|
||||
}) => {
|
||||
const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", {
|
||||
organizationId,
|
||||
description,
|
||||
ttl
|
||||
ttlDays
|
||||
});
|
||||
|
||||
return data;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type ScimTokenData = {
|
||||
id: string;
|
||||
ttl: number;
|
||||
ttlDays: number;
|
||||
description: string;
|
||||
tokenSuffix: string;
|
||||
orgId: string;
|
||||
@@ -11,7 +11,7 @@ export type ScimTokenData = {
|
||||
export type CreateScimTokenDTO = {
|
||||
organizationId: string;
|
||||
description?: string;
|
||||
ttl?: number;
|
||||
ttlDays?: number;
|
||||
}
|
||||
|
||||
export type DeleteScimTokenDTO = {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Switch } from "@app/components/v2";
|
||||
import {
|
||||
Switch,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization
|
||||
useOrganization,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useLogoutUser,useUpdateOrg } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
|
||||
|
||||
export const OrgGeneralAuthSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { subscription } = useSubscription();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync } = useUpdateOrg();
|
||||
|
||||
@@ -19,6 +28,10 @@ export const OrgGeneralAuthSection = () => {
|
||||
const handleEnforceOrgAuthToggle = async (value: boolean) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
if (!subscription?.samlSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg?.id,
|
||||
@@ -60,6 +73,11 @@ export const OrgGeneralAuthSection = () => {
|
||||
</Switch>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { faPlus, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
// import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
// import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Switch,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
// OrgPermissionActions,
|
||||
// OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useSubscription
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useSubscription,
|
||||
useOrganization
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { ScimTokenModal } from "./ScimTokenModal";
|
||||
|
||||
// TODO: add permissioning for enteprise SCIM
|
||||
import { useUpdateOrg } from "@app/hooks/api";
|
||||
|
||||
export const OrgScimSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentOrg } = useOrganization();
|
||||
// const { createNotification } = useNotificationContext();
|
||||
const { subscription } = useSubscription();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"scimToken",
|
||||
"deleteScimToken",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const [scimEnabled, setScimEnabled] = useState(false); // sync this with backend
|
||||
|
||||
const { mutateAsync } = useUpdateOrg();
|
||||
|
||||
const addScimTokenBtnClick = () => {
|
||||
if (subscription?.scim) {
|
||||
@@ -41,11 +38,29 @@ export const OrgScimSection = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSCIMToggle = (value: boolean) => {
|
||||
const handleEnableSCIMToggle = async (value: boolean) => {
|
||||
try {
|
||||
setScimEnabled(value);
|
||||
if (!currentOrg?.id) return;
|
||||
if (!subscription?.scim) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg?.id,
|
||||
scimEnabled: value
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${value ? "enabled" : "disabled"} SCIM provisioning`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to ${value ? "enable" : "disable"} SCIM provisioning`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,23 +68,35 @@ export const OrgScimSection = () => {
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-8 flex items-center">
|
||||
<h2 className="flex-1 text-xl font-semibold text-white">SCIM Configuration</h2>
|
||||
<Button
|
||||
onClick={addScimTokenBtnClick}
|
||||
colorSchema="secondary"
|
||||
// isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Manage SCIM Tokens
|
||||
</Button>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Scim}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={addScimTokenBtnClick}
|
||||
colorSchema="secondary"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Manage SCIM Tokens
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-scim"
|
||||
onCheckedChange={(value) => handleSCIMToggle(value)}
|
||||
isChecked={scimEnabled}
|
||||
isDisabled={false}
|
||||
>
|
||||
Enable SCIM Provisioning
|
||||
</Switch>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
|
||||
<Switch
|
||||
id="enable-scim"
|
||||
onCheckedChange={(value) => {
|
||||
if (subscription?.scim) {
|
||||
handleEnableSCIMToggle(value)
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
isChecked={currentOrg?.scimEnabled ?? false}
|
||||
isDisabled={false}
|
||||
>
|
||||
Enable SCIM Provisioning
|
||||
</Switch>
|
||||
</OrgPermissionCan>
|
||||
<ScimTokenModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
@@ -78,7 +105,7 @@ export const OrgScimSection = () => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use SCIM Provisioning if you switch to Infisical's Pro plan."
|
||||
text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,11 +35,9 @@ import {
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { useOrganization } from "@app/context";
|
||||
|
||||
// TODO: turn TTL into a select component
|
||||
|
||||
const schema = yup.object({
|
||||
description: yup.string(),
|
||||
ttl: yup.string()
|
||||
ttlDays: yup.string().required("TTL is required")
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
@@ -86,7 +84,7 @@ export const ScimTokenModal = ({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
description: "",
|
||||
ttl: ""
|
||||
ttlDays: "365"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,14 +101,14 @@ export const ScimTokenModal = ({
|
||||
return () => clearTimeout(timer);
|
||||
}, [isScimTokenCopied, isScimUrlCopied]);
|
||||
|
||||
const onFormSubmit = async ({ description, ttl }: FormData) => {
|
||||
const onFormSubmit = async ({ description, ttlDays }: FormData) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const { scimToken } = await createScimTokenMutateAsync({
|
||||
organizationId: currentOrg.id,
|
||||
description,
|
||||
ttl: Number(ttl)
|
||||
organizationId: currentOrg.id,
|
||||
description,
|
||||
ttlDays: Number(ttlDays)
|
||||
});
|
||||
|
||||
setToken(scimToken);
|
||||
@@ -130,19 +128,12 @@ export const ScimTokenModal = ({
|
||||
|
||||
const onDeleteScimTokenSubmit = async (scimTokenId: string) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
await deleteScimTokenMutateAsync({
|
||||
organizationId: currentOrg.id,
|
||||
scimTokenId
|
||||
});
|
||||
|
||||
// TODO: find alt way
|
||||
|
||||
// if (token.startsWith(clientSecretPrefix)) {
|
||||
// reset();
|
||||
// setToken("");
|
||||
// }
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
await deleteScimTokenMutateAsync({
|
||||
organizationId: currentOrg.id,
|
||||
scimTokenId
|
||||
});
|
||||
|
||||
handlePopUpToggle("deleteScimToken", false);
|
||||
|
||||
@@ -242,11 +233,11 @@ export const ScimTokenModal = ({
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="ttl"
|
||||
defaultValue="365"
|
||||
name="ttlDays"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="TTL (seconds - optional)"
|
||||
label="TTL (days)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
@@ -287,19 +278,19 @@ export const ScimTokenModal = ({
|
||||
({
|
||||
id,
|
||||
description,
|
||||
ttl,
|
||||
ttlDays,
|
||||
createdAt
|
||||
}) => {
|
||||
|
||||
let expiresAt;
|
||||
if (ttl > 0) {
|
||||
expiresAt = new Date(new Date(createdAt).getTime() + ttl * 1000);
|
||||
if (ttlDays > 0) {
|
||||
expiresAt = new Date(new Date(createdAt).getTime() + ttlDays * 86400);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
|
||||
<Td>{description === "" ? "-" : description}</Td>
|
||||
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}</Td>
|
||||
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd HH:mm:ss") : "-"}</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
|
||||