From fe50394ec033c1ed83874a536bab4a72805c4a13 Mon Sep 17 00:00:00 2001 From: Kodemon Date: Fri, 26 Sep 2025 12:48:31 +0200 Subject: [PATCH] feat: identity cerbos implementation --- .vscode/settings.json | 1 + api/session.ts | 15 +-- deno.json | 2 - deno.lock | 11 +- .../identity/cerbos/client.ts | 0 modules/identity/client.ts | 122 +++++++++++++++++- modules/identity/package.json | 3 +- .../routes/access/check-resource/handle.ts | 6 + .../routes/access/check-resource/spec.ts | 16 +++ .../routes/access/check-resources/handle.ts | 6 + .../routes/access/check-resources/spec.ts | 18 +++ .../routes/access/is-allowed/handle.ts | 6 + .../identity/routes/access/is-allowed/spec.ts | 16 +++ .../identity/routes/identities/get/handle.ts | 27 ++-- modules/identity/server.ts | 13 +- modules/identity/services/access.ts | 88 ------------- modules/identity/types.ts | 8 +- platform/cerbos/mod.ts | 1 - platform/cerbos/package.json | 15 --- platform/relay/libraries/client.ts | 38 +++--- platform/relay/libraries/route.ts | 4 +- 21 files changed, 254 insertions(+), 162 deletions(-) rename platform/cerbos/cerbos.ts => modules/identity/cerbos/client.ts (100%) create mode 100644 modules/identity/routes/access/check-resource/handle.ts create mode 100644 modules/identity/routes/access/check-resource/spec.ts create mode 100644 modules/identity/routes/access/check-resources/handle.ts create mode 100644 modules/identity/routes/access/check-resources/spec.ts create mode 100644 modules/identity/routes/access/is-allowed/handle.ts create mode 100644 modules/identity/routes/access/is-allowed/spec.ts delete mode 100644 modules/identity/services/access.ts delete mode 100644 platform/cerbos/mod.ts delete mode 100644 platform/cerbos/package.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 3247936..a6ffc5d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "deno.enable": true, "deno.lint": false, "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit" diff --git a/api/session.ts b/api/session.ts index 9ba21d2..1704368 100644 --- a/api/session.ts +++ b/api/session.ts @@ -1,6 +1,5 @@ -import "@modules/identity/server.ts"; - -import { getAccessControlMethods, identity } from "@modules/identity/server.ts"; +import { identity } from "@modules/identity/client.ts"; +import { getPrincipalSession } from "@modules/identity/server.ts"; import { context, UnauthorizedError } from "@platform/relay"; import { storage } from "@platform/storage"; @@ -92,7 +91,7 @@ async function resolvePrincipalSession(request: Request) { // Fetch session from identity module and tag it as a resolution // call so it can break out of a resolution loop. - const session = await identity.resolve({ + const session = await getPrincipalSession({ headers: new Headers({ cookie, [IDENTITY_RESOLVE_HEADER]: "true", @@ -102,13 +101,13 @@ async function resolvePrincipalSession(request: Request) { // ### Populate Context // On successfull resolution we build the request identity context. - if ("data" in session) { + if (session !== undefined) { const context = storage.getStore(); if (context === undefined) { return; } - context.session = session.data.session; - context.principal = session.data.principal; - context.access = getAccessControlMethods(session.data.principal); + context.session = session.session; + context.principal = session.principal; + context.access = identity.access; } } diff --git a/deno.json b/deno.json index c8f254d..eb62fe0 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,6 @@ "apps/react", "modules/identity", "modules/workspace", - "platform/cerbos", "platform/config", "platform/database", "platform/logger", @@ -22,7 +21,6 @@ "@modules/identity/server.ts": "./modules/identity/server.ts", "@modules/workspace/client.ts": "./modules/workspace/client.ts", "@modules/workspace/server.ts": "./modules/workspace/server.ts", - "@platform/cerbos": "./platform/cerbos/mod.ts", "@platform/config/": "./platform/config/", "@platform/database/": "./platform/database/", "@platform/logger": "./platform/logger/mod.ts", diff --git a/deno.lock b/deno.lock index 3f088dd..f9a14fa 100644 --- a/deno.lock +++ b/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "npm:@biomejs/biome@*": "2.2.4", "npm:@biomejs/biome@2.2.4": "2.2.4", + "npm:@cerbos/core@0.24.1": "0.24.1", "npm:@cerbos/http@0.23.1": "0.23.1", "npm:@eslint/js@9.35.0": "9.35.0", "npm:@jsr/std__assert@1.0.14": "1.0.14", @@ -2372,6 +2373,8 @@ "modules/identity": { "packageJson": { "dependencies": [ + "npm:@cerbos/core@0.24.1", + "npm:@cerbos/http@0.23.1", "npm:better-auth@1.3.16", "npm:cookie@1.0.2", "npm:zod@4.1.11" @@ -2387,14 +2390,6 @@ ] } }, - "platform/cerbos": { - "packageJson": { - "dependencies": [ - "npm:@cerbos/http@0.23.1", - "npm:zod@4.1.11" - ] - } - }, "platform/config": { "packageJson": { "dependencies": [ diff --git a/platform/cerbos/cerbos.ts b/modules/identity/cerbos/client.ts similarity index 100% rename from platform/cerbos/cerbos.ts rename to modules/identity/cerbos/client.ts diff --git a/modules/identity/client.ts b/modules/identity/client.ts index 7d96283..54043b2 100644 --- a/modules/identity/client.ts +++ b/modules/identity/client.ts @@ -1,17 +1,34 @@ +import { CheckResourcesResponse } from "@cerbos/core"; import { HttpAdapter, makeClient } from "@platform/relay"; import { config } from "./config.ts"; +import checkResource from "./routes/access/check-resource/spec.ts"; +import checkResources from "./routes/access/check-resources/spec.ts"; +import isAllowed from "./routes/access/is-allowed/spec.ts"; import getById from "./routes/identities/get/spec.ts"; import loginByPassword from "./routes/login/code/spec.ts"; import loginByEmail from "./routes/login/email/spec.ts"; import loginByCode from "./routes/login/password/spec.ts"; import me from "./routes/me/spec.ts"; +const adapter = new HttpAdapter({ + url: config.url, +}); + +const access = makeClient( + { + adapter, + }, + { + isAllowed, + checkResource, + checkResources, + }, +); + export const identity = makeClient( { - adapter: new HttpAdapter({ - url: config.url, - }), + adapter, }, { /** @@ -43,5 +60,104 @@ export const identity = makeClient( */ code: loginByCode, }, + + access: { + /** + * Check if a principal is allowed to perform an action on a resource. + * + * @param resource - Resource which we are validating. + * @param action - Action which we are validating. + * + * @example + * + * await access.isAllowed( + * { + * kind: "document", + * id: "1", + * attr: { owner: "user@example.com" }, + * }, + * "view" + * ); // => true + */ + isAllowed: async (resource: Resource, action: string) => { + const response = await access.isAllowed({ body: { resource, action } }); + if ("error" in response) { + throw response.error; + } + return response.data; + }, + + /** + * Check a principal's permissions on a resource. + * + * @param resource - Resource which we are validating. + * @param actions - Actions which we are validating. + * + * @example + * + * const decision = await access.checkResource( + * { + * kind: "document", + * id: "1", + * attr: { owner: "user@example.com" }, + * }, + * ["view", "edit"], + * ); + * + * decision.isAllowed("view"); // => true + */ + checkResource: async (resource: Resource, actions: string[]) => { + const response = await access.checkResource({ body: { resource, actions } }); + if ("error" in response) { + throw response.error; + } + return new CheckResourcesResponse(response.data); + }, + + /** + * Check a principal's permissions on a set of resources. + * + * @param resources - Resources which we are validating. + * + * @example + * + * const decision = await access.checkResources([ + * { + * resource: { + * kind: "document", + * id: "1", + * attr: { owner: "user@example.com" }, + * }, + * actions: ["view", "edit"], + * }, + * { + * resource: { + * kind: "image", + * id: "1", + * attr: { owner: "user@example.com" }, + * }, + * actions: ["delete"], + * }, + * ]); + * + * decision.isAllowed({ + * resource: { kind: "document", id: "1" }, + * action: "view", + * }); // => true + */ + checkResources: async (resources: { resource: Resource; actions: string[] }[]) => { + const response = await access.checkResources({ body: resources }); + if ("error" in response) { + throw response.error; + } + return new CheckResourcesResponse(response.data); + }, + }, }, ); + +type Resource = { + kind: string; + id: string; + attr: Record; +}; diff --git a/modules/identity/package.json b/modules/identity/package.json index 7053270..8ed1511 100644 --- a/modules/identity/package.json +++ b/modules/identity/package.json @@ -8,7 +8,8 @@ "./server.ts": "./server.ts" }, "dependencies": { - "@platform/cerbos": "workspace:*", + "@cerbos/core": "0.24.1", + "@cerbos/http": "0.23.1", "@platform/config": "workspace:*", "@platform/logger": "workspace:*", "@platform/relay": "workspace:*", diff --git a/modules/identity/routes/access/check-resource/handle.ts b/modules/identity/routes/access/check-resource/handle.ts new file mode 100644 index 0000000..4d67f6c --- /dev/null +++ b/modules/identity/routes/access/check-resource/handle.ts @@ -0,0 +1,6 @@ +import { cerbos } from "../../../cerbos/client.ts"; +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ body: { resource, actions } }, { principal }) => { + return cerbos.checkResource({ principal, resource, actions }); +}); diff --git a/modules/identity/routes/access/check-resource/spec.ts b/modules/identity/routes/access/check-resource/spec.ts new file mode 100644 index 0000000..3984730 --- /dev/null +++ b/modules/identity/routes/access/check-resource/spec.ts @@ -0,0 +1,16 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route + .post("/api/v1/identity/access/check-resource") + .body( + z.strictObject({ + resource: z.strictObject({ + kind: z.string(), + id: z.string(), + attr: z.record(z.string(), z.any()), + }), + actions: z.array(z.string()), + }), + ) + .response(z.any()); diff --git a/modules/identity/routes/access/check-resources/handle.ts b/modules/identity/routes/access/check-resources/handle.ts new file mode 100644 index 0000000..2d0d5d1 --- /dev/null +++ b/modules/identity/routes/access/check-resources/handle.ts @@ -0,0 +1,6 @@ +import { cerbos } from "../../../cerbos/client.ts"; +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ body: resources }, { principal }) => { + return cerbos.checkResources({ principal, resources }); +}); diff --git a/modules/identity/routes/access/check-resources/spec.ts b/modules/identity/routes/access/check-resources/spec.ts new file mode 100644 index 0000000..60e1d90 --- /dev/null +++ b/modules/identity/routes/access/check-resources/spec.ts @@ -0,0 +1,18 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route + .post("/api/v1/identity/access/check-resources") + .body( + z.array( + z.strictObject({ + resource: z.strictObject({ + kind: z.string(), + id: z.string(), + attr: z.record(z.string(), z.any()), + }), + actions: z.array(z.string()), + }), + ), + ) + .response(z.any()); diff --git a/modules/identity/routes/access/is-allowed/handle.ts b/modules/identity/routes/access/is-allowed/handle.ts new file mode 100644 index 0000000..c67af9c --- /dev/null +++ b/modules/identity/routes/access/is-allowed/handle.ts @@ -0,0 +1,6 @@ +import { cerbos } from "../../../cerbos/client.ts"; +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ body: { resource, action } }, { principal }) => { + return cerbos.isAllowed({ principal, resource, action }); +}); diff --git a/modules/identity/routes/access/is-allowed/spec.ts b/modules/identity/routes/access/is-allowed/spec.ts new file mode 100644 index 0000000..8728df5 --- /dev/null +++ b/modules/identity/routes/access/is-allowed/spec.ts @@ -0,0 +1,16 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route + .post("/api/v1/identity/access/is-allowed") + .body( + z.strictObject({ + resource: z.strictObject({ + kind: z.string(), + id: z.string(), + attr: z.record(z.string(), z.any()), + }), + action: z.string(), + }), + ) + .response(z.boolean()); diff --git a/modules/identity/routes/identities/get/handle.ts b/modules/identity/routes/identities/get/handle.ts index 490c499..a0d09f2 100644 --- a/modules/identity/routes/identities/get/handle.ts +++ b/modules/identity/routes/identities/get/handle.ts @@ -1,17 +1,16 @@ +import { ForbiddenError, NotFoundError } from "@platform/relay"; + +import { getPrincipalById } from "../../../services/database.ts"; import route from "./spec.ts"; -export default route.access("session").handle(async () => { - // const user = await getUserById(id); - // if (user === undefined) { - // return new NotFoundError("Identity does not exist, or has been removed."); - // } - // const decision = await access.isAllowed({ kind: "identity", id: user.id, attr: {} }, "read"); - // if (decision === false) { - // return new ForbiddenError("You do not have permission to view this identity."); - // } - // return { - // id: user.id, - // roles: await getPrincipalRoles(id), - // attr: await getPrincipalAttributes(id), - // }; +export default route.access("session").handle(async ({ params: { id } }, { access }) => { + const principal = await getPrincipalById(id); + if (principal === undefined) { + return new NotFoundError("Identity does not exist, or has been removed."); + } + const decision = await access.isAllowed({ kind: "identity", id, attr: {} }, "read"); + if (decision === false) { + return new ForbiddenError("You do not have permission to view this identity."); + } + return principal; }); diff --git a/modules/identity/server.ts b/modules/identity/server.ts index 1572784..7f50a6d 100644 --- a/modules/identity/server.ts +++ b/modules/identity/server.ts @@ -9,7 +9,7 @@ import resolve from "./routes/session/resolve/spec.ts"; |-------------------------------------------------------------------------------- */ -export const identity = makeClient( +const identity = makeClient( { adapter: new HttpAdapter({ url: config.url, @@ -22,13 +22,19 @@ export const identity = makeClient( }, ); +export async function getPrincipalSession(payload: { headers: Headers }) { + const response = await identity.resolve(payload); + if ("data" in response) { + return response.data; + } +} + /* |-------------------------------------------------------------------------------- | Server Exports |-------------------------------------------------------------------------------- */ -export * from "./services/access.ts"; export * from "./services/session.ts"; export * from "./types.ts"; @@ -49,5 +55,8 @@ export default { (await import("./routes/me/handle.ts")).default, (await import("./routes/roles/handle.ts")).default, (await import("./routes/session/resolve/handle.ts")).default, + (await import("./routes/access/is-allowed/handle.ts")).default, + (await import("./routes/access/check-resource/handle.ts")).default, + (await import("./routes/access/check-resources/handle.ts")).default, ], }; diff --git a/modules/identity/services/access.ts b/modules/identity/services/access.ts deleted file mode 100644 index a15f6ae..0000000 --- a/modules/identity/services/access.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { cerbos } from "@platform/cerbos"; - -import type { Principal } from "../models/principal.ts"; - -export function getAccessControlMethods(principal: Principal) { - return { - /** - * Check if a principal is allowed to perform an action on a resource. - * - * @param resource - Resource which we are validating. - * @param action - Action which we are validating. - * - * @example - * - * await access.isAllowed( - * { - * kind: "document", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * "view" - * ); // => true - */ - isAllowed(resource: any, action: string) { - return cerbos.isAllowed({ principal, resource, action }); - }, - - /** - * Check a principal's permissions on a resource. - * - * @param resource - Resource which we are validating. - * @param actions - Actions which we are validating. - * - * @example - * - * const decision = await access.checkResource( - * { - * kind: "document", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * ["view", "edit"], - * ); - * - * decision.isAllowed("view"); // => true - */ - checkResource(resource: any, actions: string[]) { - return cerbos.checkResource({ principal, resource, actions }); - }, - - /** - * Check a principal's permissions on a set of resources. - * - * @param resources - Resources which we are validating. - * - * @example - * - * const decision = await access.checkResources([ - * { - * resource: { - * kind: "document", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * actions: ["view", "edit"], - * }, - * { - * resource: { - * kind: "image", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * actions: ["delete"], - * }, - * ]); - * - * decision.isAllowed({ - * resource: { kind: "document", id: "1" }, - * action: "view", - * }); // => true - */ - checkResources(resources: { resource: any; actions: string[] }[]) { - return cerbos.checkResources({ principal, resources }); - }, - }; -} - -export type AccessControlMethods = ReturnType; diff --git a/modules/identity/types.ts b/modules/identity/types.ts index 3bfcc06..df4fd79 100644 --- a/modules/identity/types.ts +++ b/modules/identity/types.ts @@ -3,8 +3,8 @@ import "@platform/storage"; import type { Session } from "better-auth"; -import type { AccessControlMethods } from "./access.ts"; -import type { Principal } from "./principal.ts"; +import type { identity } from "./client.ts"; +import type { Principal } from "./models/principal.ts"; declare module "@platform/storage" { interface StorageContext { @@ -21,7 +21,7 @@ declare module "@platform/storage" { /** * TODO ... */ - access?: AccessControlMethods; + access?: typeof identity.access; } } @@ -45,6 +45,6 @@ declare module "@platform/relay" { /** * TODO ... */ - access: AccessControlMethods; + access: typeof identity.access; } } diff --git a/platform/cerbos/mod.ts b/platform/cerbos/mod.ts deleted file mode 100644 index 79b27de..0000000 --- a/platform/cerbos/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./cerbos.ts"; diff --git a/platform/cerbos/package.json b/platform/cerbos/package.json deleted file mode 100644 index 83e572c..0000000 --- a/platform/cerbos/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@platform/auth", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./mod.ts", - "exports": { - ".": "./mod.ts" - }, - "dependencies": { - "@cerbos/http": "0.23.1", - "@platform/logger": "workspace:*", - "zod": "4.1.11" - } -} diff --git a/platform/relay/libraries/client.ts b/platform/relay/libraries/client.ts index ee96afc..6b035e2 100644 --- a/platform/relay/libraries/client.ts +++ b/platform/relay/libraries/client.ts @@ -3,7 +3,7 @@ import type { ZodObject, ZodType } from "zod"; import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts"; -import { Route, type Routes } from "./route.ts"; +import { Route, type RouteFn, type Routes } from "./route.ts"; /** * Factory method for generating a new relay client instance. @@ -20,6 +20,8 @@ export function makeClient(config: Config, routes: TRout const route = routes[key]; if (route instanceof Route) { client[key] = getRouteFn(route, config); + } else if (typeof route === "function") { + client[key] = route; } else { client[key] = getNestedRoute(config, route); } @@ -39,6 +41,8 @@ function getNestedRoute(config: Config, routes: TRoutes) const route = routes[key]; if (route instanceof Route) { nested[key] = getRouteFn(route, config); + } else if (typeof route === "function") { + nested[key] = route; } else { nested[key] = getNestedRoute(config, route); } @@ -148,22 +152,26 @@ type RelayRequest = { type RelayRoutes = { [TKey in keyof TRoutes]: TRoutes[TKey] extends Route - ? HasPayload extends true - ? ( - payload: Prettify< - (TRoutes[TKey]["state"]["params"] extends ZodObject ? { params: TRoutes[TKey]["$params"] } : {}) & - (TRoutes[TKey]["state"]["query"] extends ZodObject ? { query: TRoutes[TKey]["$query"] } : {}) & - (TRoutes[TKey]["state"]["body"] extends ZodType ? { body: TRoutes[TKey]["$body"] } : {}) & { - headers?: HeadersInit; - } - >, - ) => RouteResponse - : (payload?: { headers: HeadersInit }) => RouteResponse - : TRoutes[TKey] extends Routes - ? RelayRoutes - : never; + ? ClientRoute + : TRoutes[TKey] extends RouteFn + ? TRoutes[TKey] + : TRoutes[TKey] extends Routes + ? RelayRoutes + : never; }; +type ClientRoute = HasPayload extends true + ? ( + payload: Prettify< + (TRoute["state"]["params"] extends ZodObject ? { params: TRoute["$params"] } : {}) & + (TRoute["state"]["query"] extends ZodObject ? { query: TRoute["$query"] } : {}) & + (TRoute["state"]["body"] extends ZodType ? { body: TRoute["$body"] } : {}) & { + headers?: HeadersInit; + } + >, + ) => RouteResponse + : (payload?: { headers: HeadersInit }) => RouteResponse; + type HasPayload = TRoute["state"]["params"] extends ZodObject ? true : TRoute["state"]["query"] extends ZodObject diff --git a/platform/relay/libraries/route.ts b/platform/relay/libraries/route.ts index bb8bcec..3b344bc 100644 --- a/platform/relay/libraries/route.ts +++ b/platform/relay/libraries/route.ts @@ -443,9 +443,11 @@ export const route: { */ export type Routes = { - [key: string]: Routes | Route; + [key: string]: Routes | Route | RouteFn; }; +export type RouteFn = (...args: any[]) => any; + type RouteState = { method: RouteMethod; path: string;