mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-26 21:54:38 +02:00
add default mapping policy and move auto provision
This commit is contained in:
parent
99188233db
commit
b4fda6a1f6
6 changed files with 475 additions and 257 deletions
|
@ -65,8 +65,7 @@ export enum ActionsEnum {
|
|||
listResourceRules = "listResourceRules",
|
||||
updateResourceRule = "updateResourceRule",
|
||||
listOrgDomains = "listOrgDomains",
|
||||
createNewt = "createNewt",
|
||||
createIdp = "createIdp"
|
||||
createNewt = "createNewt"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue