Template
1
0

feat: add cerbos access control

This commit is contained in:
2025-09-19 03:28:00 +02:00
parent d322138502
commit 74a9426bcc
41 changed files with 999 additions and 821 deletions

9
.editoconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -1,6 +1,8 @@
FROM denoland/deno:2.3.1 FROM denoland/deno:2.5.1
ENV TZ=UTC ENV TZ=UTC
ENV PORT=8370 ENV PORT=8370
EXPOSE 8370 EXPOSE 8370
WORKDIR /app WORKDIR /app
@@ -10,10 +12,6 @@ COPY relay/ ./relay/
COPY .npmrc . COPY .npmrc .
COPY deno-docker.json ./deno.json COPY deno-docker.json ./deno.json
RUN chown -R deno:deno /app/
USER deno
RUN deno install --allow-scripts RUN deno install --allow-scripts
CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"] CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"]

View File

@@ -0,0 +1,19 @@
meta {
name: Get By ID
type: http
seq: 2
}
get {
url: {{url}}/accounts/:id
body: none
auth: inherit
}
params:path {
id:
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,19 @@
import { cerbos } from "./cerbos.ts";
import type { Principal } from "./principal.ts";
import { Resource } from "./resources.ts";
export function access(principal: Principal) {
return {
isAllowed(resource: Resource, action: string) {
return cerbos.isAllowed({ principal, resource, action });
},
checkResource(resource: Resource, actions: string[]) {
return cerbos.checkResource({ principal, resource, actions });
},
checkResources(resources: { resource: Resource; actions: string[] }[]) {
return cerbos.checkResources({ principal, resources });
},
};
}
export type Access = ReturnType<typeof access>;

View File

@@ -1,82 +1,21 @@
import { Auth, ResolvedSession } from "@valkyr/auth"; import { Auth } from "@valkyr/auth";
import z from "zod";
import { db } from "~stores/read-store/database.ts";
import { access } from "./access.ts";
import { config } from "./config.ts"; import { config } from "./config.ts";
import { principal } from "./principal.ts";
import { resources } from "./resources.ts";
export const auth = new Auth( export const auth = new Auth({
{ principal,
settings: { resources,
access,
jwt: {
algorithm: "RS256", algorithm: "RS256",
privateKey: config.privateKey, privateKey: config.privateKey,
publicKey: config.publicKey, publicKey: config.publicKey,
issuer: "http://localhost", issuer: "http://localhost",
audience: "http://localhost", audience: "http://localhost",
}, },
session: z.object({ });
accountId: z.string(),
}),
permissions: {} as const,
guards: [],
},
{
roles: {
async add(role) {
await db.collection("roles").insertOne(role);
},
async getById(id) { export type Session = typeof auth.$session;
const role = await db.collection("roles").findOne({ id });
if (role === null) {
return undefined;
}
return role;
},
async getBySession({ accountId }) {
const account = await db.collection("accounts").findOne({ id: accountId });
if (account === null) {
return [];
}
return db
.collection("roles")
.find({ id: { $in: account.roles } })
.toArray();
},
async setPermissions() {
throw new Error("MongoRolesProvider > .setPermissions is managed by Role aggregate projections");
},
async delete(id) {
await db.collection("roles").deleteOne({ id });
},
async assignAccount(roleId: string, accountId: string): Promise<void> {
await db.collection("accounts").updateOne(
{ id: accountId },
{
$push: {
roles: roleId,
},
},
);
},
async removeAccount(roleId: string, accountId: string): Promise<void> {
await db.collection("roles").updateOne(
{ id: accountId },
{
$pull: {
roles: roleId,
},
},
);
},
},
},
);
export type Session = ResolvedSession<typeof auth>;
export type Permissions = (typeof auth)["$permissions"];

View File

@@ -0,0 +1,8 @@
import { HTTP } from "@cerbos/http";
export const cerbos = new HTTP("http://localhost:3592", {
adminCredentials: {
username: "cerbos",
password: "cerbosAdmin",
},
});

View File

@@ -0,0 +1,18 @@
import { RoleSchema } from "@spec/schemas/account/role.ts";
import { PrincipalProvider } from "@valkyr/auth";
import { db } from "~stores/read-store/database.ts";
export const principal = new PrincipalProvider(RoleSchema, {}, async function (id: string) {
const account = await db.collection("accounts").findOne({ id });
if (account === null) {
return undefined;
}
return {
id,
roles: account.roles,
attributes: {},
};
});
export type Principal = typeof principal.$principal;

View File

@@ -0,0 +1,10 @@
import { ResourceRegistry } from "@valkyr/auth";
export const resources = new ResourceRegistry([
{
kind: "account",
attributes: {},
},
] as const);
export type Resource = typeof resources.$resource;

View File

@@ -2,9 +2,9 @@ import { parseArgs } from "@std/cli";
import { Parser, toString } from "./parsers.ts"; import { Parser, toString } from "./parsers.ts";
export function getArgsVariable(key: string, fallback?: string): string; export function getArgsVariable(key: string, fallback?: any): string;
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>; export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: any): ReturnType<T>;
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> { export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: any): ReturnType<T> {
if (typeof parse === "string") { if (typeof parse === "string") {
fallback = parse; fallback = parse;
parse = undefined; parse = undefined;
@@ -17,5 +17,5 @@ export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallba
} }
throw new Error(`Config Exception: Missing ${key} variable in arguments`); throw new Error(`Config Exception: Missing ${key} variable in arguments`);
} }
return parse ? parse(value) : toString(value); return parse ? parse(value) : (toString(value) as any);
} }

