This commit is contained in:
miloschwartz 2025-04-22 22:53:52 -04:00
parent 7938b419cc
commit feb558cfa8
No known key found for this signature in database
16 changed files with 10 additions and 1522 deletions

View file

@ -453,17 +453,6 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
scopes: text("scopes").notNull()
});
export const idpOrg = sqliteTable("idpOrg", {
idpId: integer("idpId")
.notNull()
.references(() => idp.idpId, { onDelete: "cascade" }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
roleMapping: text("roleMapping"),
orgMapping: text("orgMapping")
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@ -500,4 +489,3 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;
export type IdpOrg = InferSelectModel<typeof idpOrg>;

View file

@ -517,30 +517,6 @@ authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.createIdpOrgPolicy
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy
);
authenticated.get(
"/idp/:idpId/org",
verifyUserIsServerAdmin,
idp.listIdpOrgPolicies
);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);

View file

@ -1,124 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import config from "@server/lib/config";
import { eq, and } from "drizzle-orm";
import { idp, idpOrg } from "@server/db/schemas";
const paramsSchema = z
.object({
idpId: z.coerce.number(),
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
roleMapping: z.string().optional(),
orgMapping: z.string().optional()
})
.strict();
export type CreateIdpOrgPolicyResponse = {};
registry.registerPath({
method: "put",
path: "/idp/{idpId}/org/{orgId}",
description: "Create an IDP policy for an existing IDP on an organization.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createIdpOrgPolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId, orgId } = parsedParams.data;
const { roleMapping, orgMapping } = parsedBody.data;
const [existing] = await db
.select()
.from(idp)
.leftJoin(
idpOrg,
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
)
.where(eq(idp.idpId, idpId));
if (!existing?.idp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"An IDP with this ID does not exist."
)
);
}
if (existing.idpOrg) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"An IDP org policy already exists."
)
);
}
await db.insert(idpOrg).values({
idpId,
orgId,
roleMapping,
orgMapping
});
return response<CreateIdpOrgPolicyResponse>(res, {
data: {},
success: true,
error: false,
message: "Idp created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -7,7 +7,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";

View file

@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
@ -67,11 +67,6 @@ export async function deleteIdp(
.delete(idpOidcConfig)
.where(eq(idpOidcConfig.idpId, idpId));
// Delete IDP-org mappings
await trx
.delete(idpOrg)
.where(eq(idpOrg.idpId, idpId));
// Delete the IDP itself
await trx
.delete(idp)

View file

@ -1,90 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOrg } from "@server/db/schemas";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
idpId: z.coerce.number(),
orgId: z.string()
})
.strict();
registry.registerPath({
method: "delete",
path: "/idp/{idpId}/org/{orgId}",
description: "Create an OIDC IdP for an organization.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteIdpOrgPolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId, orgId } = parsedParams.data;
const [existing] = await db
.select()
.from(idp)
.leftJoin(idpOrg, eq(idpOrg.orgId, orgId))
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)));
if (!existing.idp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"An IDP with this ID does not exist."
)
);
}
if (!existing.idpOrg) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A policy for this IDP and org does not exist."
)
);
}
await db
.delete(idpOrg)
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Policy deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";

View file

@ -5,7 +5,3 @@ export * from "./listIdps";
export * from "./generateOidcUrl";
export * from "./validateOidcCallback";
export * from "./getIdp";
export * from "./createIdpOrgPolicy";
export * from "./deleteIdpOrgPolicy";
export * from "./listIdpOrgPolicies";
export * from "./updateIdpOrgPolicy";

View file

