Template
1
0

feat: identity cerbos implementation

This commit is contained in:
2025-09-26 12:48:31 +02:00
parent 0819534901
commit fe50394ec0
21 changed files with 254 additions and 162 deletions

View File

@@ -3,6 +3,7 @@
"deno.enable": true, "deno.enable": true,
"deno.lint": false, "deno.lint": false,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit", "source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit" "source.fixAll.biome": "explicit"

View File

@@ -1,6 +1,5 @@
import "@modules/identity/server.ts"; import { identity } from "@modules/identity/client.ts";
import { getPrincipalSession } from "@modules/identity/server.ts";
import { getAccessControlMethods, identity } from "@modules/identity/server.ts";
import { context, UnauthorizedError } from "@platform/relay"; import { context, UnauthorizedError } from "@platform/relay";
import { storage } from "@platform/storage"; 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 // Fetch session from identity module and tag it as a resolution
// call so it can break out of a resolution loop. // call so it can break out of a resolution loop.
const session = await identity.resolve({ const session = await getPrincipalSession({
headers: new Headers({ headers: new Headers({
cookie, cookie,
[IDENTITY_RESOLVE_HEADER]: "true", [IDENTITY_RESOLVE_HEADER]: "true",
@@ -102,13 +101,13 @@ async function resolvePrincipalSession(request: Request) {
// ### Populate Context // ### Populate Context
// On successfull resolution we build the request identity context. // On successfull resolution we build the request identity context.
if ("data" in session) { if (session !== undefined) {
const context = storage.getStore(); const context = storage.getStore();
if (context === undefined) { if (context === undefined) {
return; return;
} }
context.session = session.data.session; context.session = session.session;
context.principal = session.data.principal; context.principal = session.principal;
context.access = getAccessControlMethods(session.data.principal); context.access = identity.access;
} }
} }

View File

@@ -6,7 +6,6 @@
"apps/react", "apps/react",
"modules/identity", "modules/identity",
"modules/workspace", "modules/workspace",
"platform/cerbos",
"platform/config", "platform/config",
"platform/database", "platform/database",
"platform/logger", "platform/logger",
@@ -22,7 +21,6 @@
"@modules/identity/server.ts": "./modules/identity/server.ts", "@modules/identity/server.ts": "./modules/identity/server.ts",
"@modules/workspace/client.ts": "./modules/workspace/client.ts", "@modules/workspace/client.ts": "./modules/workspace/client.ts",
"@modules/workspace/server.ts": "./modules/workspace/server.ts", "@modules/workspace/server.ts": "./modules/workspace/server.ts",
"@platform/cerbos": "./platform/cerbos/mod.ts",
"@platform/config/": "./platform/config/", "@platform/config/": "./platform/config/",
"@platform/database/": "./platform/database/", "@platform/database/": "./platform/database/",
"@platform/logger": "./platform/logger/mod.ts", "@platform/logger": "./platform/logger/mod.ts",

11
deno.lock generated
View File

@@ -3,6 +3,7 @@
"specifiers": { "specifiers": {
"npm:@biomejs/biome@*": "2.2.4", "npm:@biomejs/biome@*": "2.2.4",
"npm:@biomejs/biome@2.2.4": "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:@cerbos/http@0.23.1": "0.23.1",
"npm:@eslint/js@9.35.0": "9.35.0", "npm:@eslint/js@9.35.0": "9.35.0",
"npm:@jsr/std__assert@1.0.14": "1.0.14", "npm:@jsr/std__assert@1.0.14": "1.0.14",
@@ -2372,6 +2373,8 @@
"modules/identity": { "modules/identity": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@cerbos/core@0.24.1",
"npm:@cerbos/http@0.23.1",
"npm:better-auth@1.3.16", "npm:better-auth@1.3.16",
"npm:cookie@1.0.2", "npm:cookie@1.0.2",
"npm:zod@4.1.11" "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": { "platform/config": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [

View File

@@ -1,17 +1,34 @@
import { CheckResourcesResponse } from "@cerbos/core";
import { HttpAdapter, makeClient } from "@platform/relay"; import { HttpAdapter, makeClient } from "@platform/relay";
import { config } from "./config.ts"; 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 getById from "./routes/identities/get/spec.ts";
import loginByPassword from "./routes/login/code/spec.ts"; import loginByPassword from "./routes/login/code/spec.ts";
import loginByEmail from "./routes/login/email/spec.ts"; import loginByEmail from "./routes/login/email/spec.ts";
import loginByCode from "./routes/login/password/spec.ts"; import loginByCode from "./routes/login/password/spec.ts";
import me from "./routes/me/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( export const identity = makeClient(
{ {
adapter: new HttpAdapter({ adapter,
url: config.url,
}),
}, },
{ {
/** /**
@@ -43,5 +60,104 @@ export const identity = makeClient(
*/ */
code: loginByCode, 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<string, any>;
};

View File

@@ -8,7 +8,8 @@
"./server.ts": "./server.ts" "./server.ts": "./server.ts"
}, },
"dependencies": { "dependencies": {
"@platform/cerbos": "workspace:*", "@cerbos/core": "0.24.1",
"@cerbos/http": "0.23.1",
"@platform/config": "workspace:*", "@platform/config": "workspace:*",
"@platform/logger": "workspace:*", "@platform/logger": "workspace:*",
"@platform/relay": "workspace:*", "@platform/relay": "workspace:*",

View File

@@ -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 });
});

View File

@@ -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());

View File

@@ -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 });
});

View File

@@ -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());

View File

@@ -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 });
});

View File

@@ -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());

View File

