This commit is contained in:
miloschwartz
2025-04-22 22:53:52 -04:00
parent 7938b419cc
commit feb558cfa8
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)
)
);
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)
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Auto provisioning is not supported"
)
);
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(