@ -1,116 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idpOrg } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({
idpId: z.coerce.number()
});
const querySchema = z
.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
})
.strict();
async function query(idpId: number, limit: number, offset: number) {
const res = await db
.select()
.from(idpOrg)
.where(eq(idpOrg.idpId, idpId))
.limit(limit)
.offset(offset);
return res;
}
export type ListIdpOrgPoliciesResponse = {
policies: NonNullable<Awaited<ReturnType<typeof query>>>;
pagination: { total: number; limit: number; offset: number };
};
registry.registerPath({
method: "get",
path: "/idp/{idpId}/org",
description: "List all org policies on an IDP.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function listIdpOrgPolicies(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const list = await query(idpId, limit, offset);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(idpOrg)
.where(eq(idpOrg.idpId, idpId));
return response<ListIdpOrgPoliciesResponse>(res, {
data: {
policies: list,
pagination: {
total: count,
limit,
offset
}
},
success: true,
error: false,
message: "Policies retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { domains, idp, orgDomains, users, idpOrg } from "@server/db/schemas";
import { idp } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -33,10 +33,8 @@ async function query(limit: number, offset: number) {
idpId: idp.idpId,
name: idp.name,
type: idp.type,
orgCount: sql<number>`count(${idpOrg.orgId})`
})
.from(idp)
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
.groupBy(idp.idpId)
.limit(limit)
.offset(offset);
@ -48,7 +46,6 @@ export type ListIdpsResponse = {
idpId: number;
name: string;
type: string;
orgCount: number;
}>;
pagination: {
total: number;

View file

@ -1,126 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq, and } from "drizzle-orm";
import { idp, idpOrg } from "@server/db/schemas";
const paramsSchema = z
.object({
idpId: z.coerce.number(),
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
roleMapping: z.string().optional(),
orgMapping: z.string().optional()
})
.strict();
export type UpdateIdpOrgPolicyResponse = {};
registry.registerPath({
method: "post",
path: "/idp/{idpId}/org/{orgId}",
description: "Update an IDP org policy.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateIdpOrgPolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { idpId, orgId } = parsedParams.data;
const { roleMapping, orgMapping } = parsedBody.data;
// Check if IDP and policy exist
const [existing] = await db
.select()
.from(idp)
.leftJoin(
idpOrg,
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
)
.where(eq(idp.idpId, idpId));
if (!existing?.idp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"An IDP with this ID does not exist."
)
);
}
if (!existing.idpOrg) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A policy for this IDP and org does not exist."
)
);
}
// Update the policy
await db
.update(idpOrg)
.set({
roleMapping,
orgMapping
})
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
return response<UpdateIdpOrgPolicyResponse>(res, {
data: {},
success: true,
error: false,
message: "Policy updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
@ -9,26 +8,14 @@ import { fromError } from "zod-validation-error";
import {
idp,
idpOidcConfig,
idpOrg,
orgs,
Role,
roles,
userOrgs,
users
} from "@server/db/schemas";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import jmespath from "jmespath";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
import {
createSession,
generateId,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto";
const paramsSchema = z
@ -215,212 +202,12 @@ export async function validateOidcCallback(
);
if (existingIdp.idp.autoProvision) {
const allOrgs = await db.select().from(orgs);
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
let userOrgInfo: { orgId: string; roleId: number }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
.from(idpOrg)
.where(
and(
eq(idpOrg.idpId, existingIdp.idp.idpId),
eq(idpOrg.orgId, org.orgId)
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Auto provisioning is not supported"
)
);
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping(
orgMapping,
org.orgId
);
if (hydratedOrgMapping) {
logger.debug("Hydrated Org Mapping", {
hydratedOrgMapping
});
const orgId = jmespath.search(claims, hydratedOrgMapping);
logger.debug("Extraced Org ID", { orgId });
if (orgId !== true && orgId !== org.orgId) {
// user not allowed to access this org
continue;
}
}
const roleMapping =
idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { 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, org.orgId),
eq(roles.name, roleName)
)
);
if (!roleRes) {
logger.error("Role not found", {
orgId: org.orgId,
roleName
});
continue;
}
roleId = roleRes.roleId;
userOrgInfo.push({
orgId: org.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()
.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)
);
res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",
status: HttpCode.CREATED
});
} else {
if (!existingUser) {
return next(

View file

@ -39,10 +39,6 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
{
title: "General",
href: `/admin/idp/${params.idpId}/general`
},
{
title: "Organization Policies",
href: `/admin/idp/${params.idpId}/policies`
}
];

View file

@ -1,28 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd: () => void;
}
export function PolicyDataTable<TData, TValue>({
columns,
data,
onAdd
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
title="Organization Policies"
searchPlaceholder="Search organization policies..."
searchColumn="orgId"
addButtonText="Add Organization Policy"
onAdd={onAdd}
/>
);
}

View file

@ -1,154 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowUpDown,
Trash2,
MoreHorizontal,
Pencil,
ArrowRight
} from "lucide-react";
import { PolicyDataTable } from "./PolicyDataTable";
import { Badge } from "@app/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { InfoPopup } from "@app/components/ui/info-popup";
export interface PolicyRow {
orgId: string;
roleMapping?: string;
orgMapping?: string;
}
interface Props {
policies: PolicyRow[];
onDelete: (orgId: string) => void;
onAdd: () => void;
onEdit: (policy: PolicyRow) => void;
}
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
const columns: ColumnDef<PolicyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(r.orgId);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "orgId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "roleMapping",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Role Mapping
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const mapping = row.original.roleMapping;
return mapping ? (
<InfoPopup
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
info={mapping}
/>
) : (
"--"
);
}
},
{
accessorKey: "orgMapping",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization Mapping
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const mapping = row.original.orgMapping;
return mapping ? (
<InfoPopup
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
info={mapping}
/>
) : (
"--"
);
}
},
{
id: "actions",
cell: ({ row }) => {
const policy = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant={"outlinePrimary"}
className="ml-2"
onClick={() => onEdit(policy)}
>
Edit
</Button>
</div>
);
}
}
];
return <PolicyDataTable columns={columns} data={policies} onAdd={onAdd} />;
}