View File

@@ -11,9 +11,9 @@ const env = await load();
* @param key - Environment key to resolve. * @param key - Environment key to resolve.
* @param parse - Parser function to convert the value to the desired type. Default: `string`. * @param parse - Parser function to convert the value to the desired type. Default: `string`.
*/ */
export function getEnvironmentVariable(key: string, fallback?: string): string; export function getEnvironmentVariable(key: string, fallback?: any): string;
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>; export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: any): ReturnType<T>;
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> { export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: any): ReturnType<T> {
if (typeof parse === "string") { if (typeof parse === "string") {
fallback = parse; fallback = parse;
parse = undefined; parse = undefined;
@@ -25,7 +25,7 @@ export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T,
} }
throw new Error(`Config Exception: Missing ${key} variable in configuration`); throw new Error(`Config Exception: Missing ${key} variable in configuration`);
} }
return parse ? parse(value) : toString(value); return parse ? parse(value) : (toString(value) as any);
} }
/** /**

View File

@@ -166,7 +166,7 @@ export class Api {
); );
} }
if (route.state.access === "session" && req.isAuthenticated === false) { if (route.state.access === "authenticated" && req.isAuthenticated === false) {
return toResponse(new UnauthorizedError(), request); return toResponse(new UnauthorizedError(), request);
} }

View File

@@ -2,7 +2,9 @@ import { ServerContext } from "@spec/relay";
import type { Sockets } from "~libraries/socket/sockets.ts"; import type { Sockets } from "~libraries/socket/sockets.ts";
import { Access } from "../auth/access.ts";
import { Session } from "../auth/auth.ts"; import { Session } from "../auth/auth.ts";
import { Principal } from "../auth/principal.ts";
import { req } from "./request.ts"; import { req } from "./request.ts";
declare module "@spec/relay" { declare module "@spec/relay" {
@@ -17,17 +19,21 @@ declare module "@spec/relay" {
*/ */
isAuthenticated: boolean; isAuthenticated: boolean;
/**
* Get account id from session, throws an error if the request
* does not have a valid session.
*/
accountId: string;
/** /**
* Get request session instance. * Get request session instance.
*/ */
session: Session; session: Session;
/**
* Get request principal.
*/
principal: Principal;
/**
* Get access control session.
*/
access: Access;
/** /**
* Sockets instance attached to the server. * Sockets instance attached to the server.
*/ */
@@ -43,14 +49,18 @@ export function getRequestContext(request: Request): ServerContext {
return req.isAuthenticated; return req.isAuthenticated;
}, },
get accountId() {
return this.session.accountId;
},
get session(): Session { get session(): Session {
return req.session; return req.session;
}, },
get principal(): Principal {
return req.session.principal;
},
get access(): Access {
return req.session.access;
},
get sockets(): Sockets { get sockets(): Sockets {
return req.sockets; return req.sockets;
}, },

View File

