diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 0f814d2ac..271c9aa9a 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -18,6 +18,7 @@ import { TGroupProjectDALFactory } from "@app/services/group-project/group-proje import { TOrgDALFactory } from "@app/services/org/org-dal"; import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; +import { OrgAuthMethod } from "@app/services/org/org-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -71,6 +72,7 @@ type TScimServiceFactoryDep = { | "deleteMembershipById" | "transaction" | "updateMembershipById" + | "findOrgById" >; orgMembershipDAL: Pick< TOrgMembershipDALFactory, @@ -288,8 +290,7 @@ export const scimServiceFactory = ({ const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 }); - const org = await orgDAL.findById(orgId); - + const org = await orgDAL.findOrgById(orgId); if (!org) throw new ScimRequestError({ detail: "Organization not found", @@ -302,13 +303,24 @@ export const scimServiceFactory = ({ status: 403 }); + if (!org.orgAuthMethod) { + throw new ScimRequestError({ + detail: "Neither SAML or OIDC SSO is configured", + status: 400 + }); + } + const appCfg = getConfig(); const serverCfg = await getServerCfg(); + const aliasType = org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML; + const trustScimEmails = + org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails; + const userAlias = await userAliasDAL.findOne({ externalId, orgId, - aliasType: UserAliasType.SAML + aliasType }); const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { @@ -349,7 +361,7 @@ export const scimServiceFactory = ({ ); } } else { - if (serverCfg.trustSamlEmails) { + if (trustScimEmails) { user = await userDAL.findOne( { email, @@ -367,9 +379,9 @@ export const scimServiceFactory = ({ ); user = await userDAL.create( { - username: serverCfg.trustSamlEmails ? email : uniqueUsername, + username: trustScimEmails ? email : uniqueUsername, email, - isEmailVerified: serverCfg.trustSamlEmails, + isEmailVerified: trustScimEmails, firstName, lastName, authMethods: [], @@ -382,7 +394,7 @@ export const scimServiceFactory = ({ await userAliasDAL.create( { userId: user.id, - aliasType: UserAliasType.SAML, + aliasType, externalId, emails: email ? [email] : [], orgId @@ -437,7 +449,7 @@ export const scimServiceFactory = ({ recipients: [email], substitutions: { organizationName: org.name, - callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}` + callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/organizations/${org.slug}` } }); } @@ -456,6 +468,14 @@ export const scimServiceFactory = ({ // partial const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { + const org = await orgDAL.findOrgById(orgId); + if (!org.orgAuthMethod) { + throw new ScimRequestError({ + detail: "Neither SAML or OIDC SSO is configured", + status: 400 + }); + } + const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -493,6 +513,9 @@ export const scimServiceFactory = ({ scimPatch(scimUser, operations); const serverCfg = await getServerCfg(); + const trustScimEmails = + org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails; + await userDAL.transaction(async (tx) => { await orgMembershipDAL.updateById( membership.id, @@ -508,7 +531,7 @@ export const scimServiceFactory = ({ firstName: scimUser.name.givenName, email: scimUser.emails[0].value, lastName: scimUser.name.familyName, - isEmailVerified: hasEmailChanged ? serverCfg.trustSamlEmails : true + isEmailVerified: hasEmailChanged ? trustScimEmails : true }, tx ); @@ -526,6 +549,14 @@ export const scimServiceFactory = ({ email, externalId }: TReplaceScimUserDTO) => { + const org = await orgDAL.findOrgById(orgId); + if (!org.orgAuthMethod) { + throw new ScimRequestError({ + detail: "Neither SAML or OIDC SSO is configured", + status: 400 + }); + } + const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -555,7 +586,7 @@ export const scimServiceFactory = ({ await userAliasDAL.update( { orgId, - aliasType: UserAliasType.SAML, + aliasType: org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML, userId: membership.userId }, { @@ -576,7 +607,8 @@ export const scimServiceFactory = ({ firstName, email, lastName, - isEmailVerified: serverCfg.trustSamlEmails + isEmailVerified: + org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails }, tx ); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 9007ca828..9b3ef6528 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -14,10 +14,12 @@ import { Strategy as GoogleStrategy } from "passport-google-oauth20"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { fetchGithubEmails } from "@app/lib/requests/github"; +import { authRateLimit } from "@app/server/config/rateLimiter"; import { AuthMethod } from "@app/services/auth/auth-type"; +import { OrgAuthMethod } from "@app/services/org/org-types"; export const registerSsoRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -196,6 +198,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { handler: () => {} }); + server.route({ + url: "/redirect/organizations/:orgSlug", + method: "GET", + config: { + rateLimit: authRateLimit + }, + schema: { + params: z.object({ + orgSlug: z.string().trim() + }), + querystring: z.object({ + callback_port: z.string().optional() + }) + }, + handler: async (req, res) => { + const org = await server.services.org.findOrgBySlug(req.params.orgSlug); + if (org.orgAuthMethod === OrgAuthMethod.SAML) { + return res.redirect( + `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}?${ + req.query.callback_port ? `callback_port=${req.query.callback_port}` : "" + }` + ); + } + + if (org.orgAuthMethod === OrgAuthMethod.OIDC) { + return res.redirect( + `${appCfg.SITE_URL}/api/v1/sso/oidc/login?orgSlug=${org.slug}${ + req.query.callback_port ? `&callbackPort=${req.query.callback_port}` : "" + }` + ); + } + + throw new BadRequestError({ + message: "The organization does not have any SSO configured." + }); + } + }); + server.route({ url: "/github", method: "GET", diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 24f1d55b0..221840e1b 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -14,6 +14,8 @@ import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; +import { OrgAuthMethod } from "./org-types"; + export type TOrgDALFactory = ReturnType; export const orgDALFactory = (db: TDbClient) => { @@ -21,13 +23,78 @@ export const orgDALFactory = (db: TDbClient) => { const findOrgById = async (orgId: string) => { try { - const org = await db.replicaNode()(TableName.Organization).where({ id: orgId }).first(); + const org = (await db + .replicaNode()(TableName.Organization) + .where({ [`${TableName.Organization}.id` as "id"]: orgId }) + .leftJoin(TableName.SamlConfig, (qb) => { + qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( + `${TableName.SamlConfig}.isActive`, + "=", + db.raw("true") + ); + }) + .leftJoin(TableName.OidcConfig, (qb) => { + qb.on(`${TableName.OidcConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( + `${TableName.OidcConfig}.isActive`, + "=", + db.raw("true") + ); + }) + .select(selectAllTableCols(TableName.Organization)) + .select( + db.raw(` + CASE + WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}' + WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}' + ELSE '' + END as "orgAuthMethod" + `) + ) + .first()) as TOrganizations & { orgAuthMethod?: string }; + return org; } catch (error) { throw new DatabaseError({ error, name: "Find org by id" }); } }; + const findOrgBySlug = async (orgSlug: string) => { + try { + const org = (await db + .replicaNode()(TableName.Organization) + .where({ [`${TableName.Organization}.slug` as "slug"]: orgSlug }) + .leftJoin(TableName.SamlConfig, (qb) => { + qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( + `${TableName.SamlConfig}.isActive`, + "=", + db.raw("true") + ); + }) + .leftJoin(TableName.OidcConfig, (qb) => { + qb.on(`${TableName.OidcConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( + `${TableName.OidcConfig}.isActive`, + "=", + db.raw("true") + ); + }) + .select(selectAllTableCols(TableName.Organization)) + .select( + db.raw(` + CASE + WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}' + WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}' + ELSE '' + END as "orgAuthMethod" + `) + ) + .first()) as TOrganizations & { orgAuthMethod?: string }; + + return org; + } catch (error) { + throw new DatabaseError({ error, name: "Find org by slug" }); + } + }; + // special query const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => { try { @@ -398,6 +465,7 @@ export const orgDALFactory = (db: TDbClient) => { findAllOrgMembers, countAllOrgMembers, findOrgById, + findOrgBySlug, findAllOrgsByUserId, ghostUserExists, findOrgMembersByUsername, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 1d656c4d5..9741220f8 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -187,6 +187,15 @@ export const orgServiceFactory = ({ return members; }; + const findOrgBySlug = async (slug: string) => { + const org = await orgDAL.findOrgBySlug(slug); + if (!org) { + throw new NotFoundError({ message: `Organization with slug '${slug}' not found` }); + } + + return org; + }; + const findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => { const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id)); @@ -275,6 +284,7 @@ export const orgServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); const plan = await licenseService.getPlan(orgId); + const currentOrg = await orgDAL.findOrgById(actorOrgId); if (enforceMfa !== undefined) { if (!plan.enforceMfa) { @@ -305,6 +315,11 @@ export const orgServiceFactory = ({ "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 (scimEnabled && !currentOrg.orgAuthMethod) { + throw new BadRequestError({ + message: "Cannot enable SCIM when neither SAML or OIDC is configured." + }); + } } if (authEnforced) { @@ -1132,6 +1147,7 @@ export const orgServiceFactory = ({ createIncidentContact, deleteIncidentContact, getOrgGroups, - listProjectMembershipsByOrgMembershipId + listProjectMembershipsByOrgMembershipId, + findOrgBySlug }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 7d4a203de..05df9429e 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,3 +74,8 @@ export type TGetOrgGroupsDTO = TOrgPermission; export type TListProjectMembershipsByOrgMembershipIdDTO = { orgMembershipId: string; } & TOrgPermission; + +export enum OrgAuthMethod { + OIDC = "oidc", + SAML = "saml" +} diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx index 232df95a1..5b8507961 100644 --- a/docs/documentation/platform/scim/overview.mdx +++ b/docs/documentation/platform/scim/overview.mdx @@ -3,11 +3,15 @@ title: "SCIM Overview" description: "Learn how to provision users for Infisical via SCIM." --- + + SCIM provisioning can only be enabled when either SAML or OIDC is setup for + the organization. + - 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 sales@infisical.com to purchase an enterprise license to use it. + 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 sales@infisical.com to purchase an enterprise license + to use it. You can configure your organization in Infisical to have users and user groups be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc. @@ -20,13 +24,3 @@ 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