diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 1b4e82176..5b5961558 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -265,11 +265,7 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; - [TableName.ScimToken]: Knex.CompositeTableType< - TScimTokens, - TScimTokensInsert, - TScimTokensUpdate - >; + [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, TSecretApprovalPoliciesInsert, diff --git a/backend/src/db/migrations/20240208234120_scim-token.ts b/backend/src/db/migrations/20240208234120_scim-token.ts index 92364dba2..28121362a 100644 --- a/backend/src/db/migrations/20240208234120_scim-token.ts +++ b/backend/src/db/migrations/20240208234120_scim-token.ts @@ -7,7 +7,7 @@ export async function up(knex: Knex): Promise { 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 { }); } + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("scimEnabled").defaultTo(false); + }); + await createOnUpdateTrigger(knex, TableName.ScimToken); } export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.ScimToken); await dropOnUpdateTrigger(knex, TableName.ScimToken); + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("scimEnabled"); + }); } diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 087a1b7e0..a91b93ea5 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -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; diff --git a/backend/src/db/schemas/scim-tokens.ts b/backend/src/db/schemas/scim-tokens.ts index ac391ca91..593e4f7d9 100644 --- a/backend/src/db/schemas/scim-tokens.ts +++ b/backend/src/db/schemas/scim-tokens.ts @@ -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; diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 2e2edac43..6116599f1 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -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 {}; + // } + // }); }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index dfa98b0e4..4ed86b292 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -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; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index d41e45778..9b1d4c203 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -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, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index bc83f43f0..354714489 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -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; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index cc18af8ae..e527f1a4a 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -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); diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 767729179..ad33051fb 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -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; }; diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts new file mode 100644 index 000000000..b5f35c9ee --- /dev/null +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -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 + } + }; +}; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 12eb2e94a..b6f6d9769 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -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; + // TODO: pick types scimDAL: TScimDALFactory; // TODO: pick userDAL: TUserDALFactory; // TODO: pick orgDAL: TOrgDALFactory; // TODO: pick licenseService: Pick; + permissionService: Pick; + smtpService: TSmtpService; }; export type TScimServiceFactory = ReturnType; -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 => { + 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 => { - - 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 }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index a10e9cc8e..9751d591f 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -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; +export type TDeleteScimTokenDTO = { + scimTokenId: string; +} & Omit; + +// 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; -}; \ No newline at end of file + 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; + }; +}; diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index a46d02066..d93244bbd 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -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; } -} \ No newline at end of file +} diff --git a/backend/src/lib/scim/fns.ts b/backend/src/lib/scim/fns.ts deleted file mode 100644 index 430dd8fbb..000000000 --- a/backend/src/lib/scim/fns.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/backend/src/lib/scim/index.ts b/backend/src/lib/scim/index.ts deleted file mode 100644 index 8ab53e080..000000000 --- a/backend/src/lib/scim/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { TScimUser } from "./types"; -export { - createScimUser -} from "./fns"; \ No newline at end of file diff --git a/backend/src/lib/scim/types.ts b/backend/src/lib/scim/types.ts deleted file mode 100644 index f439c004e..000000000 --- a/backend/src/lib/scim/types.ts +++ /dev/null @@ -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; - }; -} \ No newline at end of file diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 7066764ff..cf8d9dea3 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -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 = | { diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index ac1912030..c8da4077a 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -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) => { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b07799217..e55704748 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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 }); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index bfda652f0..6eff558fb 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -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({ diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index e914aac42..bb3cbbd95 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -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) { diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts new file mode 100644 index 000000000..9ad4d5ddb --- /dev/null +++ b/backend/src/services/org/org-fns.ts @@ -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; +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 7fafca438..1d78ef0b9 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -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; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 01b3c8e37..bbb325247 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -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; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 142993ceb..7ebeaa227 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -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 { diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars new file mode 100644 index 000000000..b1482aa17 --- /dev/null +++ b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars @@ -0,0 +1,16 @@ + + + + + + + Organization Invitation + + +

Join your organization on Infisical

+

You've been invited to join the Infisical organization — {{organizationName}}

+ Join now +

What is Infisical?