@@ -1,11 +1,11 @@
import { InternalServerError, UnauthorizedError } from "@spec/relay"; import { InternalServerError, UnauthorizedError } from "@spec/relay";
import { Session } from "../auth/auth.ts"; import { Session } from "../auth/auth.ts";
import { asyncLocalStorage } from "./storage.ts"; import { storage } from "./storage.ts";
export const req = { export const req = {
get store() { get store() {
const store = asyncLocalStorage.getStore(); const store = storage.getStore();
if (store === undefined) { if (store === undefined) {
throw new InternalServerError("AsyncLocalStorage not defined."); throw new InternalServerError("AsyncLocalStorage not defined.");
} }
@@ -55,7 +55,7 @@ export const req = {
* Typically used when utility functions might run in and out of request scope. * Typically used when utility functions might run in and out of request scope.
*/ */
getStore() { getStore() {
return asyncLocalStorage.getStore(); return storage.getStore();
}, },
} as const; } as const;

View File

@@ -3,7 +3,9 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { Session } from "~libraries/auth/mod.ts"; import type { Session } from "~libraries/auth/mod.ts";
import type { Sockets } from "~libraries/socket/sockets.ts"; import type { Sockets } from "~libraries/socket/sockets.ts";
export const asyncLocalStorage = new AsyncLocalStorage<{ export const storage = new AsyncLocalStorage<Storage>();
export type Storage = {
session?: Session; session?: Session;
info: { info: {
method: string; method: string;
@@ -14,4 +16,4 @@ export const asyncLocalStorage = new AsyncLocalStorage<{
response: { response: {
headers: Headers; headers: Headers;
}; };
}>(); };

View File

@@ -5,20 +5,22 @@
"migrate": "deno run --allow-all .tasks/migrate.ts" "migrate": "deno run --allow-all .tasks/migrate.ts"
}, },
"dependencies": { "dependencies": {
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1", "@cerbos/grpc": "0.23.1",
"@cerbos/http": "0.23.1",
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
"@spec/modules": "workspace:*", "@spec/modules": "workspace:*",
"@spec/relay": "workspace:*", "@spec/relay": "workspace:*",
"@spec/shared": "workspace:*", "@spec/shared": "workspace:*",
"@std/cli": "npm:@jsr/std__cli@1", "@std/cli": "npm:@jsr/std__cli@1.0.22",
"@std/dotenv": "npm:@jsr/std__dotenv@0.225", "@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
"@std/fs": "npm:@jsr/std__fs@1", "@std/fs": "npm:@jsr/std__fs@1.0.19",
"@std/path": "npm:@jsr/std__path@1", "@std/path": "npm:@jsr/std__path@1.1.2",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2", "@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.3",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.6", "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1", "@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1", "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
"cookie": "1", "cookie": "1.0.2",
"mongodb": "6", "mongodb": "6.20.0",
"zod": "4" "zod": "4.1.9"
} }
} }

View File

@@ -13,6 +13,7 @@ export default create.access("public").handle(async ({ body: { name, email } })
.create() .create()
.addName(name) .addName(name)
.addEmailStrategy(email) .addEmailStrategy(email)
.addRole("user")
.save() .save()
.then((account) => account.id); .then((account) => account.id);
}); });

View File

@@ -0,0 +1,17 @@
import { ForbiddenError } from "@spec/relay/mod.ts";
import { NotFoundError } from "@spec/relay/mod.ts";
import { getById } from "@spec/schemas/account/routes.ts";
import { db } from "~stores/read-store/database.ts";
export default getById.access("authenticated").handle(async ({ params: { id } }, { access }) => {
const account = await db.collection("accounts").findOne({ id });
if (account === null) {
return new NotFoundError();
}
const decision = await access.isAllowed({ kind: "account", id: account.id, attributes: {} }, "read");
if (decision === false) {
return new ForbiddenError();
}
return account;
});

View File

@@ -70,7 +70,7 @@ export default code.access("public").handle(async ({ params: { accountId, codeId
status: 302, status: 302,
headers: { headers: {
location: next, location: next,
"set-cookie": cookie.serialize("token", await auth.generate({ accountId: account.id }, "1 week"), options), "set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
}, },
}); });
} }
@@ -78,7 +78,7 @@ export default code.access("public").handle(async ({ params: { accountId, codeId
return new Response(null, { return new Response(null, {
status: 200, status: 200,
headers: { headers: {
"set-cookie": cookie.serialize("token", await auth.generate({ accountId: account.id }, "1 week"), options), "set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
}, },
}); });
}); });

View File

