add default mapping policy and move auto provision

This commit is contained in:
miloschwartz 2025-04-18 17:04:16 -04:00
parent 99188233db
commit b4fda6a1f6
No known key found for this signature in database
6 changed files with 475 additions and 257 deletions

View file

@ -65,8 +65,7 @@ export enum ActionsEnum {
listResourceRules = "listResourceRules", listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule", updateResourceRule = "updateResourceRule",
listOrgDomains = "listOrgDomains", listOrgDomains = "listOrgDomains",
createNewt = "createNewt", createNewt = "createNewt"
createIdp = "createIdp"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(

View file

@ -425,7 +425,14 @@ export const supporterKey = sqliteTable("supporterKey", {
export const idp = sqliteTable("idp", { export const idp = sqliteTable("idp", {
idpId: integer("idpId").primaryKey({ autoIncrement: true }), idpId: integer("idpId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), 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 // Identity Provider OAuth Configuration
@ -440,11 +447,6 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
clientSecret: text("clientSecret").notNull(), clientSecret: text("clientSecret").notNull(),
authUrl: text("authUrl").notNull(), authUrl: text("authUrl").notNull(),
tokenUrl: text("tokenUrl").notNull(), tokenUrl: text("tokenUrl").notNull(),
autoProvision: integer("autoProvision", {
mode: "boolean"
})
.notNull()
.default(false),
identifierPath: text("identifierPath").notNull(), identifierPath: text("identifierPath").notNull(),
emailPath: text("emailPath"), emailPath: text("emailPath"),
namePath: text("namePath"), namePath: text("namePath"),

View file

@ -91,6 +91,7 @@ export async function createOidcIdp(
.insert(idp) .insert(idp)
.values({ .values({
name, name,
autoProvision,
type: "oidc" type: "oidc"
}) })
.returning(); .returning();
@ -103,7 +104,6 @@ export async function createOidcIdp(
clientSecret: encryptedSecret, clientSecret: encryptedSecret,
authUrl, authUrl,
tokenUrl, tokenUrl,
autoProvision,
scopes, scopes,
identifierPath, identifierPath,
emailPath, emailPath,

View file

@ -20,16 +20,18 @@ const paramsSchema = z
const bodySchema = z const bodySchema = z
.object({ .object({
name: z.string().nonempty(), name: z.string().optional(),
clientId: z.string().nonempty(), clientId: z.string().optional(),
clientSecret: z.string().nonempty(), clientSecret: z.string().optional(),
authUrl: z.string().url(), authUrl: z.string().optional(),
tokenUrl: z.string().url(), tokenUrl: z.string().optional(),
identifierPath: z.string().nonempty(), identifierPath: z.string().optional(),
emailPath: z.string().optional(), emailPath: z.string().optional(),
namePath: z.string().optional(), namePath: z.string().optional(),
scopes: z.string().optional(), scopes: z.string().optional(),
autoProvision: z.boolean().optional() autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional()
}) })
.strict(); .strict();
@ -92,7 +94,9 @@ export async function updateOidcIdp(
emailPath, emailPath,
namePath, namePath,
name, name,
autoProvision autoProvision,
defaultRoleMapping,
defaultOrgMapping
} = parsedBody.data; } = parsedBody.data;
// Check if IDP exists and is of type OIDC // Check if IDP exists and is of type OIDC
@ -115,33 +119,51 @@ export async function updateOidcIdp(
} }
const key = config.getRawConfig().server.secret; const key = config.getRawConfig().server.secret;
const encryptedSecret = encrypt(clientSecret, key); const encryptedSecret = clientSecret
const encryptedClientId = encrypt(clientId, key); ? encrypt(clientSecret, key)
: undefined;
const encryptedClientId = clientId ? encrypt(clientId, key) : undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update IDP name const idpData = {
await trx name,
.update(idp) autoProvision,
.set({ defaultRoleMapping,
name defaultOrgMapping
}) };
.where(eq(idp.idpId, idpId));
// Update OIDC config // only update if at least one key is not undefined
await trx let keysToUpdate = Object.keys(idpData).filter(
.update(idpOidcConfig) (key) => idpData[key as keyof typeof idpData] !== undefined
.set({ );
clientId: encryptedClientId,
clientSecret: encryptedSecret, if (keysToUpdate.length > 0) {
authUrl, await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId));
tokenUrl, }
autoProvision,
scopes, const configData = {
identifierPath, clientId: encryptedClientId,
emailPath, clientSecret: encryptedSecret,
namePath authUrl,
}) tokenUrl,
.where(eq(idpOidcConfig.idpId, idpId)); 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<UpdateIdpResponse>(res, { return response<UpdateIdpResponse>(res, {

View file

@ -213,188 +213,229 @@ export async function validateOidcCallback(
) )
); );
const idpOrgs = await db if (existingIdp.idp.autoProvision) {
.select() const idpOrgs = await db
.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
.select() .select()
.from(userOrgs) .from(idpOrg)
.where(eq(userOrgs.userId, userId)); .where(eq(idpOrg.idpId, existingIdp.idp.idpId));
// Delete orgs that are no longer valid const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const orgsToDelete = currentUserOrgs.filter( const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
(currentOrg) =>
!userOrgInfo.some(
(newOrg) => newOrg.orgId === currentOrg.orgId
)
);
if (orgsToDelete.length > 0) { let userOrgInfo: { orgId: string; roleId: number }[] = [];
await trx.delete(userOrgs).where( for (const idpOrg of idpOrgs) {
and( let roleId: number | undefined = undefined;
eq(userOrgs.userId, userId),
inArray(
userOrgs.orgId,
orgsToDelete.map((org) => org.orgId)
)
)
);
}
// Update roles for existing orgs where the role has changed const orgMapping = idpOrg.orgMapping || defaultOrgMapping;
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { const hydratedOrgMapping = orgMapping
const newOrg = userOrgInfo.find( ?.split("{{orgId}}")
(newOrg) => newOrg.orgId === currentOrg.orgId .join(idpOrg.orgId);
);
return newOrg && newOrg.roleId !== currentOrg.roleId;
});
if (orgsToUpdate.length > 0) { if (hydratedOrgMapping) {
for (const org of orgsToUpdate) { const orgId = jmespath.search(claims, hydratedOrgMapping);
const newRole = userOrgInfo.find( if (!(orgId === true || orgId === idpOrg.orgId)) {
(newOrg) => newOrg.orgId === org.orgId continue;
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, org.orgId)
)
);
} }
} }
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 logger.debug("User org info", { userOrgInfo });
const orgsToAdd = userOrgInfo.filter(
(newOrg) => let existingUserId = existingUser?.userId;
!currentUserOrgs.some(
(currentOrg) => currentOrg.orgId === newOrg.orgId // 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) { res.appendHeader("Set-Cookie", cookie);
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({ return response<ValidateOidcUrlCallbackResponse>(res, {
userId, data: {
orgId: org.orgId, redirectUrl: postAuthRedirectUrl
roleId: org.roleId, },
dateCreated: new Date().toISOString() 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 token = generateSessionToken(); // const sess = await createSession(token, existingUser.userId);
const sess = await createSession(token, existingUserId); // const isSecure = req.protocol === "https";
const isSecure = req.protocol === "https"; // const cookie = serializeSessionCookie(
const cookie = serializeSessionCookie( // token,
token, // isSecure,
isSecure, // new Date(sess.expiresAt)
new Date(sess.expiresAt) // );
); //
// res.appendHeader("Set-Cookie", cookie);
res.appendHeader("Set-Cookie", cookie); //
// return response<ValidateOidcUrlCallbackResponse>(res, {
return response<ValidateOidcUrlCallbackResponse>(res, { // data: {
data: { // redirectUrl: postAuthRedirectUrl
redirectUrl: postAuthRedirectUrl // },
}, // success: true,
success: true, // error: false,
error: false, // message: "OIDC callback validated successfully",
message: "OIDC callback validated successfully", // status: HttpCode.CREATED
status: HttpCode.CREATED // });
}); }
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(

View file

@ -32,7 +32,6 @@ import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
import PolicyTable, { PolicyRow } from "./PolicyTable"; import PolicyTable, { PolicyRow } from "./PolicyTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import { import {
@ -53,6 +52,17 @@ import { CaretSortIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { Textarea } from "@app/components/ui/textarea"; import { Textarea } from "@app/components/ui/textarea";
import { InfoPopup } from "@app/components/ui/info-popup"; 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 = { type Organization = {
orgId: string; orgId: string;
@ -65,7 +75,13 @@ const policyFormSchema = z.object({
orgMapping: z.string().optional() orgMapping: z.string().optional()
}); });
const defaultMappingsSchema = z.object({
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional()
});
type PolicyFormValues = z.infer<typeof policyFormSchema>; type PolicyFormValues = z.infer<typeof policyFormSchema>;
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
export default function PoliciesPage() { export default function PoliciesPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@ -88,6 +104,35 @@ export default function PoliciesPage() {
} }
}); });
const defaultMappingsForm = useForm<DefaultMappingsValues>({
resolver: zodResolver(defaultMappingsSchema),
defaultValues: {
defaultRoleMapping: "",
defaultOrgMapping: ""
}
});
const loadIdp = async () => {
try {
const res = await api.get<AxiosResponse<GetIdpResponse>>(
`/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 () => { const loadPolicies = async () => {
try { try {
const res = await api.get(`/idp/${idpId}/org`); const res = await api.get(`/idp/${idpId}/org`);
@ -126,6 +171,7 @@ export default function PoliciesPage() {
async function load() { async function load() {
setLoading(true); setLoading(true);
await loadPolicies(); await loadPolicies();
await loadIdp();
setLoading(false); setLoading(false);
} }
load(); 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) { if (loading) {
return null; return null;
} }
return ( return (
<> <>
<Alert variant="neutral" className="mb-6"> <SettingsContainer>
<InfoIcon className="h-4 w-4" /> <Alert variant="neutral" className="mb-6">
<AlertTitle className="font-semibold"> <InfoIcon className="h-4 w-4" />
About Organization Policies <AlertTitle className="font-semibold">
</AlertTitle> About Organization Policies
<AlertDescription> </AlertTitle>
Organization policies are used to control access to <AlertDescription>
organizations based on the user's ID token. You can specify Organization policies are used to control access to
JMESPath expressions to extract role and organization organizations based on the user's ID token. You can
information from the ID token. For more information, see{" "} specify JMESPath expressions to extract role and
<Link organization information from the ID token. For more
href="" information, see{" "}
target="_blank" <Link
rel="noopener noreferrer" href=""
className="text-primary hover:underline" target="_blank"
> rel="noopener noreferrer"
the documentation className="text-primary hover:underline"
<ExternalLink className="ml-1 h-4 w-4 inline" /> >
</Link> the documentation
</AlertDescription> <ExternalLink className="ml-1 h-4 w-4 inline" />
</Alert> </Link>
</AlertDescription>
</Alert>
<PolicyTable <SettingsSection>
policies={policies} <SettingsSectionHeader>
onDelete={onDeletePolicy} <SettingsSectionTitle>
onAdd={() => { Default Mappings (Optional)
loadOrganizations(); </SettingsSectionTitle>
setEditingPolicy(null); <SettingsSectionDescription>
setShowAddDialog(true); The default mappings are used when when there is not
}} an organization policy defined for an organization.
onEdit={(policy) => { You can specify the default role and organization
setEditingPolicy(policy); mappings to fall back to here.
form.reset({ </SettingsSectionDescription>
orgId: policy.orgId, </SettingsSectionHeader>
roleMapping: policy.roleMapping || "", <SettingsSectionBody>
orgMapping: policy.orgMapping || "" <Form {...defaultMappingsForm}>
}); <form
setShowAddDialog(true); onSubmit={defaultMappingsForm.handleSubmit(
}} onUpdateDefaultMappings
/> )}
id="policy-default-mappings-form"
className="space-y-4"
>
<div className="grid gap-6 md:grid-cols-2">
<FormField
control={defaultMappingsForm.control}
name="defaultRoleMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Default Role Mapping
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={defaultMappingsForm.control}
name="defaultOrgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Default Organization Mapping
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract
organization information
from the ID token. This
expression must return true
for the user to be allowed
to access the organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<SettingsSectionFooter>
<Button
type="submit"
form="policy-default-mappings-form"
loading={loading}
>
Save Default Mappings
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
<PolicyTable
policies={policies}
onDelete={onDeletePolicy}
onAdd={() => {
loadOrganizations();
setEditingPolicy(null);
setShowAddDialog(true);
}}
onEdit={(policy) => {
setEditingPolicy(policy);
form.reset({
orgId: policy.orgId,
roleMapping: policy.roleMapping || "",
orgMapping: policy.orgMapping || ""
});
setShowAddDialog(true);
}}
/>
</SettingsContainer>
<Credenza <Credenza
open={showAddDialog} open={showAddDialog}
@ -392,13 +546,14 @@ export default function PoliciesPage() {
Role Mapping Path (Optional) Role Mapping Path (Optional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
JMESPath to extract role JMESPath to extract role
information from the ID token. information from the ID token.
The result of this expression The result of this expression
must return the role name as a must return the role name as
defined in the organization as a
string. string.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -416,15 +571,14 @@ export default function PoliciesPage() {
(Optional) (Optional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
JMESPath to extract organization JMESPath to extract organization
information from the ID token. information from the ID token.
This expression must return a This expression must return true
truthy value for the user to be for the user to be allowed to
allowed to access the access the organization.
organization.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>