misc: unbinded scim from saml

This commit is contained in:
Sheen Capadngan
2024-11-23 01:59:07 +08:00
parent 1a084d8fcf
commit 7c62a776fb
5 changed files with 175 additions and 14 deletions
+43 -11
View File
@@ -18,6 +18,7 @@ import { TGroupProjectDALFactory } from "@app/services/group-project/group-proje
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; import { deleteOrgMembershipFn } from "@app/services/org/org-fns";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-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 { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
@@ -71,6 +72,7 @@ type TScimServiceFactoryDep = {
| "deleteMembershipById" | "deleteMembershipById"
| "transaction" | "transaction"
| "updateMembershipById" | "updateMembershipById"
| "findOrgById"
>; >;
orgMembershipDAL: Pick< orgMembershipDAL: Pick<
TOrgMembershipDALFactory, TOrgMembershipDALFactory,
@@ -288,8 +290,7 @@ export const scimServiceFactory = ({
const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => {
if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 }); 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) if (!org)
throw new ScimRequestError({ throw new ScimRequestError({
detail: "Organization not found", detail: "Organization not found",
@@ -302,13 +303,24 @@ export const scimServiceFactory = ({
status: 403 status: 403
}); });
if (!org.orgAuthMethod) {
throw new ScimRequestError({
detail: "Neither SAML or OIDC SSO is configured",
status: 400
});
}
const appCfg = getConfig(); const appCfg = getConfig();
const serverCfg = await getServerCfg(); 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({ const userAlias = await userAliasDAL.findOne({
externalId, externalId,
orgId, orgId,
aliasType: UserAliasType.SAML aliasType
}); });
const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => {
@@ -349,7 +361,7 @@ export const scimServiceFactory = ({
); );
} }
} else { } else {
if (serverCfg.trustSamlEmails) { if (trustScimEmails) {
user = await userDAL.findOne( user = await userDAL.findOne(
{ {
email, email,
@@ -367,9 +379,9 @@ export const scimServiceFactory = ({
); );
user = await userDAL.create( user = await userDAL.create(
{ {
username: serverCfg.trustSamlEmails ? email : uniqueUsername, username: trustScimEmails ? email : uniqueUsername,
email, email,
isEmailVerified: serverCfg.trustSamlEmails, isEmailVerified: trustScimEmails,
firstName, firstName,
lastName, lastName,
authMethods: [], authMethods: [],
@@ -382,7 +394,7 @@ export const scimServiceFactory = ({
await userAliasDAL.create( await userAliasDAL.create(
{ {
userId: user.id, userId: user.id,
aliasType: UserAliasType.SAML, aliasType,
externalId, externalId,
emails: email ? [email] : [], emails: email ? [email] : [],
orgId orgId
@@ -437,7 +449,7 @@ export const scimServiceFactory = ({
recipients: [email], recipients: [email],
substitutions: { substitutions: {
organizationName: org.name, 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 // partial
const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { 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 const [membership] = await orgDAL
.findMembership({ .findMembership({
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
@@ -493,6 +513,9 @@ export const scimServiceFactory = ({
scimPatch(scimUser, operations); scimPatch(scimUser, operations);
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
const trustScimEmails =
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails;
await userDAL.transaction(async (tx) => { await userDAL.transaction(async (tx) => {
await orgMembershipDAL.updateById( await orgMembershipDAL.updateById(
membership.id, membership.id,
@@ -508,7 +531,7 @@ export const scimServiceFactory = ({
firstName: scimUser.name.givenName, firstName: scimUser.name.givenName,
email: scimUser.emails[0].value, email: scimUser.emails[0].value,
lastName: scimUser.name.familyName, lastName: scimUser.name.familyName,
isEmailVerified: hasEmailChanged ? serverCfg.trustSamlEmails : true isEmailVerified: hasEmailChanged ? trustScimEmails : true
}, },
tx tx
); );
@@ -526,6 +549,14 @@ export const scimServiceFactory = ({
email, email,
externalId externalId
}: TReplaceScimUserDTO) => { }: 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 const [membership] = await orgDAL
.findMembership({ .findMembership({
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
@@ -555,7 +586,7 @@ export const scimServiceFactory = ({
await userAliasDAL.update( await userAliasDAL.update(
{ {
orgId, orgId,
aliasType: UserAliasType.SAML, aliasType: org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML,
userId: membership.userId userId: membership.userId
}, },
{ {
@@ -576,7 +607,8 @@ export const scimServiceFactory = ({
firstName, firstName,
email, email,
lastName, lastName,
isEmailVerified: serverCfg.trustSamlEmails isEmailVerified:
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
}, },
tx tx
); );
+41 -1
View File
@@ -14,10 +14,12 @@ import { Strategy as GoogleStrategy } from "passport-google-oauth20";
import { z } from "zod"; import { z } from "zod";
import { getConfig } from "@app/lib/config/env"; 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 { logger } from "@app/lib/logger";
import { fetchGithubEmails } from "@app/lib/requests/github"; import { fetchGithubEmails } from "@app/lib/requests/github";
import { authRateLimit } from "@app/server/config/rateLimiter";
import { AuthMethod } from "@app/services/auth/auth-type"; import { AuthMethod } from "@app/services/auth/auth-type";
import { OrgAuthMethod } from "@app/services/org/org-types";
export const registerSsoRouter = async (server: FastifyZodProvider) => { export const registerSsoRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -196,6 +198,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
handler: () => {} 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({ server.route({
url: "/github", url: "/github",
method: "GET", method: "GET",
+69 -1
View File
@@ -14,6 +14,8 @@ import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex";
import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim";
import { OrgAuthMethod } from "./org-types";
export type TOrgDALFactory = ReturnType<typeof orgDALFactory>; export type TOrgDALFactory = ReturnType<typeof orgDALFactory>;
export const orgDALFactory = (db: TDbClient) => { export const orgDALFactory = (db: TDbClient) => {
@@ -21,13 +23,78 @@ export const orgDALFactory = (db: TDbClient) => {
const findOrgById = async (orgId: string) => { const findOrgById = async (orgId: string) => {
try { 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; return org;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find org by id" }); 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 // special query
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => { const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
try { try {
@@ -398,6 +465,7 @@ export const orgDALFactory = (db: TDbClient) => {
findAllOrgMembers, findAllOrgMembers,
countAllOrgMembers, countAllOrgMembers,
findOrgById, findOrgById,
findOrgBySlug,
findAllOrgsByUserId, findAllOrgsByUserId,
ghostUserExists, ghostUserExists,
findOrgMembersByUsername, findOrgMembersByUsername,
+17 -1
View File
@@ -187,6 +187,15 @@ export const orgServiceFactory = ({
return members; 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 findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => {
const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id)); 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); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const plan = await licenseService.getPlan(orgId); const plan = await licenseService.getPlan(orgId);
const currentOrg = await orgDAL.findOrgById(actorOrgId);
if (enforceMfa !== undefined) { if (enforceMfa !== undefined) {
if (!plan.enforceMfa) { 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." "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); 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) { if (authEnforced) {
@@ -1132,6 +1147,7 @@ export const orgServiceFactory = ({
createIncidentContact, createIncidentContact,
deleteIncidentContact, deleteIncidentContact,
getOrgGroups, getOrgGroups,
listProjectMembershipsByOrgMembershipId listProjectMembershipsByOrgMembershipId,
findOrgBySlug
}; };
}; };
+5
View File
@@ -74,3 +74,8 @@ export type TGetOrgGroupsDTO = TOrgPermission;
export type TListProjectMembershipsByOrgMembershipIdDTO = { export type TListProjectMembershipsByOrgMembershipIdDTO = {
orgMembershipId: string; orgMembershipId: string;
} & TOrgPermission; } & TOrgPermission;
export enum OrgAuthMethod {
OIDC = "oidc",
SAML = "saml"
}