View file

@ -1,609 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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 { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
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;
name: string;
};
const policyFormSchema = z.object({
orgId: z.string().min(1, { message: "Organization is required" }),
roleMapping: 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 DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
export default function PoliciesPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const { idpId } = useParams();
const [loading, setLoading] = useState(true);
const [policies, setPolicies] = useState<PolicyRow[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
const form = useForm<PolicyFormValues>({
resolver: zodResolver(policyFormSchema),
defaultValues: {
orgId: "",
roleMapping: "",
orgMapping: ""
}
});
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`);
if (res.status === 200) {
setPolicies(res.data.data.policies);
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const loadOrganizations = async () => {
try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>("/orgs");
if (res.status === 200) {
const existingOrgIds = policies.map((p) => p.orgId);
const availableOrgs = res.data.data.orgs.filter(
(org) => !existingOrgIds.includes(org.orgId)
);
setOrganizations(availableOrgs);
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
useEffect(() => {
async function load() {
setLoading(true);
await loadPolicies();
await loadIdp();
setLoading(false);
}
load();
}, [idpId]);
const onAddPolicy = async (data: PolicyFormValues) => {
setLoading(true);
try {
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
});
if (res.status === 201) {
toast({
title: "Success",
description: "Policy added successfully"
});
loadPolicies();
setShowAddDialog(false);
form.reset();
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const onEditPolicy = async (data: PolicyFormValues) => {
if (!editingPolicy) return;
setLoading(true);
try {
const res = await api.post(
`/idp/${idpId}/org/${editingPolicy.orgId}`,
{
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
}
);
if (res.status === 200) {
setPolicies(
policies.map((policy) =>
policy.orgId === editingPolicy.orgId
? {
...policy,
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
}
: policy
)
);
toast({
title: "Success",
description: "Policy updated successfully"
});
setShowAddDialog(false);
setEditingPolicy(null);
form.reset();
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const onDeletePolicy = async (orgId: string) => {
try {
const res = await api.delete(`/idp/${idpId}/org/${orgId}`);
if (res.status === 200) {
setPolicies(
policies.filter((policy) => policy.orgId !== orgId)
);
toast({
title: "Success",
description: "Policy deleted successfully"
});
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
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">
About Organization Policies
</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{" "}
<Link
href=""
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
the documentation
<ExternalLink className="ml-1 h-4 w-4 inline" />
</Link>
</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 thr
org ID or 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
open={showAddDialog}
onOpenChange={(val) => {
setShowAddDialog(val);
setLoading(false);
setEditingPolicy(null);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{editingPolicy
? "Edit Organization Policy"
: "Add Organization Policy"}
</CredenzaTitle>
<CredenzaDescription>
Configure access for an organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
editingPolicy ? onEditPolicy : onAddPolicy
)}
className="space-y-4"
id="policy-form"
>
<FormField
control={form.control}
name="orgId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Organization</FormLabel>
{editingPolicy ? (
<Input {...field} disabled />
) : (
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? organizations.find(
(
org
) =>
org.orgId ===
field.value
)?.name
: "Select organization"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search org" />
<CommandList>
<CommandEmpty>
No org
found.
</CommandEmpty>
<CommandGroup>
{organizations.map(
(
org
) => (
<CommandItem
value={`${org.orgId}`}
key={
org.orgId
}
onSelect={() => {
form.setValue(
"orgId",
org.orgId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
org.orgId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
org.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Role Mapping Path (Optional)
</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={form.control}
name="orgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Organization Mapping Path
(Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract organization
information from the ID token.
This expression must return the
org ID or true for the user to
be allowed to access the
organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button
type="submit"
form="policy-form"
loading={loading}
disabled={loading}
>
{editingPolicy ? "Update Policy" : "Add Policy"}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}