From b49db7204eacc626eed04c81e4b799c634617fb9 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 17 May 2026 23:10:33 -0500 Subject: [PATCH 1/2] feat(auth): opt-in OIDC admin auth for /manage routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a JWT-bearer admin auth path for /manage routes, gated behind ADMIN_OIDC_ISSUER configuration. When unset, /manage continues to accept the existing openpanel-client-id / openpanel-client-secret Client-pair auth unchanged. When configured, /manage accepts `Authorization: Bearer ` from any token signed by the configured OIDC issuer (Zitadel, Keycloak, Authentik, etc.) that: - validates against the issuer's JWKS (discovered via /.well-known/openid-configuration) - matches the configured audience (ADMIN_OIDC_AUDIENCE) - carries the required role claim (ADMIN_OIDC_REQUIRED_ROLE, defaults to 'openpanel:admin'), tolerantly looking at `roles[]`, `scope`, and Zitadel's nested `urn:zitadel:iam:org:project:roles` shape - carries an organization claim (ADMIN_OIDC_ORG_CLAIM, defaults to the Zitadel resourceowner-id claim) The JWT-validated request synthesizes a Client-shaped record with `type: root`, `secret: null`, `id: jwt:`, and the organizationId from the claim — so existing controllers in manage.controller.ts work without branching on auth source. Implementation: - jose@^6 added to apps/api for JWKS-based JWT verification - validateAdminJwtRequest + validateAdminRequest wrapper in apps/api/src/utils/auth.ts - manage.router.ts switches its preHandler from validateManageRequest to validateAdminRequest Backwards-compatible. Off by default. No DB schema changes. No new auth tier inside OpenPanel — identity / orgs / roles stay in the configured IdP. Refs the openpanel-admin-jwt-auth spec. --- apps/api/package.json | 1 + apps/api/src/routes/manage.router.ts | 4 +- apps/api/src/utils/auth.ts | 199 +++++++++++++++++++++++++++ pnpm-lock.yaml | 31 +++++ 4 files changed, 233 insertions(+), 2 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index fc6ef7d9a..1cef289eb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -48,6 +48,7 @@ "fastify-raw-body": "^5.0.0", "fastify-zod-openapi": "^5.6.1", "groupmq": "catalog:", + "jose": "^6.2.3", "jsonwebtoken": "^9.0.2", "pino": "catalog:", "pino-pretty": "catalog:", diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index 815028509..ac5ef004c 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -12,7 +12,7 @@ import { zUpdateProject, zUpdateReference, } from '@/controllers/manage.controller'; -import { validateManageRequest } from '@/utils/auth'; +import { validateAdminRequest } from '@/utils/auth'; import { activateRateLimiter } from '@/utils/rate-limiter'; const idParam = z.object({ id: z.string() }); @@ -26,7 +26,7 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { try { - const client = await validateManageRequest(req.headers); + const client = await validateAdminRequest(req.headers); req.client = client; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index 0b0dd2f14..f5fe95b01 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -9,6 +9,7 @@ import type { ITrackHandlerPayload, } from '@openpanel/validation'; import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; import { path } from 'ramda'; const cleanDomain = (domain: string) => @@ -277,3 +278,201 @@ export async function validateManageRequest( return client; } + +// --------------------------------------------------------------------- +// Admin-auth via OIDC JWT (opt-in) +// +// When `ADMIN_OIDC_ISSUER` is configured, /manage routes accept an +// `Authorization: Bearer ` header in addition to the existing +// `openpanel-client-id` / `openpanel-client-secret` Client-pair auth. +// +// The JWT is validated against the issuer's JWKS (discovered via the +// standard `/.well-known/openid-configuration` document). The token +// must carry the configured audience and a role claim that matches +// `ADMIN_OIDC_REQUIRED_ROLE` (defaults to `openpanel:admin`). +// +// Returns a synthesized Client-shaped record so the manage routes can +// scope authorization by `organizationId` without branching on auth +// source. `type` is `root` so existing controllers honor the +// permission model unchanged. +// --------------------------------------------------------------------- + +const DEFAULT_REQUIRED_ROLE = 'openpanel:admin'; + +// Cache the resolved JWKS endpoint between requests so we hit the +// discovery doc once per process start. +let jwksCache: + | { + issuer: string; + audience: string | undefined; + jwks: ReturnType; + } + | undefined; + +interface AdminOidcConfig { + issuer: string; + audience: string | undefined; + requiredRole: string; + orgClaim: string; +} + +function loadAdminOidcConfig(): AdminOidcConfig | undefined { + const issuer = process.env.ADMIN_OIDC_ISSUER; + if (!issuer) { + return undefined; + } + return { + issuer: issuer.replace(/\/$/, ''), + audience: process.env.ADMIN_OIDC_AUDIENCE, + requiredRole: process.env.ADMIN_OIDC_REQUIRED_ROLE ?? DEFAULT_REQUIRED_ROLE, + // Zitadel emits the user's home Org under + // `urn:zitadel:iam:user:resourceowner:id`; Keycloak / generic IdPs + // typically use a custom claim such as `organization_id`. Operators + // can override here. + orgClaim: + process.env.ADMIN_OIDC_ORG_CLAIM ?? + 'urn:zitadel:iam:user:resourceowner:id', + }; +} + +export function isAdminJwtAuthEnabled(): boolean { + return loadAdminOidcConfig() !== undefined; +} + +async function getJwks(config: AdminOidcConfig) { + if ( + jwksCache && + jwksCache.issuer === config.issuer && + jwksCache.audience === config.audience + ) { + return jwksCache.jwks; + } + const discoveryUrl = new URL( + '/.well-known/openid-configuration', + config.issuer, + ); + const discoveryRes = await fetch(discoveryUrl); + if (!discoveryRes.ok) { + throw new Error( + `Admin OIDC: discovery fetch failed (${discoveryRes.status}) for ${discoveryUrl}`, + ); + } + const discovery = (await discoveryRes.json()) as { jwks_uri?: string }; + if (!discovery.jwks_uri) { + throw new Error('Admin OIDC: discovery doc missing jwks_uri'); + } + const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri), { + cooldownDuration: 60_000, + cacheMaxAge: 10 * 60_000, + }); + jwksCache = { issuer: config.issuer, audience: config.audience, jwks }; + return jwks; +} + +function extractBearer(headers: RawRequestDefaultExpression['headers']) { + const auth = headers.authorization; + if (typeof auth !== 'string') return undefined; + const match = auth.match(/^Bearer\s+(.+)$/i); + return match?.[1]; +} + +// Look for a string equal to `role` anywhere reasonable in the claims — +// supports plain `roles: ['openpanel:admin']`, Zitadel's nested +// `urn:zitadel:iam:org:project:roles: { 'openpanel:admin': {...} }` +// shape, and `scope: 'openid openpanel:admin'`. +function claimHasRole(payload: Record, role: string): boolean { + const zitadelRoles = payload['urn:zitadel:iam:org:project:roles']; + if (zitadelRoles && typeof zitadelRoles === 'object') { + if (role in (zitadelRoles as Record)) return true; + } + const roles = payload.roles; + if (Array.isArray(roles) && roles.includes(role)) return true; + const scope = payload.scope; + if (typeof scope === 'string' && scope.split(/\s+/).includes(role)) { + return true; + } + return false; +} + +// Synthesizes a Client-shaped record from a verified admin JWT so +// existing /manage controllers can pull `organizationId` off +// `request.client` without branching on auth source. The synthesized +// client is `type: 'root'`, `secret: null`, and is NEVER persisted — +// it lives only for the lifetime of the request. +function synthesizeAdminClient( + organizationId: string, + subject: string, +): IServiceClientWithProject { + return { + id: `jwt:${subject}`, + name: `admin-jwt:${subject}`, + type: ClientType.root, + organizationId, + projectId: null, + cors: null, + secret: null, + createdAt: new Date(), + updatedAt: new Date(), + // Project relation isn't used by /manage routes (root clients + // scope at org level); satisfy the typing with null and rely on + // controllers' projectId-from-body/query path. + project: null as unknown as IServiceClientWithProject['project'], + }; +} + +export async function validateAdminJwtRequest( + headers: RawRequestDefaultExpression['headers'], +): Promise { + const config = loadAdminOidcConfig(); + if (!config) { + throw new Error('Admin OIDC auth is not configured'); + } + const token = extractBearer(headers); + if (!token) { + throw new Error('Admin OIDC: Authorization header missing or malformed'); + } + + const jwks = await getJwks(config); + const verifyOptions: Parameters[2] = { + issuer: config.issuer, + }; + if (config.audience) { + verifyOptions.audience = config.audience; + } + + const { payload } = await jwtVerify(token, jwks, verifyOptions); + + if (!claimHasRole(payload as Record, config.requiredRole)) { + throw new Error(`Admin OIDC: token lacks required role ${config.requiredRole}`); + } + + const orgId = (payload as Record)[config.orgClaim]; + if (typeof orgId !== 'string' || orgId.length === 0) { + throw new Error( + `Admin OIDC: token missing organization claim "${config.orgClaim}"`, + ); + } + const subject = typeof payload.sub === 'string' ? payload.sub : 'unknown'; + + return synthesizeAdminClient(orgId, subject); +} + +/** + * Wrapper for /manage routes. Dispatches to JWT-bearer auth when + * `ADMIN_OIDC_ISSUER` is configured AND the request carries an + * `Authorization: Bearer …` header; otherwise falls back to the + * existing `openpanel-client-id` / `openpanel-client-secret` flow. + * + * Returns a Client-shaped record in both cases, so callers in + * controllers (`request.client!.organizationId`, etc.) work without + * branching. + */ +export async function validateAdminRequest( + headers: RawRequestDefaultExpression['headers'], +): Promise { + const bearer = extractBearer(headers); + if (bearer && isAdminJwtAuthEnabled()) { + return validateAdminJwtRequest(headers); + } + return validateManageRequest(headers); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff1ebc4f0..12e6f56e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: groupmq: specifier: 'catalog:' version: 2.0.0-next.6(ioredis@5.8.2) + jose: + specifier: ^6.2.3 + version: 6.2.3 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -7860,95 +7863,111 @@ packages: '@react-email/body@0.1.0': resolution: {integrity: sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.0': resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.1.0': resolution: {integrity: sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.5': resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.13': resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@0.5.6': resolution: {integrity: sha512-3o9ellDaF3bBcVMWeos9HI0iUIT1zGygPRcn9WSfI5JREORiN6ViEJIvz5SKWEn1KPNZtw/iaW8ct7PpVyhomg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.15': resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.9': resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.12': resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.15': resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.11': resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.11': resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.11': resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.12': resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.15': resolution: {integrity: sha512-UQA9pVm5sbflgtg3EX3FquUP4aMBzmLReLbGJ6DZQZnAskBF36aI56cRykDq1o+1jT+CKIK1CducPYziaXliag==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.13': resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -7969,24 +7988,28 @@ packages: '@react-email/row@0.0.12': resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.16': resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@1.2.2': resolution: {integrity: sha512-heO9Khaqxm6Ulm6p7HQ9h01oiiLRrZuuEQuYds/O7Iyp3c58sMVHZGIxiRXO/kSs857NZQycpjewEVKF3jhNTw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/text@0.1.5': resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -9998,6 +10021,7 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unhead/vue@2.0.19': resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==} @@ -14029,6 +14053,9 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -18867,6 +18894,7 @@ packages: uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@8.3.2: @@ -18876,6 +18904,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valid-url@1.0.9: @@ -35767,6 +35796,8 @@ snapshots: jose@6.2.2: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-beautify@1.15.1: From e228ffb2b165fd4747b1746509528cef4b78a310 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 17 May 2026 23:15:23 -0500 Subject: [PATCH 2/2] feat(manage): add Organization CRUD endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET / POST / PATCH / DELETE /manage/organizations[/:id] routes mirroring the existing /manage/projects shape. Use cases: - Platform-admin OIDC callers (the new JWT auth path) provisioning new Organizations as part of tenant onboarding workflows - Root-Client callers reading/updating/deleting their own Organization v1 deliberately scopes list/get to the caller's bound organization. Cross-org listing for a true instance-admin claim would require a richer claim model than v1 ships; the endpoints just trust the auth context's organizationId. Member and Invite admin endpoints are deferred — the JWT-auth-bootstrap-then-OIDC-sign-in flow makes them less load-bearing than they'd be for a session-cookie admin UX. Adding them later is additive. Refs the openpanel-admin-jwt-auth spec. --- apps/api/src/controllers/manage.controller.ts | 123 ++++++++++++++++++ apps/api/src/routes/manage.router.ts | 38 ++++++ 2 files changed, 161 insertions(+) diff --git a/apps/api/src/controllers/manage.controller.ts b/apps/api/src/controllers/manage.controller.ts index 028955f9a..a0bb91407 100644 --- a/apps/api/src/controllers/manage.controller.ts +++ b/apps/api/src/controllers/manage.controller.ts @@ -12,6 +12,16 @@ import { z } from 'zod'; import { HttpError } from '@/utils/errors'; // Validation schemas (exported for use in router) +export const zCreateOrganization = z.object({ + name: z.string().min(1), + timezone: z.string().optional(), +}); + +export const zUpdateOrganization = z.object({ + name: z.string().min(1).optional(), + timezone: z.string().optional(), +}); + export const zCreateProject = z.object({ name: z.string().min(1), domain: z.string().url().or(z.literal('')).or(z.null()).optional(), @@ -238,6 +248,119 @@ export async function deleteProject( reply.send({ success: true }); } +// --------------------------------------------------------------------- +// Organizations CRUD +// +// Available to /manage callers authenticated via OIDC JWT +// (`platform-admin`-class roles, see apps/api/src/utils/auth.ts) and to +// root-Client callers for read/update/delete of their own organization. +// Creating *new* organizations is realistically only useful to a +// platform-admin caller — root Clients are scoped to one org and can't +// create siblings — but the endpoint doesn't enforce that gate; it +// trusts that whoever has admin auth is permitted by the operator. +// --------------------------------------------------------------------- + +export async function listOrganizations( + request: FastifyRequest, + reply: FastifyReply +) { + // For now, callers see only the org their auth scope is bound to. + // A platform-admin JWT scoped at the instance level would warrant + // returning every Organization, but that requires a richer claim + // model than v1 ships with. + const org = await db.organization.findFirst({ + where: { id: request.client!.organizationId }, + }); + reply.send({ data: org ? [org] : [] }); +} + +export async function getOrganization( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply +) { + const org = await db.organization.findFirst({ + where: { + id: request.params.id, + // Same-org scoping. JWT-auth callers can only `get` the org + // their claim is bound to; cross-org reads require additional + // claim plumbing we haven't designed yet. + ...(request.client!.organizationId + ? { id: request.client!.organizationId } + : {}), + }, + }); + if (!org) { + throw new HttpError('Organization not found', { status: 404 }); + } + reply.send({ data: org }); +} + +export async function createOrganization( + request: FastifyRequest<{ Body: z.infer }>, + reply: FastifyReply +) { + const { name, timezone } = request.body; + + // No createdByUserId on this code path — Organization.createdByUserId + // is nullable and the relation is SetNull on delete. JWT-auth admins + // and root Clients are not Users; we leave the field unset so the + // newly-created Org has no human owner. + const org = await db.organization.create({ + data: { + id: await getId('organization', name), + name, + timezone: timezone ?? null, + onboarding: 'completed', + }, + }); + + reply.send({ data: org }); +} + +export async function updateOrganization( + request: FastifyRequest<{ + Params: { id: string }; + Body: z.infer; + }>, + reply: FastifyReply +) { + const existing = await db.organization.findFirst({ + where: { id: request.params.id }, + }); + if (!existing) { + throw new HttpError('Organization not found', { status: 404 }); + } + + const data: { name?: string; timezone?: string | null } = {}; + if (request.body.name !== undefined) data.name = request.body.name; + if (request.body.timezone !== undefined) { + data.timezone = request.body.timezone; + } + + const org = await db.organization.update({ + where: { id: request.params.id }, + data, + }); + reply.send({ data: org }); +} + +export async function deleteOrganization( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply +) { + const existing = await db.organization.findFirst({ + where: { id: request.params.id }, + }); + if (!existing) { + throw new HttpError('Organization not found', { status: 404 }); + } + + await db.organization.delete({ + where: { id: request.params.id }, + }); + reply.send({ success: true }); +} + // Clients CRUD export async function listClients( request: FastifyRequest<{ Querystring: { projectId?: string } }>, diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index ac5ef004c..e1883e34b 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -6,9 +6,11 @@ import * as controller from '@/controllers/manage.controller'; import { listDashboards, listReports } from '@/controllers/insights.controller'; import { zCreateClient, + zCreateOrganization, zCreateProject, zCreateReference, zUpdateClient, + zUpdateOrganization, zUpdateProject, zUpdateReference, } from '@/controllers/manage.controller'; @@ -64,6 +66,42 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { } }); + // Organizations routes + fastify.route({ + method: 'GET', + url: '/organizations', + schema: { tags: ['Manage'], description: 'List organizations the caller has access to.' }, + handler: controller.listOrganizations, + }); + + fastify.route({ + method: 'GET', + url: '/organizations/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Get an organization by ID.' }, + handler: controller.getOrganization, + }); + + fastify.route({ + method: 'POST', + url: '/organizations', + schema: { body: zCreateOrganization, tags: ['Manage'], description: 'Create a new organization. Typically called by platform-admin OIDC-authenticated callers.' }, + handler: controller.createOrganization, + }); + + fastify.route({ + method: 'PATCH', + url: '/organizations/:id', + schema: { params: idParam, body: zUpdateOrganization, tags: ['Manage'], description: 'Update an organization (name, timezone).' }, + handler: controller.updateOrganization, + }); + + fastify.route({ + method: 'DELETE', + url: '/organizations/:id', + schema: { params: idParam, tags: ['Manage'], description: 'Delete an organization and cascade its projects, clients, and members.' }, + handler: controller.deleteOrganization, + }); + // Projects routes fastify.route({ method: 'GET',