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",
updateResourceRule = "updateResourceRule",
listOrgDomains = "listOrgDomains",
createNewt = "createNewt",
createIdp = "createIdp"
createNewt = "createNewt"
}
export async function checkUserActionPermission(

View file

@ -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"),

View file

@ -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,

View file

@ -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({
// 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,
autoProvision,
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, {

View file

@ -213,24 +213,34 @@ export async function validateOidcCallback(
)
);
if (existingIdp.idp.autoProvision) {
const idpOrgs = await db
.select()
.from(idpOrg)
.where(eq(idpOrg.idpId, existingIdp.idp.idpId));
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
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) {
const orgMapping = idpOrg.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = orgMapping
?.split("{{orgId}}")
.join(idpOrg.orgId);
if (hydratedOrgMapping) {
const orgId = jmespath.search(claims, hydratedOrgMapping);
if (!(orgId === true || orgId === idpOrg.orgId)) {
continue;
}
}
if (idpOrg.roleMapping) {
const roleName = jmespath.search(claims, idpOrg.roleMapping);
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", {
@ -395,6 +405,37 @@ export async function validateOidcCallback(
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, existingUser.userId);
// const isSecure = req.protocol === "https";
// const cookie = serializeSessionCookie(
// token,
// isSecure,
// new Date(sess.expiresAt)
// );
//
// res.appendHeader("Set-Cookie", cookie);
//
// return response<ValidateOidcUrlCallbackResponse>(res, {
// data: {
// redirectUrl: postAuthRedirectUrl
// },
// success: true,
// error: false,
// message: "OIDC callback validated successfully",
// status: HttpCode.CREATED
// });
}
} catch (error) {
logger.error(error);
return next(

View file

@ -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<typeof policyFormSchema>;
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
export default function PoliciesPage() {
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 () => {
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,12 +268,34 @@ 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 (
<>
<SettingsContainer>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
@ -235,9 +303,10 @@ export default function PoliciesPage() {
</AlertTitle>
<AlertDescription>
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{" "}
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{" "}
<Link
href=""
target="_blank"
@ -250,6 +319,90 @@ export default function PoliciesPage() {
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Default Mappings (Optional)
</SettingsSectionTitle>
<SettingsSectionDescription>
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.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...defaultMappingsForm}>
<form
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}
@ -268,6 +421,7 @@ export default function PoliciesPage() {
setShowAddDialog(true);
}}
/>
</SettingsContainer>
<Credenza
open={showAddDialog}
@ -392,13 +546,14 @@ export default function PoliciesPage() {
Role Mapping Path (Optional)
</FormLabel>
<FormControl>
<Textarea {...field} />
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract role
information from the ID token.
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.
</FormDescription>
<FormMessage />
@ -416,15 +571,14 @@ export default function PoliciesPage() {
(Optional)
</FormLabel>
<FormControl>
<Textarea {...field} />
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract organization
information from the ID token.
This expression must return a
truthy value for the user to be
allowed to access the
organization.
This expression must return true
for the user to be allowed to
access the organization.
</FormDescription>
<FormMessage />
</FormItem>