@@ -1,17 +1,16 @@
import { ForbiddenError, NotFoundError } from "@platform/relay";
import { getPrincipalById } from "../../../services/database.ts";
import route from "./spec.ts"; import route from "./spec.ts";
export default route.access("session").handle(async () => { export default route.access("session").handle(async ({ params: { id } }, { access }) => {
// const user = await getUserById(id); const principal = await getPrincipalById(id);
// if (user === undefined) { if (principal === undefined) {
// return new NotFoundError("Identity does not exist, or has been removed."); return new NotFoundError("Identity does not exist, or has been removed.");
// } }
// const decision = await access.isAllowed({ kind: "identity", id: user.id, attr: {} }, "read"); const decision = await access.isAllowed({ kind: "identity", id, attr: {} }, "read");
// if (decision === false) { if (decision === false) {
// return new ForbiddenError("You do not have permission to view this identity."); return new ForbiddenError("You do not have permission to view this identity.");
// } }
// return { return principal;
// id: user.id,
// roles: await getPrincipalRoles(id),
// attr: await getPrincipalAttributes(id),
// };
}); });

View File

@@ -9,7 +9,7 @@ import resolve from "./routes/session/resolve/spec.ts";
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
*/ */
export const identity = makeClient( const identity = makeClient(
{ {
adapter: new HttpAdapter({ adapter: new HttpAdapter({
url: config.url, 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 | Server Exports
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
*/ */
export * from "./services/access.ts";
export * from "./services/session.ts"; export * from "./services/session.ts";
export * from "./types.ts"; export * from "./types.ts";
@@ -49,5 +55,8 @@ export default {
(await import("./routes/me/handle.ts")).default, (await import("./routes/me/handle.ts")).default,
(await import("./routes/roles/handle.ts")).default, (await import("./routes/roles/handle.ts")).default,
(await import("./routes/session/resolve/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,
], ],
}; };

View File

@@ -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<typeof getAccessControlMethods>;

View File

@@ -3,8 +3,8 @@ import "@platform/storage";
import type { Session } from "better-auth"; import type { Session } from "better-auth";
import type { AccessControlMethods } from "./access.ts"; import type { identity } from "./client.ts";
import type { Principal } from "./principal.ts"; import type { Principal } from "./models/principal.ts";
declare module "@platform/storage" { declare module "@platform/storage" {
interface StorageContext { interface StorageContext {
@@ -21,7 +21,7 @@ declare module "@platform/storage" {
/** /**
* TODO ... * TODO ...
*/ */
access?: AccessControlMethods; access?: typeof identity.access;
} }
} }
@@ -45,6 +45,6 @@ declare module "@platform/relay" {
/** /**
* TODO ... * TODO ...
*/ */
access: AccessControlMethods; access: typeof identity.access;
} }
} }

View File

@@ -1 +0,0 @@
export * from "./cerbos.ts";

View File

@@ -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"
}
}

View File

@@ -3,7 +3,7 @@
import type { ZodObject, ZodType } from "zod"; import type { ZodObject, ZodType } from "zod";
import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts"; 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. * Factory method for generating a new relay client instance.
@@ -20,6 +20,8 @@ export function makeClient<TRoutes extends Routes>(config: Config, routes: TRout
const route = routes[key]; const route = routes[key];
if (route instanceof Route) { if (route instanceof Route) {
client[key] = getRouteFn(route, config); client[key] = getRouteFn(route, config);
} else if (typeof route === "function") {
client[key] = route;
} else { } else {
client[key] = getNestedRoute(config, route); client[key] = getNestedRoute(config, route);
} }
@@ -39,6 +41,8 @@ function getNestedRoute<TRoutes extends Routes>(config: Config, routes: TRoutes)
const route = routes[key]; const route = routes[key];
if (route instanceof Route) { if (route instanceof Route) {
nested[key] = getRouteFn(route, config); nested[key] = getRouteFn(route, config);
} else if (typeof route === "function") {
nested[key] = route;
} else { } else {
nested[key] = getNestedRoute(config, route); nested[key] = getNestedRoute(config, route);
} }
@@ -148,22 +152,26 @@ type RelayRequest = {
type RelayRoutes<TRoutes extends Routes> = { type RelayRoutes<TRoutes extends Routes> = {
[TKey in keyof TRoutes]: TRoutes[TKey] extends Route [TKey in keyof TRoutes]: TRoutes[TKey] extends Route
? HasPayload<TRoutes[TKey]> extends true ? ClientRoute<TRoutes[TKey]>
? ( : TRoutes[TKey] extends RouteFn
payload: Prettify< ? TRoutes[TKey]
(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<TRoutes[TKey]>
: (payload?: { headers: HeadersInit }) => RouteResponse<TRoutes[TKey]>
: TRoutes[TKey] extends Routes : TRoutes[TKey] extends Routes
? RelayRoutes<TRoutes[TKey]> ? RelayRoutes<TRoutes[TKey]>
: never; : never;
}; };
type ClientRoute<TRoute extends Route> = HasPayload<TRoute> 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<TRoute>
: (payload?: { headers: HeadersInit }) => RouteResponse<TRoute>;
type HasPayload<TRoute extends Route> = TRoute["state"]["params"] extends ZodObject type HasPayload<TRoute extends Route> = TRoute["state"]["params"] extends ZodObject
? true ? true
: TRoute["state"]["query"] extends ZodObject : TRoute["state"]["query"] extends ZodObject

View File

@@ -443,9 +443,11 @@ export const route: {
*/ */
export type Routes = { export type Routes = {
[key: string]: Routes | Route; [key: string]: Routes | Route | RouteFn;
}; };
export type RouteFn = (...args: any[]) => any;
type RouteState = { type RouteState = {
method: RouteMethod; method: RouteMethod;
path: string; path: string;