From b4fda6a1f62a329bffd0e0a634ca033ec759c255 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 18 Apr 2025 17:04:16 -0400 Subject: [PATCH] add default mapping policy and move auto provision --- server/auth/actions.ts | 3 +- server/db/schemas/schema.ts | 14 +- server/routers/idp/createOidcIdp.ts | 2 +- server/routers/idp/updateOidcIdp.ts | 86 +++-- server/routers/idp/validateOidcCallback.ts | 379 +++++++++++--------- src/app/admin/idp/[idpId]/policies/page.tsx | 248 ++++++++++--- 6 files changed, 475 insertions(+), 257 deletions(-) diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 59492017..009d5c21 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -65,8 +65,7 @@ export enum ActionsEnum { listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", listOrgDomains = "listOrgDomains", - createNewt = "createNewt", - createIdp = "createIdp" + createNewt = "createNewt" } export async function checkUserActionPermission( diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index 207237aa..42510c19 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -425,7 +425,14 @@ export const supporterKey = sqliteTable("supporterKey", { export const idp = sqliteTable("idp", { idpId: integer("idpId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), - type: text("type").notNull() + type: text("type").notNull(), + defaultRoleMapping: text("defaultRoleMapping"), + defaultOrgMapping: text("defaultOrgMapping"), + autoProvision: integer("autoProvision", { + mode: "boolean" + }) + .notNull() + .default(false) }); // Identity Provider OAuth Configuration @@ -440,11 +447,6 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { clientSecret: text("clientSecret").notNull(), authUrl: text("authUrl").notNull(), tokenUrl: text("tokenUrl").notNull(), - autoProvision: integer("autoProvision", { - mode: "boolean" - }) - .notNull() - .default(false), identifierPath: text("identifierPath").notNull(), emailPath: text("emailPath"), namePath: text("namePath"), diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index a8e7767d..555895f3 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -91,6 +91,7 @@ export async function createOidcIdp( .insert(idp) .values({ name, + autoProvision, type: "oidc" }) .returning(); @@ -103,7 +104,6 @@ export async function createOidcIdp( clientSecret: encryptedSecret, authUrl, tokenUrl, - autoProvision, scopes, identifierPath, emailPath, diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index cf43cdfc..4eba73d2 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -20,16 +20,18 @@ const paramsSchema = z const bodySchema = z .object({ - name: z.string().nonempty(), - clientId: z.string().nonempty(), - clientSecret: z.string().nonempty(), - authUrl: z.string().url(), - tokenUrl: z.string().url(), - identifierPath: z.string().nonempty(), + name: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + authUrl: z.string().optional(), + tokenUrl: z.string().optional(), + identifierPath: z.string().optional(), emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z.string().optional(), - autoProvision: z.boolean().optional() + autoProvision: z.boolean().optional(), + defaultRoleMapping: z.string().optional(), + defaultOrgMapping: z.string().optional() }) .strict(); @@ -92,7 +94,9 @@ export async function updateOidcIdp( emailPath, namePath, name, - autoProvision + autoProvision, + defaultRoleMapping, + defaultOrgMapping } = parsedBody.data; // Check if IDP exists and is of type OIDC @@ -115,33 +119,51 @@ export async function updateOidcIdp( } const key = config.getRawConfig().server.secret; - const encryptedSecret = encrypt(clientSecret, key); - const encryptedClientId = encrypt(clientId, key); + const encryptedSecret = clientSecret + ? encrypt(clientSecret, key) + : undefined; + const encryptedClientId = clientId ? encrypt(clientId, key) : undefined; await db.transaction(async (trx) => { - // Update IDP name - await trx - .update(idp) - .set({ - name - }) - .where(eq(idp.idpId, idpId)); + const idpData = { + name, + autoProvision, + defaultRoleMapping, + defaultOrgMapping + }; - // Update OIDC config - await trx - .update(idpOidcConfig) - .set({ - clientId: encryptedClientId, - clientSecret: encryptedSecret, - authUrl, - tokenUrl, - autoProvision, - scopes, - identifierPath, - emailPath, - namePath - }) - .where(eq(idpOidcConfig.idpId, idpId)); + // only update if at least one key is not undefined + let keysToUpdate = Object.keys(idpData).filter( + (key) => idpData[key as keyof typeof idpData] !== undefined + ); + + if (keysToUpdate.length > 0) { + await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId)); + } + + const configData = { + clientId: encryptedClientId, + clientSecret: encryptedSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath + }; + + keysToUpdate = Object.keys(configData).filter( + (key) => + configData[key as keyof typeof configData] !== undefined + ); + + if (keysToUpdate.length > 0) { + // Update OIDC config + await trx + .update(idpOidcConfig) + .set(configData) + .where(eq(idpOidcConfig.idpId, idpId)); + } }); return response(res, { diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 1a78fb18..45e9fd7c 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -213,188 +213,229 @@ export async function validateOidcCallback( ) ); - const idpOrgs = await db - .select() - .from(idpOrg) - .where(eq(idpOrg.idpId, existingIdp.idp.idpId)); - - let userOrgInfo: { orgId: string; roleId: number }[] = []; - for (const idpOrg of idpOrgs) { - let roleId: number | undefined = undefined; - - if (idpOrg.orgMapping) { - const orgId = jmespath.search(claims, idpOrg.orgMapping); - if (!orgId) { - continue; - } - } - - if (idpOrg.roleMapping) { - const roleName = jmespath.search(claims, idpOrg.roleMapping); - - if (!roleName) { - logger.error("Role name not found in the ID token", { - roleName - }); - continue; - } - - const [roleRes] = await db - .select() - .from(roles) - .where( - and( - eq(roles.orgId, idpOrg.orgId), - eq(roles.name, roleName) - ) - ); - - if (!roleRes) { - logger.error("Role not found", { - orgId: idpOrg.orgId, - roleName - }); - continue; - } - - roleId = roleRes.roleId; - - userOrgInfo.push({ - orgId: idpOrg.orgId, - roleId - }); - } - } - - logger.debug("User org info", { userOrgInfo }); - - let existingUserId = existingUser?.userId; - - // sync the user with the orgs and roles - await db.transaction(async (trx) => { - let userId = existingUser?.userId; - - // create user if not exists - if (!existingUser) { - userId = generateId(15); - - await trx.insert(users).values({ - userId, - username: userIdentifier, - email: email || null, - name: name || null, - type: UserType.OIDC, - idpId: existingIdp.idp.idpId, - emailVerified: true, // OIDC users are always verified - dateCreated: new Date().toISOString() - }); - } else { - // set the name and email - await trx - .update(users) - .set({ - username: userIdentifier, - email: email || null, - name: name || null - }) - .where(eq(users.userId, userId)); - } - - existingUserId = userId; - - // get all current user orgs - const currentUserOrgs = await trx + if (existingIdp.idp.autoProvision) { + const idpOrgs = await db .select() - .from(userOrgs) - .where(eq(userOrgs.userId, userId)); + .from(idpOrg) + .where(eq(idpOrg.idpId, existingIdp.idp.idpId)); - // Delete orgs that are no longer valid - const orgsToDelete = currentUserOrgs.filter( - (currentOrg) => - !userOrgInfo.some( - (newOrg) => newOrg.orgId === currentOrg.orgId - ) - ); + const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; + const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; - if (orgsToDelete.length > 0) { - await trx.delete(userOrgs).where( - and( - eq(userOrgs.userId, userId), - inArray( - userOrgs.orgId, - orgsToDelete.map((org) => org.orgId) - ) - ) - ); - } + let userOrgInfo: { orgId: string; roleId: number }[] = []; + for (const idpOrg of idpOrgs) { + let roleId: number | undefined = undefined; - // Update roles for existing orgs where the role has changed - const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { - const newOrg = userOrgInfo.find( - (newOrg) => newOrg.orgId === currentOrg.orgId - ); - return newOrg && newOrg.roleId !== currentOrg.roleId; - }); + const orgMapping = idpOrg.orgMapping || defaultOrgMapping; + const hydratedOrgMapping = orgMapping + ?.split("{{orgId}}") + .join(idpOrg.orgId); - if (orgsToUpdate.length > 0) { - for (const org of orgsToUpdate) { - const newRole = userOrgInfo.find( - (newOrg) => newOrg.orgId === org.orgId - ); - if (newRole) { - await trx - .update(userOrgs) - .set({ roleId: newRole.roleId }) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, org.orgId) - ) - ); + if (hydratedOrgMapping) { + const orgId = jmespath.search(claims, hydratedOrgMapping); + if (!(orgId === true || orgId === idpOrg.orgId)) { + continue; } } + + const roleMapping = idpOrg.roleMapping || defaultRoleMapping; + if (roleMapping) { + const roleName = jmespath.search(claims, roleMapping); + + if (!roleName) { + logger.error("Role name not found in the ID token", { + roleName + }); + continue; + } + + const [roleRes] = await db + .select() + .from(roles) + .where( + and( + eq(roles.orgId, idpOrg.orgId), + eq(roles.name, roleName) + ) + ); + + if (!roleRes) { + logger.error("Role not found", { + orgId: idpOrg.orgId, + roleName + }); + continue; + } + + roleId = roleRes.roleId; + + userOrgInfo.push({ + orgId: idpOrg.orgId, + roleId + }); + } } - // Add new orgs that don't exist yet - const orgsToAdd = userOrgInfo.filter( - (newOrg) => - !currentUserOrgs.some( - (currentOrg) => currentOrg.orgId === newOrg.orgId - ) + logger.debug("User org info", { userOrgInfo }); + + let existingUserId = existingUser?.userId; + + // sync the user with the orgs and roles + await db.transaction(async (trx) => { + let userId = existingUser?.userId; + + // create user if not exists + if (!existingUser) { + userId = generateId(15); + + await trx.insert(users).values({ + userId, + username: userIdentifier, + email: email || null, + name: name || null, + type: UserType.OIDC, + idpId: existingIdp.idp.idpId, + emailVerified: true, // OIDC users are always verified + dateCreated: new Date().toISOString() + }); + } else { + // set the name and email + await trx + .update(users) + .set({ + username: userIdentifier, + email: email || null, + name: name || null + }) + .where(eq(users.userId, userId)); + } + + existingUserId = userId; + + // get all current user orgs + const currentUserOrgs = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.userId, userId)); + + // Delete orgs that are no longer valid + const orgsToDelete = currentUserOrgs.filter( + (currentOrg) => + !userOrgInfo.some( + (newOrg) => newOrg.orgId === currentOrg.orgId + ) + ); + + if (orgsToDelete.length > 0) { + await trx.delete(userOrgs).where( + and( + eq(userOrgs.userId, userId), + inArray( + userOrgs.orgId, + orgsToDelete.map((org) => org.orgId) + ) + ) + ); + } + + // Update roles for existing orgs where the role has changed + const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { + const newOrg = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + return newOrg && newOrg.roleId !== currentOrg.roleId; + }); + + if (orgsToUpdate.length > 0) { + for (const org of orgsToUpdate) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === org.orgId + ); + if (newRole) { + await trx + .update(userOrgs) + .set({ roleId: newRole.roleId }) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, org.orgId) + ) + ); + } + } + } + + // Add new orgs that don't exist yet + const orgsToAdd = userOrgInfo.filter( + (newOrg) => + !currentUserOrgs.some( + (currentOrg) => currentOrg.orgId === newOrg.orgId + ) + ); + + if (orgsToAdd.length > 0) { + await trx.insert(userOrgs).values( + orgsToAdd.map((org) => ({ + userId, + orgId: org.orgId, + roleId: org.roleId, + dateCreated: new Date().toISOString() + })) + ); + } + }); + + const token = generateSessionToken(); + const sess = await createSession(token, existingUserId); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(sess.expiresAt) ); - if (orgsToAdd.length > 0) { - await trx.insert(userOrgs).values( - orgsToAdd.map((org) => ({ - userId, - orgId: org.orgId, - roleId: org.roleId, - dateCreated: new Date().toISOString() - })) + res.appendHeader("Set-Cookie", cookie); + + return response(res, { + data: { + redirectUrl: postAuthRedirectUrl + }, + success: true, + error: false, + message: "OIDC callback validated successfully", + status: HttpCode.CREATED + }); + } else { + if (!existingUser) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User not found in the IdP" + ) ); } - }); - - const token = generateSessionToken(); - const sess = await createSession(token, existingUserId); - const isSecure = req.protocol === "https"; - const cookie = serializeSessionCookie( - token, - isSecure, - new Date(sess.expiresAt) - ); - - res.appendHeader("Set-Cookie", cookie); - - return response(res, { - data: { - redirectUrl: postAuthRedirectUrl - }, - success: true, - error: false, - message: "OIDC callback validated successfully", - status: HttpCode.CREATED - }); + // + // const token = generateSessionToken(); + // const sess = await createSession(token, existingUser.userId); + // const isSecure = req.protocol === "https"; + // const cookie = serializeSessionCookie( + // token, + // isSecure, + // new Date(sess.expiresAt) + // ); + // + // res.appendHeader("Set-Cookie", cookie); + // + // return response(res, { + // data: { + // redirectUrl: postAuthRedirectUrl + // }, + // success: true, + // error: false, + // message: "OIDC callback validated successfully", + // status: HttpCode.CREATED + // }); + } } catch (error) { logger.error(error); return next( diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 121f6a85..11f28b6c 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -32,7 +32,6 @@ import { z } from "zod"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; import PolicyTable, { PolicyRow } from "./PolicyTable"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { AxiosResponse } from "axios"; import { ListOrgsResponse } from "@server/routers/org"; import { @@ -53,6 +52,17 @@ import { CaretSortIcon } from "@radix-ui/react-icons"; import Link from "next/link"; import { Textarea } from "@app/components/ui/textarea"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { GetIdpResponse } from "@server/routers/idp"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionFooter, + SettingsSectionForm +} from "@app/components/Settings"; type Organization = { orgId: string; @@ -65,7 +75,13 @@ const policyFormSchema = z.object({ orgMapping: z.string().optional() }); +const defaultMappingsSchema = z.object({ + defaultRoleMapping: z.string().optional(), + defaultOrgMapping: z.string().optional() +}); + type PolicyFormValues = z.infer; +type DefaultMappingsValues = z.infer; export default function PoliciesPage() { const { env } = useEnvContext(); @@ -88,6 +104,35 @@ export default function PoliciesPage() { } }); + const defaultMappingsForm = useForm({ + resolver: zodResolver(defaultMappingsSchema), + defaultValues: { + defaultRoleMapping: "", + defaultOrgMapping: "" + } + }); + + const loadIdp = async () => { + try { + const res = await api.get>( + `/idp/${idpId}` + ); + if (res.status === 200) { + const data = res.data.data; + defaultMappingsForm.reset({ + defaultRoleMapping: data.idp.defaultRoleMapping || "", + defaultOrgMapping: data.idp.defaultOrgMapping || "" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + const loadPolicies = async () => { try { const res = await api.get(`/idp/${idpId}/org`); @@ -126,6 +171,7 @@ export default function PoliciesPage() { async function load() { setLoading(true); await loadPolicies(); + await loadIdp(); setLoading(false); } load(); @@ -222,52 +268,160 @@ export default function PoliciesPage() { } }; + const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { + try { + const res = await api.post(`/idp/${idpId}/oidc`, { + defaultRoleMapping: data.defaultRoleMapping, + defaultOrgMapping: data.defaultOrgMapping + }); + if (res.status === 200) { + toast({ + title: "Success", + description: "Default mappings updated successfully" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + if (loading) { return null; } return ( <> - - - - About Organization Policies - - - Organization policies are used to control access to - organizations based on the user's ID token. You can specify - JMESPath expressions to extract role and organization - information from the ID token. For more information, see{" "} - - the documentation - - - - + + + + + About Organization Policies + + + Organization policies are used to control access to + organizations based on the user's ID token. You can + specify JMESPath expressions to extract role and + organization information from the ID token. For more + information, see{" "} + + the documentation + + + + - { - loadOrganizations(); - setEditingPolicy(null); - setShowAddDialog(true); - }} - onEdit={(policy) => { - setEditingPolicy(policy); - form.reset({ - orgId: policy.orgId, - roleMapping: policy.roleMapping || "", - orgMapping: policy.orgMapping || "" - }); - setShowAddDialog(true); - }} - /> + + + + Default Mappings (Optional) + + + The default mappings are used when when there is not + an organization policy defined for an organization. + You can specify the default role and organization + mappings to fall back to here. + + + +
+ +
+ ( + + + Default Role Mapping + + + + + + JMESPath to extract role + information from the ID + token. The result of this + expression must return the + role name as defined in the + organization as a string. + + + + )} + /> + + ( + + + Default Organization Mapping + + + + + + JMESPath to extract + organization information + from the ID token. This + expression must return true + for the user to be allowed + to access the organization. + + + + )} + /> +
+
+ + + + +
+
+ + { + loadOrganizations(); + setEditingPolicy(null); + setShowAddDialog(true); + }} + onEdit={(policy) => { + setEditingPolicy(policy); + form.reset({ + orgId: policy.orgId, + roleMapping: policy.roleMapping || "", + orgMapping: policy.orgMapping || "" + }); + setShowAddDialog(true); + }} + /> +
-