+

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + \ No newline at end of file diff --git a/docs/documentation/platform/scim/azure.mdx b/docs/documentation/platform/scim/azure.mdx new file mode 100644 index 000000000..45f95e135 --- /dev/null +++ b/docs/documentation/platform/scim/azure.mdx @@ -0,0 +1,74 @@ +--- +title: "Azure SCIM" +description: "Configure SCIM provisioning with Azure for Infisical" +--- + + + 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. + + +Prerequisites: +- [Configure Azure SAML for Infisical](/documentation/platform/sso/azure) + + + + 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. + + ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) + + Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Azure. + + ![SCIM create token](/images/platform/scim/scim-create-token.png) + + Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Azure. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + In Azure, head to your Enterprise Application > Provisioning > Overview and press **Get started**. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-get-started.png) + + 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. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-config.png) + + After you hit **Save**, select **Provision Microsoft Entra ID Users** under the **Mappings** subsection. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-select-user-mappings.png) + + Next, adjust the mappings so you have them configured as below: + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-user-mappings.png) + + Finally, head to your Enterprise Application > Provisioning and set the **Provisioning Status** to **On**. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-provisioning-status.png) + + Alternatively, you can go to **Overview** and press **Start provisioning** to have Azure start provisioning/deprovisioning users to Infisical. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-start-provisioning.png) + + Now Azure can provision/deprovision users to/from your organization in Infisical. + + + +**FAQ** + + + + 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. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/jumpcloud.mdx b/docs/documentation/platform/scim/jumpcloud.mdx new file mode 100644 index 000000000..68bb9b66f --- /dev/null +++ b/docs/documentation/platform/scim/jumpcloud.mdx @@ -0,0 +1,64 @@ +--- +title: "JumpCloud SCIM" +description: "Configure SCIM provisioning with JumpCloud for Infisical" +--- + + + 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. + + +Prerequisites: +- [Configure JumpCloud SAML for Infisical](/documentation/platform/sso/jumpcloud) + + + + 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. + + ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) + + Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for JumpCloud. + + ![SCIM create token](/images/platform/scim/scim-create-token.png) + + Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in JumpCloud. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + 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**. + + ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png) + + 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. + + ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-config.png) + + Next, press **Test Connection** to check that SCIM is configured properly. Finally, press **Activate** + to have JumpCloud start provisioning/deprovisioning users to Infisical. + + ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png) + + Now JumpCloud can provision/deprovision users to/from your organization in Infisical. + + + +**FAQ** + + + + 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. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/okta.mdx b/docs/documentation/platform/scim/okta.mdx new file mode 100644 index 000000000..4baa19815 --- /dev/null +++ b/docs/documentation/platform/scim/okta.mdx @@ -0,0 +1,70 @@ +--- +title: "Okta SCIM" +description: "Configure SCIM provisioning with Okta for Infisical" +--- + + + 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. + + +Prerequisites: +- [Configure Okta SAML for Infisical](/documentation/platform/sso/okta) + + + + 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. + + ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) + + Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Okta. + + ![SCIM create token](/images/platform/scim/scim-create-token.png) + + Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Okta. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + In Okta, head to your Application > General > App Settings. Next, select **Edit** and check the box + labled **Enable SCIM provisioning**. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-enable-provisioning.png) + + 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`. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-config.png) + + Under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-auth.png) + + Next, press **Test Connector Configuration** to check that SCIM is configured properly. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-test.png) + + Next, head to Provisioning > To App and check the boxes labeled **Enable** for **Create Users**, **Update User Attributes**, and **Deactivate Users**. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-app-settings.png) + + Now Okta can provision/deprovision users to/from your organization in Infisical. + + + +**FAQ** + + + + 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. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx new file mode 100644 index 000000000..deec8b630 --- /dev/null +++ b/docs/documentation/platform/scim/overview.mdx @@ -0,0 +1,32 @@ +--- +title: "SCIM Overview" +description: "Provision users for Infisical via SCIM" +--- + + + 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. + + +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** + + + + 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. + + \ No newline at end of file diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 0f644834a..35f287cdf 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -12,7 +12,7 @@ description: "Configure Azure SAML for Infisical SSO" - 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. diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index 2273a8852..0366bbf7f 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -12,7 +12,7 @@ description: "Configure JumpCloud SAML for Infisical SSO" - 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. diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 576bd769a..354cc1800 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -12,7 +12,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" - 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. ![Okta SAML initial configuration](../../../images/sso/okta/init-config.png) diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 8f4b3bb0f..cd2f8ff31 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -3,13 +3,13 @@ title: "SSO Overview" description: "Log in to Infisical via SSO protocols" --- - + 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). - + Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com. + 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). diff --git a/docs/images/platform/scim/azure/scim-azure-config.png b/docs/images/platform/scim/azure/scim-azure-config.png new file mode 100644 index 000000000..5255c3a8d Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-config.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-get-started.png b/docs/images/platform/scim/azure/scim-azure-get-started.png new file mode 100644 index 000000000..c97574673 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-get-started.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-provisioning-status.png b/docs/images/platform/scim/azure/scim-azure-provisioning-status.png new file mode 100644 index 000000000..d457a1170 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-provisioning-status.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-select-user-mappings.png b/docs/images/platform/scim/azure/scim-azure-select-user-mappings.png new file mode 100644 index 000000000..2654f86fe Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-select-user-mappings.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-start-provisioning.png b/docs/images/platform/scim/azure/scim-azure-start-provisioning.png new file mode 100644 index 000000000..949474a49 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-start-provisioning.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-user-mappings.png b/docs/images/platform/scim/azure/scim-azure-user-mappings.png new file mode 100644 index 000000000..b96ab6cf7 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-user-mappings.png differ diff --git a/docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png new file mode 100644 index 000000000..b10fd099d Binary files /dev/null and b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png differ diff --git a/docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png new file mode 100644 index 000000000..1c42729a7 Binary files /dev/null and b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png differ diff --git a/docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png new file mode 100644 index 000000000..e1980fdfb Binary files /dev/null and b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-app-settings.png b/docs/images/platform/scim/okta/scim-okta-app-settings.png new file mode 100644 index 000000000..a3ea836ec Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-app-settings.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-auth.png b/docs/images/platform/scim/okta/scim-okta-auth.png new file mode 100644 index 000000000..97ad34567 Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-auth.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-config.png b/docs/images/platform/scim/okta/scim-okta-config.png new file mode 100644 index 000000000..b20ceddca Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-config.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-enable-provisioning.png b/docs/images/platform/scim/okta/scim-okta-enable-provisioning.png new file mode 100644 index 000000000..d5688182e Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-enable-provisioning.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-test.png b/docs/images/platform/scim/okta/scim-okta-test.png new file mode 100644 index 000000000..f1c2e9221 Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-test.png differ diff --git a/docs/images/platform/scim/scim-copy-token.png b/docs/images/platform/scim/scim-copy-token.png new file mode 100644 index 000000000..d3a4586c2 Binary files /dev/null and b/docs/images/platform/scim/scim-copy-token.png differ diff --git a/docs/images/platform/scim/scim-create-token.png b/docs/images/platform/scim/scim-create-token.png new file mode 100644 index 000000000..9fe4eac3e Binary files /dev/null and b/docs/images/platform/scim/scim-create-token.png differ diff --git a/docs/images/platform/scim/scim-enable-provisioning.png b/docs/images/platform/scim/scim-enable-provisioning.png new file mode 100644 index 000000000..a4385244f Binary files /dev/null and b/docs/images/platform/scim/scim-enable-provisioning.png differ diff --git a/docs/mint.json b/docs/mint.json index b67493aeb..32ded21fc 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -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" + ] } ] }, diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index f0fbd23eb..8a7393a17 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -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] diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 018fa1edb..441aa591c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -71,12 +71,14 @@ export const useUpdateOrg = () => { mutationFn: ({ name, authEnforced, + scimEnabled, slug, orgId }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, + scimEnabled, slug }); }, diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 6492e3515..9c26ce02b 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -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; }; diff --git a/frontend/src/hooks/api/scim/mutations.tsx b/frontend/src/hooks/api/scim/mutations.tsx index d0646b98b..3ac5ab3a7 100644 --- a/frontend/src/hooks/api/scim/mutations.tsx +++ b/frontend/src/hooks/api/scim/mutations.tsx @@ -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; diff --git a/frontend/src/hooks/api/scim/types.ts b/frontend/src/hooks/api/scim/types.ts index 814ce6013..a373ba943 100644 --- a/frontend/src/hooks/api/scim/types.ts +++ b/frontend/src/hooks/api/scim/types.ts @@ -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 = { diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx index 9bc026cf8..9e2b6f085 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx @@ -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 = () => { )} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can enforce SAML SSO if you switch to Infisical's Pro plan." + /> ); } \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx index 0c69552d0..ea496748b 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx @@ -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 = () => {

SCIM Configuration

- + + {(isAllowed) => ( + + )} +
- handleSCIMToggle(value)} - isChecked={scimEnabled} - isDisabled={false} - > - Enable SCIM Provisioning - + + { + if (subscription?.scim) { + handleEnableSCIMToggle(value) + } else { + handlePopUpOpen("upgradePlan"); + } + }} + isChecked={currentOrg?.scimEnabled ?? false} + isDisabled={false} + > + Enable SCIM Provisioning + + { 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." />
); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx index 329900b14..586b07049 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx @@ -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; @@ -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 = ({ /> ( @@ -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 ( {description === "" ? "-" : description} - {expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"} + {expiresAt ? format(expiresAt, "yyyy-MM-dd HH:mm:ss") : "-"} {format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}