diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index ef850bb8c..bd7a09d86 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -6,6 +6,7 @@ import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; +import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; @@ -105,6 +106,7 @@ declare module "fastify" { secretRotation: TSecretRotationServiceFactory; snapshot: TSecretSnapshotServiceFactory; saml: TSamlConfigServiceFactory; + scim: TScimServiceFactory; auditLog: TAuditLogServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5bf563810..5b5961558 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -83,6 +83,9 @@ import { TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, + TScimTokens, + TScimTokensInsert, + TScimTokensUpdate, TSecretApprovalPolicies, TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, @@ -262,6 +265,7 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [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 new file mode 100644 index 000000000..28121362a --- /dev/null +++ b/backend/src/db/migrations/20240208234120_scim-token.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +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("ttlDays").defaultTo(365).notNullable(); + t.string("description").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + 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/index.ts b/backend/src/db/schemas/index.ts index 62b01ebd2..b330c90b7 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -26,6 +26,7 @@ export * from "./project-memberships"; export * from "./project-roles"; export * from "./projects"; export * from "./saml-configs"; +export * from "./scim-tokens"; export * from "./secret-approval-policies"; export * from "./secret-approval-policies-approvers"; export * from "./secret-approval-request-secret-tags"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 4ef943bbe..e817d6ed0 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -40,6 +40,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", + ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", 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 new file mode 100644 index 000000000..593e4f7d9 --- /dev/null +++ b/backend/src/db/schemas/scim-tokens.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ScimTokensSchema = z.object({ + id: z.string(), + ttlDays: z.coerce.number().default(365), + description: z.string(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TScimTokens = z.infer; +export type TScimTokensInsert = Omit; +export type TScimTokensUpdate = Partial>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2ed439316..fb92aa3c5 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -3,6 +3,7 @@ import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; import { registerSamlRouter } from "./saml-router"; +import { registerScimRouter } from "./scim-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router"; import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router"; @@ -33,6 +34,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { prefix: "/secret-rotation-providers" }); await server.register(registerSamlRouter, { prefix: "/sso" }); + await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts new file mode 100644 index 000000000..095815212 --- /dev/null +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -0,0 +1,331 @@ +import { z } from "zod"; + +import { ScimTokensSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerScimRouter = async (server: FastifyZodProvider) => { + server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) { + try { + const strBody = body instanceof Buffer ? body.toString() : body; + + const json: unknown = JSON.parse(strBody); + done(null, json); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + + server.route({ + url: "/scim-tokens", + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + organizationId: z.string().trim(), + description: z.string().trim().default(""), + ttlDays: z.number().min(0).default(0) + }), + response: { + 200: z.object({ + scimToken: z.string().trim() + }) + } + }, + handler: async (req) => { + const { scimToken } = await server.services.scim.createScimToken({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + orgId: req.body.organizationId, + description: req.body.description, + ttlDays: req.body.ttlDays + }); + + return { scimToken }; + } + }); + + server.route({ + url: "/scim-tokens", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + querystring: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + scimTokens: z.array(ScimTokensSchema) + }) + } + }, + handler: async (req) => { + 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 }; + } + }); + + server.route({ + url: "/scim-tokens/:scimTokenId", + method: "DELETE", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + scimTokenId: z.string().trim() + }), + response: { + 200: z.object({ + scimToken: ScimTokensSchema + }) + } + }, + handler: async (req) => { + 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() + }), + 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; + } + }); +}; 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 011dffebe..2aaaf7562 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; + actor: UserActor | IdentityActor | ServiceActor | ScimClientActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -105,6 +105,8 @@ interface IdentityActorMetadata { name: string; } +interface ScimClientActorMetadata {} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -120,7 +122,12 @@ export interface IdentityActor { metadata: IdentityActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor; +export interface ScimClientActor { + type: ActorType.SCIM_CLIENT; + metadata: ScimClientActorMetadata; +} + +export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 7014eac5f..9b1d4c203 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,6 +24,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogs: false, auditLogsRetentionDays: 0, 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 762aff5b2..354714489 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -25,6 +25,7 @@ export type TFeatureSet = { auditLogs: false; auditLogsRetentionDays: 0; 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-dal.ts b/backend/src/ee/services/scim/scim-dal.ts new file mode 100644 index 000000000..05c21b80c --- /dev/null +++ b/backend/src/ee/services/scim/scim-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TScimDALFactory = ReturnType; + +export const scimDALFactory = (db: TDbClient) => { + const scimTokenOrm = ormify(db, TableName.ScimToken); + return scimTokenOrm; +}; 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 new file mode 100644 index 000000000..657d95a4a --- /dev/null +++ b/backend/src/ee/services/scim/scim-service.ts @@ -0,0 +1,430 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; +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 { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +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, + TCreateScimUserDTO, + TDeleteScimTokenDTO, + TGetScimUserDTO, + TListScimUsers, + TListScimUsersDTO, + TReplaceScimUserDTO, + TScimTokenJwtPayload, + TUpdateScimUserDTO +} from "./scim-types"; + +type TScimServiceFactoryDep = { + scimDAL: Pick; + userDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" + >; + projectDAL: Pick; + projectMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; + smtpService: TSmtpService; +}; + +export type TScimServiceFactory = ReturnType; + +export const scimServiceFactory = ({ + licenseService, + scimDAL, + userDAL, + orgDAL, + projectDAL, + projectMembershipDAL, + permissionService, + smtpService +}: TScimServiceFactoryDep) => { + 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(); + + const scimTokenData = await scimDAL.create({ + orgId, + description, + ttlDays + }); + + const scimToken = jwt.sign( + { + scimTokenId: scimTokenData.id, + authTokenType: AuthTokenType.SCIM_TOKEN + }, + appCfg.AUTH_SECRET + ); + + 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, 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()); + + let attributeName = parsedName; + if (parsedName === "userName") { + attributeName = "email"; + } + + return { [attributeName]: parsedValue }; + }; + + const findOpts = { + ...(offset && { offset }), + ...(limit && { limit }) + }; + + 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 + }); + }; + + const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { + 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 + }); + + return buildScimUser({ + userId: membership.userId as string, + firstName: membership.firstName as string, + lastName: membership.lastName as string, + email: membership.email, + active: true + }); + }; + + 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) { + 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}` + } + }); + + return buildScimUser({ + userId: user.id, + firstName: user.firstName as string, + lastName: user.lastName as string, + 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, + projectDAL, + projectMembershipDAL + }); + } + + 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, + projectDAL, + projectMembershipDAL + }); + } + + 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) => { + 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 * 1000; + 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 { + createScimToken, + listScimTokens, + deleteScimToken, + 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 new file mode 100644 index 000000000..9751d591f --- /dev/null +++ b/backend/src/ee/services/scim/scim-types.ts @@ -0,0 +1,87 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TCreateScimTokenDTO = { + description: string; + ttlDays: number; +} & TOrgPermission; + +export type TDeleteScimTokenDTO = { + scimTokenId: string; +} & Omit; + +// SCIM server endpoint types + +export type TListScimUsersDTO = { + offset: number; + limit: number; + filter?: string; + orgId: string; +}; + +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; +}; + +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; +}; + +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 b4376f007..d93244bbd 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -58,3 +58,35 @@ export class BadRequestError extends Error { this.error = 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; + }) { + super(detail ?? "The request is invalid"); + this.name = name || "ScimRequestError"; + this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"]; + this.error = error; + this.detail = detail; + this.status = status; + } +} diff --git a/backend/src/server/plugins/audit-log.ts b/backend/src/server/plugins/audit-log.ts index b42cc2ff2..de352a9bc 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -63,6 +63,11 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { identityId: req.auth.identityId } }; + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + payload.actor = { + type: ActorType.SCIM_CLIENT, + metadata: {} + }; } else { throw new BadRequestError({ message: "Missing logic for other actor" }); } diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 7abcd073c..cf8d9dea3 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -3,6 +3,7 @@ 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"; @@ -35,6 +36,12 @@ export type TAuthMode = actor: ActorType.IDENTITY; identityId: string; identityName: string; + } + | { + authMode: AuthMode.SCIM_TOKEN; + actor: ActorType.SCIM_CLIENT; + scimTokenId: string; + orgId: string; }; const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { @@ -55,6 +62,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { } const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload; + switch (decodedToken.authTokenType) { case AuthTokenType.ACCESS_TOKEN: return { @@ -70,6 +78,12 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { token: decodedToken as TIdentityAccessTokenJwtPayload, actor: ActorType.IDENTITY } as const; + case AuthTokenType.SCIM_TOKEN: + return { + authMode: AuthMode.SCIM_TOKEN, + token: decodedToken as TScimTokenJwtPayload, + actor: ActorType.SCIM_CLIENT + } as const; default: return { authMode: null, token: null } as const; } @@ -113,6 +127,11 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user }; break; } + case AuthMode.SCIM_TOKEN: { + const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId }; + break; + } default: throw new UnauthorizedError({ name: "Unknown token strategy" }); } diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 572814d64..2d61647e8 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -14,6 +14,8 @@ export const injectPermission = fp(async (server) => { req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId }; } else if (req.auth.actor === ActorType.SERVICE) { req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId }; + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + req.permission = { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId }; } }); }); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8587c93bd..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 } 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) => { @@ -21,6 +27,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: "PermissionDenied", message: `You are not allowed to ${error.action} on ${error.subjectType}` }); + } else if (error instanceof ScimRequestError) { + void res.status(error.status).send({ + schemas: error.schemas, + status: error.status, + detail: error.detail + }); } else { void res.send(error); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 28f65ea99..741e2b703 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"; @@ -155,6 +157,7 @@ export const registerRoutes = async ( const auditLogDAL = auditLogDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); + const scimDAL = scimDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -188,6 +191,7 @@ export const registerRoutes = async ( trustedIpDAL, permissionService }); + const auditLogQueue = auditLogQueueServiceFactory({ auditLogDAL, queueService, @@ -210,6 +214,16 @@ export const registerRoutes = async ( samlConfigDAL, licenseService }); + const scimService = scimServiceFactory({ + licenseService, + scimDAL, + userDAL, + orgDAL, + projectDAL, + projectMembershipDAL, + permissionService, + smtpService + }); const telemetryService = telemetryServiceFactory(); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); @@ -486,6 +500,7 @@ export const registerRoutes = async ( secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, + scim: scimService, secretBlindIndex: secretBlindIndexService, telemetry: telemetryService }); 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/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index bea0dbe10..b051952e7 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -17,21 +17,24 @@ export enum AuthTokenType { API_KEY = "apiKey", SERVICE_ACCESS_TOKEN = "serviceAccessToken", SERVICE_REFRESH_TOKEN = "serviceRefreshToken", - IDENTITY_ACCESS_TOKEN = "identityAccessToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", + SCIM_TOKEN = "scimToken" } export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", API_KEY = "apiKey", - IDENTITY_ACCESS_TOKEN = "identityAccessToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", + SCIM_TOKEN = "scimToken" } export enum ActorType { // would extend to AWS, Azure, ... USER = "user", // userIdentity SERVICE = "service", IDENTITY = "identity", - Machine = "machine" + Machine = "machine", + SCIM_CLIENT = "scimClient" } export type AuthModeJwtTokenPayload = { 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..ec6d4cb2d --- /dev/null +++ b/backend/src/services/org/org-fns.ts @@ -0,0 +1,41 @@ +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +type TDeleteOrgMembership = { + orgMembershipId: string; + orgId: string; + orgDAL: Pick; + projectDAL: Pick; + projectMembershipDAL: Pick; +}; + +export const deleteOrgMembership = async ({ + orgMembershipId, + orgId, + orgDAL, + projectDAL, + projectMembershipDAL +}: TDeleteOrgMembership) => { + const membership = await orgDAL.transaction(async (tx) => { + // delete org membership + const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); + + const projects = await projectDAL.find({ orgId }, { tx }); + + // delete associated project memberships + await projectMembershipDAL.delete( + { + $in: { + projectId: projects.map((project) => project.id) + }, + userId: orgMembership.userId as string + }, + tx + ); + + return orgMembership; + }); + + return membership; +}; 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..25303fed6 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. @@ -101,6 +101,11 @@ description: "Configure Azure SAML for Infisical SSO" To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Azure user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Azure + prior to enforcing SAML SSO to prevent any unintended issues. + diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index 2273a8852..d25ec0b6d 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. @@ -81,6 +81,11 @@ description: "Configure JumpCloud SAML for Infisical SSO" To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one JumpCloud user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in JumpCloud + prior to enforcing SAML SSO to prevent any unintended issues. + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 576bd769a..bac8efb68 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) @@ -84,6 +84,11 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Okta user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Okta + prior to enforcing SAML SSO to prevent any unintended issues. + 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/index.tsx b/frontend/src/hooks/api/index.tsx index 4caeaea48..d63a5e111 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -10,6 +10,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./roles"; +export * from "./scim"; export * from "./secretApproval"; export * from "./secretApprovalRequest"; export * from "./secretFolders"; 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/index.tsx b/frontend/src/hooks/api/scim/index.tsx new file mode 100644 index 000000000..1981659d1 --- /dev/null +++ b/frontend/src/hooks/api/scim/index.tsx @@ -0,0 +1,5 @@ +export { + useCreateScimToken, + useDeleteScimToken +} from "./mutations"; +export { useGetScimTokens } from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/mutations.tsx b/frontend/src/hooks/api/scim/mutations.tsx new file mode 100644 index 000000000..7469fb532 --- /dev/null +++ b/frontend/src/hooks/api/scim/mutations.tsx @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { scimKeys } from "./queries"; +import { + CreateScimTokenDTO, + CreateScimTokenRes, + DeleteScimTokenDTO +} from "./types"; + +export const useCreateScimToken = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + description, + ttlDays + }) => { + const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", { + organizationId, + description, + ttlDays + }); + + return data; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId)); + } + }); +}; + +export const useDeleteScimToken = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ scimTokenId }) => { + const { data } = await apiRequest.delete(`/api/v1/scim/scim-tokens/${scimTokenId}`); + return data; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/queries.tsx b/frontend/src/hooks/api/scim/queries.tsx new file mode 100644 index 000000000..f76018c01 --- /dev/null +++ b/frontend/src/hooks/api/scim/queries.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { ScimTokenData } from "./types"; + +export const scimKeys = { + getScimTokens: (orgId: string) => [{ orgId }, "organization-scim-token"] as const, +}; + +export const useGetScimTokens = (organizationId: string) => { + return useQuery({ + queryKey: scimKeys.getScimTokens(organizationId), + queryFn: async () => { + if (organizationId === "") { + return undefined; + } + + const { data: { scimTokens } } = await apiRequest.get<{ scimTokens: ScimTokenData[] }>(`/api/v1/scim/scim-tokens?organizationId=${organizationId}`); + + return scimTokens; + }, + enabled: true + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/types.ts b/frontend/src/hooks/api/scim/types.ts new file mode 100644 index 000000000..a373ba943 --- /dev/null +++ b/frontend/src/hooks/api/scim/types.ts @@ -0,0 +1,24 @@ +export type ScimTokenData = { + id: string; + ttlDays: number; + description: string; + tokenSuffix: string; + orgId: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateScimTokenDTO = { + organizationId: string; + description?: string; + ttlDays?: number; +} + +export type DeleteScimTokenDTO = { + organizationId: string; + scimTokenId: string; +} + +export type CreateScimTokenRes = { + scimToken: string; +} \ No newline at end of file diff --git a/frontend/src/hooks/api/ssoConfig/queries.tsx b/frontend/src/hooks/api/ssoConfig/queries.tsx index 9b75095b8..c294679f8 100644 --- a/frontend/src/hooks/api/ssoConfig/queries.tsx +++ b/frontend/src/hooks/api/ssoConfig/queries.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { organizationKeys } from "@app/hooks/api/organization/queries"; const ssoConfigKeys = { getSSOConfig: (orgId: string) => [{ orgId }, "organization-saml-sso"] as const @@ -82,8 +83,12 @@ export const useUpdateSSOConfig = () => { return data; }, - onSuccess(_, dto) { - queryClient.invalidateQueries(ssoConfigKeys.getSSOConfig(dto.organizationId)); + onSuccess(_, { organizationId, isActive }) { + if (isActive === false) { + queryClient.invalidateQueries(organizationKeys.getUserOrganizations); + } + + queryClient.invalidateQueries(ssoConfigKeys.getSSOConfig(organizationId)); } }); }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b975f0472..5680da02d 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -18,6 +18,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + scim: boolean; status: | "incomplete" | "incomplete_expired" diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index 246922b58..c73376aba 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -46,7 +46,7 @@ export const OrgMembersSection = () => { const handleAddMemberModal = () => { if (currentOrg?.authEnforced) { createNotification({ - text: "You cannot invite users when org-level auth is configured for your organization", + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", type: "error" }); return; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 5469bbf05..0f9cfe043 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -231,6 +231,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop {(isAllowed) => ( { + if (currentOrg?.authEnforced) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + handlePopUpOpen("removeMember", { orgMembershipId, email }); }} size="lg" diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index 4706c94c9..406bd569e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -81,6 +81,12 @@ const SIMPLE_PERMISSION_OPTIONS = [ subtitle: "Define organization level SSO requirements", icon: faSignIn, formName: "sso" + }, + { + title: "SCIM", + subtitle: "Define organization level SCIM requirements", + icon: faUsers, + formName: "scim" } ] as const; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts index 3590b2e20..415fa68df 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -34,6 +34,7 @@ export const formSchema = z.object({ "incident-contact": generalPermissionSchema, "secret-scanning": generalPermissionSchema, sso: generalPermissionSchema, + scim: generalPermissionSchema, billing: generalPermissionSchema, identity: generalPermissionSchema }) diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index c4d78b6f0..e1e0e0702 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -2,6 +2,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { withPermission } from "@app/hoc"; import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection"; +import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; export const OrgAuthTab = withPermission( @@ -10,6 +11,7 @@ export const OrgAuthTab = withPermission(
+
); }, diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx index 9bc026cf8..ef81a6cab 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 { useLogoutUser, useUpdateOrg } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; export const OrgGeneralAuthSection = () => { const { createNotification } = useNotificationContext(); const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, 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, @@ -39,7 +52,7 @@ export const OrgGeneralAuthSection = () => { } catch (err) { console.error(err); createNotification({ - text: `Failed to ${value ? "enforce" : "un-enforce"} org-level auth`, + text: (err as { response: { data: { message: string; }}}).response.data.message, type: "error" }); } @@ -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 new file mode 100644 index 000000000..041518d9f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx @@ -0,0 +1,113 @@ +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 { + Button, + Switch, + UpgradePlanModal +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription} from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { ScimTokenModal } from "./ScimTokenModal"; + +export const OrgScimSection = () => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "scimToken", + "deleteScimToken", + "upgradePlan" + ] as const); + + const { mutateAsync } = useUpdateOrg(); + + const addScimTokenBtnClick = () => { + if (subscription?.scim) { + handlePopUpOpen("scimToken"); + } else { + handlePopUpOpen("upgradePlan"); + } + } + + const handleEnableSCIMToggle = async (value: boolean) => { + try { + 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) { + createNotification({ + text: (err as { response: { data: { message: string; }}}).response.data.message, + type: "error" + }); + } + } + + return ( +
+
+

SCIM Configuration

+ + {(isAllowed) => ( + + )} + +
+ + {(isAllowed) => ( + { + if (subscription?.scim) { + handleEnableSCIMToggle(value) + } else { + handlePopUpOpen("upgradePlan"); + } + }} + isChecked={currentOrg?.scimEnabled ?? false} + isDisabled={!isAllowed} + > + Enable SCIM Provisioning + + )} + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan." + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx new file mode 100644 index 000000000..e7d4b1063 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx @@ -0,0 +1,343 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { format } from "date-fns"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + useCreateScimToken, + useDeleteScimToken, + useGetScimTokens} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + description: yup.string(), + ttlDays: yup.string().required("TTL is required") +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["scimToken", "deleteScimToken"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteScimToken"]>, + data?: { + scimTokenId: string; + } + ) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState< + ["scimToken", "deleteScimToken"] + >, + state?: boolean + ) => void; +}; + +export const ScimTokenModal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const { currentOrg } = useOrganization(); + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const [token, setToken] = useState(""); + + const [isScimUrlCopied, setIsScimUrlCopied] = useToggle(false); + const [isScimTokenCopied, setIsScimTokenCopied] = useToggle(false); + + const { data, isLoading } = useGetScimTokens(currentOrg?.id ?? ""); + const { mutateAsync: createScimTokenMutateAsync } = useCreateScimToken(); + const { mutateAsync: deleteScimTokenMutateAsync } = useDeleteScimToken(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + description: "", + ttlDays: "365" + } + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isScimUrlCopied) { + timer = setTimeout(() => setIsScimUrlCopied.off(), 2000); + } + + if (isScimTokenCopied) { + timer = setTimeout(() => setIsScimTokenCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isScimTokenCopied, isScimUrlCopied]); + + const onFormSubmit = async ({ description, ttlDays }: FormData) => { + try { + if (!currentOrg?.id) return; + + const { scimToken } = await createScimTokenMutateAsync({ + organizationId: currentOrg.id, + description, + ttlDays: Number(ttlDays) + }); + + setToken(scimToken); + + createNotification({ + text: "Successfully created SCIM token", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SCIM token", + type: "error" + }); + } + }; + + const onDeleteScimTokenSubmit = async (scimTokenId: string) => { + try { + if (!currentOrg?.id) return; + + await deleteScimTokenMutateAsync({ + organizationId: currentOrg.id, + scimTokenId + }); + + handlePopUpToggle("deleteScimToken", false); + + createNotification({ + text: "Successfully deleted SCIM token", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SCIM token", + type: "error" + }); + } + }; + + const hasToken = Boolean(token); + const scimUrl = `${window.origin}/api/v1/scim`; + + return ( + { + handlePopUpToggle("scimToken", isOpen); + reset(); + setToken(""); + }} + > + +

SCIM URL

+
+

{scimUrl}

+ { + navigator.clipboard.writeText(scimUrl); + setIsScimUrlCopied.on(); + }} + > + + + {t("common.click-to-copy")} + + +
+

New SCIM Token

+ {hasToken ? ( +
+
+

We will only show this token once

+ +
+
+

{token}

+ { + navigator.clipboard.writeText(token); + setIsScimTokenCopied.on(); + }} + > + + + {t("common.click-to-copy")} + + +
+
+ ) : ( +
+ ( + + + + )} + /> + ( + +
+ + +
+
+ )} + /> + + )} +

SCIM Tokens

+ + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map( + ({ + id, + description, + ttlDays, + createdAt + }) => { + + let expiresAt; + if (ttlDays > 0) { + expiresAt = new Date(new Date(createdAt).getTime() + ttlDays * 86400 * 1000); + } + + return ( + + + + + + + ); + } + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
DescriptionExpires AtCreated At +
{description === "" ? "-" : description}{expiresAt ? format(expiresAt, "yyyy-MM-dd HH:mm:ss") : "-"}{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")} + { + handlePopUpOpen("deleteScimToken", { + scimTokenId: id + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ handlePopUpToggle("scimToken", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + + const deleteScimTokenData = popUp?.deleteScimToken?.data as { + scimTokenId: string; + }; + + return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId); + }} + /> +
+
+ ); +};