mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-15 14:50:37 +01:00
testing
This commit is contained in:
parent
7938b419cc
commit
feb558cfa8
16 changed files with 10 additions and 1522 deletions
|
@ -453,17 +453,6 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||||
scopes: text("scopes").notNull()
|
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 Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
@ -500,4 +489,3 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
export type Domain = InferSelectModel<typeof domains>;
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||||
export type Idp = InferSelectModel<typeof idp>;
|
export type Idp = InferSelectModel<typeof idp>;
|
||||||
export type IdpOrg = InferSelectModel<typeof idpOrg>;
|
|
||||||
|
|
|
@ -517,30 +517,6 @@ authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
|
||||||
|
|
||||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
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
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,7 +7,7 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
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 { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
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 { eq } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
@ -67,11 +67,6 @@ export async function deleteIdp(
|
||||||
.delete(idpOidcConfig)
|
.delete(idpOidcConfig)
|
||||||
.where(eq(idpOidcConfig.idpId, idpId));
|
.where(eq(idpOidcConfig.idpId, idpId));
|
||||||
|
|
||||||
// Delete IDP-org mappings
|
|
||||||
await trx
|
|
||||||
.delete(idpOrg)
|
|
||||||
.where(eq(idpOrg.idpId, idpId));
|
|
||||||
|
|
||||||
// Delete the IDP itself
|
// Delete the IDP itself
|
||||||
await trx
|
await trx
|
||||||
.delete(idp)
|
.delete(idp)
|
||||||
|
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
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 { and, eq } from "drizzle-orm";
|
||||||
import * as arctic from "arctic";
|
import * as arctic from "arctic";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
|
|
|
@ -5,7 +5,3 @@ export * from "./listIdps";
|
||||||
export * from "./generateOidcUrl";
|
export * from "./generateOidcUrl";
|
||||||
export * from "./validateOidcCallback";
|
export * from "./validateOidcCallback";
|
||||||
export * from "./getIdp";
|
export * from "./getIdp";
|
||||||
export * from "./createIdpOrgPolicy";
|
|
||||||
export * from "./deleteIdpOrgPolicy";
|
|
||||||
export * from "./listIdpOrgPolicies";
|
|
||||||
export * from "./updateIdpOrgPolicy";
|
|
||||||
|
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
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 response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -33,10 +33,8 @@ async function query(limit: number, offset: number) {
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
type: idp.type,
|
type: idp.type,
|
||||||
orgCount: sql<number>`count(${idpOrg.orgId})`
|
|
||||||
})
|
})
|
||||||
.from(idp)
|
.from(idp)
|
||||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
|
||||||
.groupBy(idp.idpId)
|
.groupBy(idp.idpId)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
@ -48,7 +46,6 @@ export type ListIdpsResponse = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
orgCount: number;
|
|
||||||
}>;
|
}>;
|
||||||
pagination: {
|
pagination: {
|
||||||
total: number;
|
total: number;
|
||||||
|
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
@ -9,26 +8,14 @@ import { fromError } from "zod-validation-error";
|
||||||
import {
|
import {
|
||||||
idp,
|
idp,
|
||||||
idpOidcConfig,
|
idpOidcConfig,
|
||||||
idpOrg,
|
|
||||||
orgs,
|
|
||||||
Role,
|
|
||||||
roles,
|
|
||||||
userOrgs,
|
|
||||||
users
|
users
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as arctic from "arctic";
|
import * as arctic from "arctic";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import jmespath from "jmespath";
|
import jmespath from "jmespath";
|
||||||
import jsonwebtoken from "jsonwebtoken";
|
import jsonwebtoken from "jsonwebtoken";
|
||||||
import config from "@server/lib/config";
|
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";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
|
@ -215,212 +202,12 @@ export async function validateOidcCallback(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIdp.idp.autoProvision) {
|
if (existingIdp.idp.autoProvision) {
|
||||||
const allOrgs = await db.select().from(orgs);
|
return next(
|
||||||
|
createHttpError(
|
||||||
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
|
HttpCode.BAD_REQUEST,
|
||||||
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
|
"Auto provisioning is not supported"
|
||||||
|
)
|
||||||
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)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -39,10 +39,6 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: `/admin/idp/${params.idpId}/general`
|
href: `/admin/idp/${params.idpId}/general`
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Organization Policies",
|
|
||||||
href: `/admin/idp/${params.idpId}/policies`
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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} />;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
Loading…
Reference in a new issue