@@ -8,7 +8,7 @@ import { password } from "~libraries/crypto/mod.ts";
import { logger } from "~libraries/logger/mod.ts"; import { logger } from "~libraries/logger/mod.ts";
import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts"; import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts";
export default route.handle(async ({ body: { alias, password: userPassword } }) => { export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => {
const strategy = await getPasswordStrategyByAlias(alias); const strategy = await getPasswordStrategyByAlias(alias);
if (strategy === undefined) { if (strategy === undefined) {
return logger.info({ return logger.info({
@@ -28,7 +28,7 @@ export default route.handle(async ({ body: { alias, password: userPassword } })
headers: { headers: {
"set-cookie": cookie.serialize( "set-cookie": cookie.serialize(
"token", "token",
await auth.generate({ accountId: strategy.accountId }, "1 week"), await auth.generate({ id: strategy.accountId }, "1 week"),
config.cookie(1000 * 60 * 60 * 24 * 7), config.cookie(1000 * 60 * 60 * 24 * 7),
), ),
}, },

View File

@@ -3,8 +3,8 @@ import { session } from "@spec/schemas/auth/routes.ts";
import { getAccountById } from "~stores/read-store/methods.ts"; import { getAccountById } from "~stores/read-store/methods.ts";
export default session.access("session").handle(async ({ accountId }) => { export default session.access("authenticated").handle(async ({ principal }) => {
const account = await getAccountById(accountId); const account = await getAccountById(principal.id);
if (account === undefined) { if (account === undefined) {
return new UnauthorizedError(); return new UnauthorizedError();
} }

View File

@@ -3,7 +3,7 @@ import cookie from "cookie";
import { auth, type Session } from "~libraries/auth/mod.ts"; import { auth, type Session } from "~libraries/auth/mod.ts";
import { logger } from "~libraries/logger/mod.ts"; import { logger } from "~libraries/logger/mod.ts";
import { asyncLocalStorage } from "~libraries/server/mod.ts"; import { type Storage, storage } from "~libraries/server/mod.ts";
import { Api, resolveRoutes } from "~libraries/server/mod.ts"; import { Api, resolveRoutes } from "~libraries/server/mod.ts";
import { config } from "./config.ts"; import { config } from "./config.ts";
@@ -45,8 +45,6 @@ Deno.serve(
async (request) => { async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
// ### Session
let session: Session | undefined; let session: Session | undefined;
const token = cookie.parse(request.headers.get("cookie") ?? "").token; const token = cookie.parse(request.headers.get("cookie") ?? "").token;
@@ -63,31 +61,23 @@ Deno.serve(
session = resolved; session = resolved;
} }
// ### Headers const context = {
// Set the default headers.
const headers = new Headers();
// ### Handle
const ts = performance.now();
return asyncLocalStorage.run(
{
session, session,
info: { info: {
method: request.url, method: request.url,
start: Date.now(), start: Date.now(),
}, },
response: { response: {
headers, headers: new Headers(),
}, },
}, } satisfies Storage;
async () => {
return storage.run(context, async () => {
return api.fetch(request).finally(() => { return api.fetch(request).finally(() => {
log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`); log.info(
`${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`,
);
});
}); });
}, },
);
},
); );

View File

@@ -1,4 +1,5 @@
import { toAccountDocument } from "@spec/schemas/account/account.ts"; import { toAccountDocument } from "@spec/schemas/account/account.ts";
import { Role } from "@spec/schemas/account/role.ts";
import { Strategy } from "@spec/schemas/account/strategies.ts"; import { Strategy } from "@spec/schemas/account/strategies.ts";
import { Avatar } from "@spec/schemas/avatar.ts"; import { Avatar } from "@spec/schemas/avatar.ts";
import { Contact } from "@spec/schemas/contact.ts"; import { Contact } from "@spec/schemas/contact.ts";
@@ -22,6 +23,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
emails: [], emails: [],
}; };
strategies: Strategy[] = []; strategies: Strategy[] = [];
roles: Role[] = [];
createdAt!: Date; createdAt!: Date;
updatedAt!: Date; updatedAt!: Date;
@@ -51,6 +53,11 @@ export class Account extends AggregateRoot<EventStoreFactory> {
this.updatedAt = getDate(event.created); this.updatedAt = getDate(event.created);
break; break;
} }
case "account:role:added": {
this.roles.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "strategy:email:added": { case "strategy:email:added": {
this.strategies.push({ type: "email", value: event.data }); this.strategies.push({ type: "email", value: event.data });
this.updatedAt = getDate(event.created); this.updatedAt = getDate(event.created);
@@ -103,11 +110,11 @@ export class Account extends AggregateRoot<EventStoreFactory> {
}); });
} }
addRole(roleId: string, meta: Auditor = systemAuditor): this { addRole(role: Role, meta: Auditor = systemAuditor): this {
return this.push({ return this.push({
stream: this.id, stream: this.id,
type: "account:role:added", type: "account:role:added",
data: roleId, data: role,
meta, meta,
}); });
} }
@@ -194,8 +201,8 @@ projector.on("account:email:added", async ({ stream: id, data: email }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } }); await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
}); });
projector.on("account:role:added", async ({ stream: id, data: roleId }) => { projector.on("account:role:added", async ({ stream: id, data: role }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } }); await db.collection("accounts").updateOne({ id }, { $push: { roles: role } });
}); });
projector.on("strategy:email:added", async ({ stream: id, data: email }) => { projector.on("strategy:email:added", async ({ stream: id, data: email }) => {

View File

@@ -1,118 +0,0 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { db } from "~libraries/read-store/database.ts";
import type { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
import { projector } from "../projector.ts";
export class Role extends AggregateRoot<EventStoreFactory> {
static override readonly name = "role";
id!: string;
name!: string;
permissions: { [resource: string]: Set<string> } = {};
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Role);
static create(data: RoleCreatedData, meta: Auditor): Role {
return new Role().push({
type: "role:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Role | undefined> {
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "role:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
this.updatedAt = getDate(event.created);
break;
}
case "role:name-set": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "role:permissions-set": {
for (const operation of event.data) {
if (operation.type === "grant") {
if (this.permissions[operation.resource] === undefined) {
this.permissions[operation.resource] = new Set();
}
this.permissions[operation.resource].add(operation.action);
}
if (operation.type === "deny") {
if (operation.action === undefined) {
delete this.permissions[operation.resource];
} else {
this.permissions[operation.resource]?.delete(operation.action);
}
}
}
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
setName(name: string, meta: Auditor): this {
return this.push({
type: "role:name-set",
stream: this.id,
data: name,
meta,
});
}
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
return this.push({
type: "role:permissions-set",
stream: this.id,
data: operations,
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
await db.collection("roles").insertOne({
id: stream,
name,
permissions: permissions.reduce(
(map, permission) => {
map[permission.resource] = permission.actions;
return map;
},
{} as Record<string, string[]>,
),
});
});

View File

@@ -1,3 +1,4 @@
import { RoleSchema } from "@spec/schemas/account/role.ts";
import { EmailSchema } from "@spec/schemas/email.ts"; import { EmailSchema } from "@spec/schemas/email.ts";
import { NameSchema } from "@spec/schemas/name.ts"; import { NameSchema } from "@spec/schemas/name.ts";
import { event } from "@valkyr/event-store"; import { event } from "@valkyr/event-store";
@@ -10,5 +11,5 @@ export default [
event.type("account:avatar:added").data(z.string()).meta(AuditorSchema), event.type("account:avatar:added").data(z.string()).meta(AuditorSchema),
event.type("account:name:added").data(NameSchema).meta(AuditorSchema), event.type("account:name:added").data(NameSchema).meta(AuditorSchema),
event.type("account:email:added").data(EmailSchema).meta(AuditorSchema), event.type("account:email:added").data(EmailSchema).meta(AuditorSchema),
event.type("account:role:added").data(z.string()).meta(AuditorSchema), event.type("account:role:added").data(RoleSchema).meta(AuditorSchema),
]; ];

View File

@@ -3,9 +3,8 @@ import { EventFactory } from "@valkyr/event-store";
import account from "./account.ts"; import account from "./account.ts";
import code from "./code.ts"; import code from "./code.ts";
import organization from "./organization.ts"; import organization from "./organization.ts";
import role from "./role.ts";
import strategy from "./strategy.ts"; import strategy from "./strategy.ts";
export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]); export const events = new EventFactory([...account, ...code, ...organization, ...strategy]);
export type EventStoreFactory = typeof events; export type EventStoreFactory = typeof events;

View File

@@ -1,37 +0,0 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { AuditorSchema } from "./auditor.ts";
const CreatedSchema = z.object({
name: z.string(),
permissions: z.array(
z.object({
resource: z.string(),
actions: z.array(z.string()),
}),
),
});
const OperationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("grant"),
resource: z.string(),
action: z.string(),
}),
z.object({
type: z.literal("deny"),
resource: z.string(),
action: z.string().optional(),
}),
]);
export default [
event.type("role:created").data(CreatedSchema).meta(AuditorSchema),
event.type("role:name-set").data(z.string()).meta(AuditorSchema),
event.type("role:permissions-set").data(z.array(OperationSchema)).meta(AuditorSchema),
];
export type RoleCreatedData = z.infer<typeof CreatedSchema>;
export type RolePermissionOperation = z.infer<typeof OperationSchema>;

View File

@@ -1,4 +1,3 @@
import { RoleDocument } from "@spec/schemas/access/role.ts";
import type { AccountDocument } from "@spec/schemas/account/account.ts"; import type { AccountDocument } from "@spec/schemas/account/account.ts";
import { config } from "~config"; import { config } from "~config";
@@ -6,7 +5,6 @@ import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
export const db = getDatabaseAccessor<{ export const db = getDatabaseAccessor<{
accounts: AccountDocument; accounts: AccountDocument;
roles: RoleDocument;
}>(`${config.name}:read-store`); }>(`${config.name}:read-store`);
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined { export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {

View File

@@ -12,29 +12,29 @@
"dependencies": { "dependencies": {
"@spec/relay": "workspace:*", "@spec/relay": "workspace:*",
"@spec/schemas": "workspace:*", "@spec/schemas": "workspace:*",
"@tanstack/react-query": "5", "@tanstack/react-query": "5.89.0",
"@tanstack/react-router": "1", "@tanstack/react-router": "1.131.47",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0-beta.3", "@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1", "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1",
"fast-equals": "5", "fast-equals": "5.2.2",
"react": "19", "react": "19.1.1",
"react-dom": "19", "react-dom": "19.1.1",
"tailwindcss": "4", "tailwindcss": "4.1.13",
"zod": "4" "zod": "4.1.9"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9", "@eslint/js": "9.35.0",
"@tailwindcss/vite": "4", "@tailwindcss/vite": "4.1.13",
"@tanstack/react-router-devtools": "1", "@tanstack/react-router-devtools": "1.131.47",
"@types/react": "19", "@types/react": "19.1.13",
"@types/react-dom": "19", "@types/react-dom": "19.1.9",
"@vitejs/plugin-react": "4", "@vitejs/plugin-react": "4.7.0",
"eslint": "9", "eslint": "9.35.0",
"eslint-plugin-react-hooks": "5", "eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4", "eslint-plugin-react-refresh": "0.4.20",
"globals": "16", "globals": "16.4.0",
"typescript": "5", "typescript": "5.9.2",
"typescript-eslint": "8", "typescript-eslint": "8.44.0",
"vite": "7" "vite": "7.1.6"
} }
} }

14
cerbos/config.yaml Normal file
View File

@@ -0,0 +1,14 @@
server:
adminAPI:
enabled: true
adminCredentials:
username: cerbos
passwordHash: JDJ5JDEwJDc5VzBkQ0NUWHFTT3N1OW9xZkx5ZC43M0tuM0JBSTU0dVRsMVBkOEtuYVBCaWFzVXk5d0phCgo=
httpListenAddr: ":3592"
grpcListenAddr: ":3593"
storage:
driver: disk
disk:
directory: /data/policies
watchForChanges: true

View File

@@ -0,0 +1,47 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: account
version: default
rules:
### Read
- actions:
- read
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- read
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
### Update
- actions:
- update
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
### Delete
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id

View File

@@ -21,7 +21,7 @@
"description": "Start react application instance." "description": "Start react application instance."
}, },
"check": { "check": {
"command": "deno check ./mod.ts", "command": "deno check ./api/server.ts",
"description": "Runs a check on all the projects main entry files." "description": "Runs a check on all the projects main entry files."
}, },
"lint": { "lint": {

1080
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,18 @@
services: services:
cerbos:
container_name: cerbos
image: ghcr.io/cerbos/cerbos:latest
command: ["server", "--config=/config.yaml"] # <--- ensure config is used
ports:
- "3592:3592"
- "3593:3593"
- "3594:3594"
volumes:
- ./cerbos/config.yaml:/config.yaml # <--- mount config
- ./cerbos/policies:/data/policies # <--- mount policies
networks:
- localdev
mongo: mongo:
image: mongo:8 image: mongo:8
restart: unless-stopped restart: unless-stopped

View File

@@ -1,10 +1,10 @@
{ {
"devDependencies": { "devDependencies": {
"@std/assert": "npm:@jsr/std__assert@1", "@std/assert": "npm:@jsr/std__assert@1.0.14",
"@std/testing": "npm:@jsr/std__testing@1", "@std/testing": "npm:@jsr/std__testing@1.0.15",
"eslint": "9", "eslint": "9.35.0",
"eslint-plugin-simple-import-sort": "12", "eslint-plugin-simple-import-sort": "12.1.1",
"prettier": "3", "prettier": "3.6.2",
"typescript-eslint": "8" "typescript-eslint": "8.44.0"
} }
} }

View File

@@ -1,7 +1,7 @@
import z, { ZodType } from "zod"; import z, { ZodType } from "zod";
import { ServerError, ServerErrorClass } from "./errors.ts"; import { ServerError, ServerErrorClass } from "./errors.ts";
import { Access, ServerContext } from "./types.ts"; import { RouteAccess, ServerContext } from "./route.ts";
export class Procedure<const TState extends State = State> { export class Procedure<const TState extends State = State> {
readonly type = "procedure" as const; readonly type = "procedure" as const;
@@ -64,7 +64,7 @@ export class Procedure<const TState extends State = State> {
* }); * });
* ``` * ```
*/ */
access<TAccess extends Access>(access: TAccess): Procedure<Omit<TState, "access"> & { access: TAccess }> { access<TAccess extends RouteAccess>(access: TAccess): Procedure<Omit<TState, "access"> & { access: TAccess }> {
return new Procedure({ ...this.state, access: access as TAccess }); return new Procedure({ ...this.state, access: access as TAccess });
} }
@@ -220,7 +220,7 @@ export type Procedures = {
type State = { type State = {
method: string; method: string;
access?: Access; access?: RouteAccess;
params?: ZodType; params?: ZodType;
errors?: ServerErrorClass[]; errors?: ServerErrorClass[];
response?: ZodType; response?: ZodType;

View File

@@ -3,7 +3,6 @@ import z, { ZodObject, ZodRawShape, ZodType } from "zod";
import { ServerError, ServerErrorClass } from "./errors.ts"; import { ServerError, ServerErrorClass } from "./errors.ts";
import { Hooks } from "./hooks.ts"; import { Hooks } from "./hooks.ts";
import { ServerContext } from "./types.ts";
export class Route<const TState extends RouteState = RouteState> { export class Route<const TState extends RouteState = RouteState> {
readonly type = "route" as const; readonly type = "route" as const;
@@ -81,7 +80,7 @@ export class Route<const TState extends RouteState = RouteState> {
* route.post("/foo").meta({ description: "Super route" }); * route.post("/foo").meta({ description: "Super route" });
* ``` * ```
*/ */
meta<TRouteMeta extends RouteMeta>(meta: TRouteMeta): Route<Omit<TState, "meta"> & { meta: TRouteMeta }> { meta<TRouteMeta extends RouteMeta>(meta: TRouteMeta): Route<Prettify<Omit<TState, "meta"> & { meta: TRouteMeta }>> {
return new Route({ ...this.state, meta }); return new Route({ ...this.state, meta });
} }
@@ -134,7 +133,7 @@ export class Route<const TState extends RouteState = RouteState> {
* }); * });
* ``` * ```
*/ */
access<TAccess extends RouteAccess>(access: TAccess): Route<Omit<TState, "access"> & { access: TAccess }> { access<TAccess extends RouteAccess>(access: TAccess): Route<Prettify<Omit<TState, "access"> & { access: TAccess }>> {
return new Route({ ...this.state, access: access as TAccess }); return new Route({ ...this.state, access: access as TAccess });
} }
@@ -157,7 +156,9 @@ export class Route<const TState extends RouteState = RouteState> {
* }); * });
* ``` * ```
*/ */
params<TParams extends ZodRawShape>(params: TParams): Route<Omit<TState, "params"> & { params: ZodObject<TParams> }> { params<TParams extends ZodRawShape>(
params: TParams,
): Route<Prettify<Omit<TState, "params"> & { params: ZodObject<TParams> }>> {
return new Route({ ...this.state, params: z.object(params) as any }); return new Route({ ...this.state, params: z.object(params) as any });
} }
@@ -180,7 +181,9 @@ export class Route<const TState extends RouteState = RouteState> {
* }); * });
* ``` * ```
*/ */
query<TQuery extends ZodRawShape>(query: TQuery): Route<Omit<TState, "search"> & { query: ZodObject<TQuery> }> { query<TQuery extends ZodRawShape>(
query: TQuery,
): Route<Prettify<Omit<TState, "search"> & { query: ZodObject<TQuery> }>> {
return new Route({ ...this.state, query: z.object(query) as any }); return new Route({ ...this.state, query: z.object(query) as any });
} }
@@ -205,7 +208,7 @@ export class Route<const TState extends RouteState = RouteState> {
* }); * });
* ``` * ```
*/ */
body<TBody extends ZodType>(body: TBody): Route<Omit<TState, "body"> & { body: TBody }> { body<TBody extends ZodType>(body: TBody): Route<Prettify<Omit<TState, "body"> & { body: TBody }>> {
return new Route({ ...this.state, body }); return new Route({ ...this.state, body });
} }
@@ -227,7 +230,9 @@ export class Route<const TState extends RouteState = RouteState> {
* }); * });
* ``` * ```
*/ */
errors<TErrors extends ServerErrorClass[]>(errors: TErrors): Route<Omit<TState, "errors"> & { errors: TErrors }> { errors<TErrors extends ServerErrorClass[]>(
errors: TErrors,
): Route<Prettify<Omit<TState, "errors"> & { errors: TErrors }>> {
return new Route({ ...this.state, errors }); return new Route({ ...this.state, errors });
} }
@@ -254,7 +259,9 @@ export class Route<const TState extends RouteState = RouteState> {
* }); * });
* ``` * ```
*/ */
response<TResponse extends ZodType>(response: TResponse): Route<Omit<TState, "response"> & { response: TResponse }> { response<TResponse extends ZodType>(
response: TResponse,
): Route<Prettify<Omit<TState, "response"> & { response: TResponse }>> {
return new Route({ ...this.state, response }); return new Route({ ...this.state, response });
} }
@@ -292,7 +299,7 @@ export class Route<const TState extends RouteState = RouteState> {
* *
* @param hooks - Hooks to register with the route. * @param hooks - Hooks to register with the route.
*/ */
hooks<THooks extends Hooks>(hooks: THooks): Route<Omit<TState, "hooks"> & { hooks: THooks }> { hooks<THooks extends Hooks>(hooks: THooks): Route<Prettify<Omit<TState, "hooks"> & { hooks: THooks }>> {
return new Route({ ...this.state, hooks }); return new Route({ ...this.state, hooks });
} }
} }
@@ -444,9 +451,10 @@ export type RouteMeta = {
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
export type RouteAccess = "public" | "session" | (() => boolean)[]; export type RouteAccess = "public" | "authenticated";
export type AccessFn = (resource: string, action: string) => () => boolean; // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServerContext {}
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = ( type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
...args: TArgs ...args: TArgs
@@ -471,3 +479,7 @@ type HasInputArgs<TState extends RouteState> = TState["params"] extends ZodObjec
: TState["body"] extends ZodType : TState["body"] extends ZodType
? true ? true
: false; : false;
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

View File

@@ -1,6 +0,0 @@
export type Access = "public" | "session" | (() => boolean)[];
export type AccessFn = (resource: string, action: string) => () => boolean;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServerContext {}

View File

@@ -1,14 +0,0 @@
import z from "zod";
import { makeSchemaParser } from "../database.ts";
export const RoleSchema = z.object({
id: z.uuid(),
name: z.string(),
permissions: z.record(z.string(), z.array(z.string())),
});
export const parseRole = makeSchemaParser(RoleSchema);
export type Role = z.infer<typeof RoleSchema>;
export type RoleDocument = z.infer<typeof RoleSchema>;

View File

@@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { RoleSchema } from "../access/role.ts";
import { AvatarSchema } from "../avatar.ts"; import { AvatarSchema } from "../avatar.ts";
import { ContactSchema } from "../contact.ts"; import { ContactSchema } from "../contact.ts";
import { makeSchemaParser } from "../database.ts"; import { makeSchemaParser } from "../database.ts";
import { NameSchema } from "../name.ts"; import { NameSchema } from "../name.ts";
import { RoleSchema } from "./role.ts";
import { StrategySchema } from "./strategies.ts"; import { StrategySchema } from "./strategies.ts";
export const AccountSchema = z.object({ export const AccountSchema = z.object({
@@ -18,10 +18,8 @@ export const AccountSchema = z.object({
roles: z.array(RoleSchema).default([]), roles: z.array(RoleSchema).default([]),
}); });
export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.array(z.string()) }); export const toAccountDocument = makeSchemaParser(AccountSchema);
export const toAccountDocument = makeSchemaParser(AccountDocumentSchema);
export const fromAccountDocument = makeSchemaParser(AccountSchema); export const fromAccountDocument = makeSchemaParser(AccountSchema);
export type Account = z.infer<typeof AccountSchema>; export type Account = z.infer<typeof AccountSchema>;
export type AccountDocument = z.infer<typeof AccountDocumentSchema>; export type AccountDocument = z.infer<typeof AccountSchema>;

View File

@@ -0,0 +1,5 @@
import z from "zod";
export const RoleSchema = z.union([z.literal("user"), z.literal("admin")]);
export type Role = z.infer<typeof RoleSchema>;

View File

@@ -1,7 +1,8 @@
import { route } from "@spec/relay"; import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@spec/relay";
import z from "zod"; import z from "zod";
import { NameSchema } from "../name.ts"; import { NameSchema } from "../name.ts";
import { AccountSchema } from "./account.ts";
import { AccountEmailClaimedError } from "./errors.ts"; import { AccountEmailClaimedError } from "./errors.ts";
export const create = route export const create = route
@@ -15,6 +16,15 @@ export const create = route
.errors([AccountEmailClaimedError]) .errors([AccountEmailClaimedError])
.response(z.uuid()); .response(z.uuid());
export const getById = route
.get("/api/v1/accounts/:id")
.params({
id: z.string(),
})
.errors([UnauthorizedError, ForbiddenError, NotFoundError])
.response(AccountSchema);
export const routes = { export const routes = {
create, create,
getById,
}; };