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 PORT=8370
EXPOSE 8370
WORKDIR /app
@@ -10,10 +12,6 @@ COPY relay/ ./relay/
COPY .npmrc .
COPY deno-docker.json ./deno.json
RUN chown -R deno:deno /app/
USER deno
RUN deno install --allow-scripts
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 z from "zod";
import { db } from "~stores/read-store/database.ts";
import { Auth } from "@valkyr/auth";
import { access } from "./access.ts";
import { config } from "./config.ts";
import { principal } from "./principal.ts";
import { resources } from "./resources.ts";
export const auth = new Auth(
{
settings: {
export const auth = new Auth({
principal,
resources,
access,
jwt: {
algorithm: "RS256",
privateKey: config.privateKey,
publicKey: config.publicKey,
issuer: "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) {
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"];
export type Session = typeof auth.$session;

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";
export function getArgsVariable(key: string, fallback?: string): 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?: string): ReturnType<T> {
export function getArgsVariable(key: string, fallback?: any): string;
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?: any): ReturnType<T> {
if (typeof parse === "string") {
fallback = parse;
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`);
}
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 parse - Parser function to convert the value to the desired type. Default: `string`.
*/
export function getEnvironmentVariable(key: string, fallback?: string): 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?: string): ReturnType<T> {
export function getEnvironmentVariable(key: string, fallback?: any): string;
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?: any): ReturnType<T> {
if (typeof parse === "string") {
fallback = parse;
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`);
}
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);
}

View File

@@ -2,7 +2,9 @@ import { ServerContext } from "@spec/relay";
import type { Sockets } from "~libraries/socket/sockets.ts";
import { Access } from "../auth/access.ts";
import { Session } from "../auth/auth.ts";
import { Principal } from "../auth/principal.ts";
import { req } from "./request.ts";
declare module "@spec/relay" {
@@ -17,17 +19,21 @@ declare module "@spec/relay" {
*/
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.
*/
session: Session;
/**
* Get request principal.
*/
principal: Principal;
/**
* Get access control session.
*/
access: Access;
/**
* Sockets instance attached to the server.
*/
@@ -43,14 +49,18 @@ export function getRequestContext(request: Request): ServerContext {
return req.isAuthenticated;
},
get accountId() {
return this.session.accountId;
},
get session(): Session {
return req.session;
},
get principal(): Principal {
return req.session.principal;
},
get access(): Access {
return req.session.access;
},
get sockets(): Sockets {
return req.sockets;
},

View File

@@ -1,11 +1,11 @@
import { InternalServerError, UnauthorizedError } from "@spec/relay";
import { Session } from "../auth/auth.ts";
import { asyncLocalStorage } from "./storage.ts";
import { storage } from "./storage.ts";
export const req = {
get store() {
const store = asyncLocalStorage.getStore();
const store = storage.getStore();
if (store === undefined) {
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.
*/
getStore() {
return asyncLocalStorage.getStore();
return storage.getStore();
},
} as const;

View File

@@ -3,7 +3,9 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { Session } from "~libraries/auth/mod.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;
info: {
method: string;
@@ -14,4 +16,4 @@ export const asyncLocalStorage = new AsyncLocalStorage<{
response: {
headers: Headers;
};
}>();
};

View File

@@ -5,20 +5,22 @@
"migrate": "deno run --allow-all .tasks/migrate.ts"
},
"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/relay": "workspace:*",
"@spec/shared": "workspace:*",
"@std/cli": "npm:@jsr/std__cli@1",
"@std/dotenv": "npm:@jsr/std__dotenv@0.225",
"@std/fs": "npm:@jsr/std__fs@1",
"@std/path": "npm:@jsr/std__path@1",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.6",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1",
"cookie": "1",
"mongodb": "6",
"zod": "4"
"@std/cli": "npm:@jsr/std__cli@1.0.22",
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
"@std/fs": "npm:@jsr/std__fs@1.0.19",
"@std/path": "npm:@jsr/std__path@1.1.2",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.3",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
"cookie": "1.0.2",
"mongodb": "6.20.0",
"zod": "4.1.9"
}
}

View File

@@ -13,6 +13,7 @@ export default create.access("public").handle(async ({ body: { name, email } })
.create()
.addName(name)
.addEmailStrategy(email)
.addRole("user")
.save()
.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,
headers: {
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, {
status: 200,
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 { 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);
if (strategy === undefined) {
return logger.info({
@@ -28,7 +28,7 @@ export default route.handle(async ({ body: { alias, password: userPassword } })
headers: {
"set-cookie": cookie.serialize(
"token",
await auth.generate({ accountId: strategy.accountId }, "1 week"),
await auth.generate({ id: strategy.accountId }, "1 week"),
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";
export default session.access("session").handle(async ({ accountId }) => {
const account = await getAccountById(accountId);
export default session.access("authenticated").handle(async ({ principal }) => {
const account = await getAccountById(principal.id);
if (account === undefined) {
return new UnauthorizedError();
}

View File

@@ -3,7 +3,7 @@ import cookie from "cookie";
import { auth, type Session } from "~libraries/auth/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 { config } from "./config.ts";
@@ -45,8 +45,6 @@ Deno.serve(
async (request) => {
const url = new URL(request.url);
// ### Session
let session: Session | undefined;
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
@@ -63,31 +61,23 @@ Deno.serve(
session = resolved;
}
// ### Headers
// Set the default headers.
const headers = new Headers();
// ### Handle
const ts = performance.now();
return asyncLocalStorage.run(
{
const context = {
session,
info: {
method: request.url,
start: Date.now(),
},
response: {
headers,
headers: new Headers(),
},
},
async () => {
} satisfies Storage;
return storage.run(context, async () => {
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 { Role } from "@spec/schemas/account/role.ts";
import { Strategy } from "@spec/schemas/account/strategies.ts";
import { Avatar } from "@spec/schemas/avatar.ts";
import { Contact } from "@spec/schemas/contact.ts";
@@ -22,6 +23,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
emails: [],
};
strategies: Strategy[] = [];
roles: Role[] = [];
createdAt!: Date;
updatedAt!: Date;
@@ -51,6 +53,11 @@ export class Account extends AggregateRoot<EventStoreFactory> {
this.updatedAt = getDate(event.created);
break;
}
case "account:role:added": {
this.roles.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "strategy:email:added": {
this.strategies.push({ type: "email", value: event.data });
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({
stream: this.id,
type: "account:role:added",
data: roleId,
data: role,
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 } });
});
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
projector.on("account:role:added", async ({ stream: id, data: role }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: role } });
});
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 { NameSchema } from "@spec/schemas/name.ts";
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:name:added").data(NameSchema).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 code from "./code.ts";
import organization from "./organization.ts";
import role from "./role.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;

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

View File

@@ -12,29 +12,29 @@
"dependencies": {
"@spec/relay": "workspace:*",
"@spec/schemas": "workspace:*",
"@tanstack/react-query": "5",
"@tanstack/react-router": "1",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0-beta.3",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1",
"fast-equals": "5",
"react": "19",
"react-dom": "19",
"tailwindcss": "4",
"zod": "4"
"@tanstack/react-query": "5.89.0",
"@tanstack/react-router": "1.131.47",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1",
"fast-equals": "5.2.2",
"react": "19.1.1",
"react-dom": "19.1.1",
"tailwindcss": "4.1.13",
"zod": "4.1.9"
},
"devDependencies": {
"@eslint/js": "9",
"@tailwindcss/vite": "4",
"@tanstack/react-router-devtools": "1",
"@types/react": "19",
"@types/react-dom": "19",
"@vitejs/plugin-react": "4",
"eslint": "9",
"eslint-plugin-react-hooks": "5",
"eslint-plugin-react-refresh": "0.4",
"globals": "16",
"typescript": "5",
"typescript-eslint": "8",
"vite": "7"
"@eslint/js": "9.35.0",
"@tailwindcss/vite": "4.1.13",
"@tanstack/react-router-devtools": "1.131.47",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react": "4.7.0",
"eslint": "9.35.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"globals": "16.4.0",
"typescript": "5.9.2",
"typescript-eslint": "8.44.0",
"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."
},
"check": {
"command": "deno check ./mod.ts",
"command": "deno check ./api/server.ts",
"description": "Runs a check on all the projects main entry files."
},
"lint": {

1080
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,18 @@
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:
image: mongo:8
restart: unless-stopped

View File

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

View File

@@ -1,7 +1,7 @@
import z, { ZodType } from "zod";
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> {
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 });
}
@@ -220,7 +220,7 @@ export type Procedures = {
type State = {
method: string;
access?: Access;
access?: RouteAccess;
params?: ZodType;
errors?: ServerErrorClass[];
response?: ZodType;

View File

@@ -3,7 +3,6 @@ import z, { ZodObject, ZodRawShape, ZodType } from "zod";
import { ServerError, ServerErrorClass } from "./errors.ts";
import { Hooks } from "./hooks.ts";
import { ServerContext } from "./types.ts";
export class Route<const TState extends RouteState = RouteState> {
readonly type = "route" as const;
@@ -81,7 +80,7 @@ export class Route<const TState extends RouteState = RouteState> {
* 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 });
}
@@ -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 });
}
@@ -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 });
}
@@ -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 });
}
@@ -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 });
}
@@ -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 });
}
@@ -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 });
}
@@ -292,7 +299,7 @@ export class Route<const TState extends RouteState = RouteState> {
*
* @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 });
}
}
@@ -444,9 +451,10 @@ export type RouteMeta = {
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> = (
...args: TArgs
@@ -471,3 +479,7 @@ type HasInputArgs<TState extends RouteState> = TState["params"] extends ZodObjec
: TState["body"] extends ZodType
? true
: 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 { RoleSchema } from "../access/role.ts";
import { AvatarSchema } from "../avatar.ts";
import { ContactSchema } from "../contact.ts";
import { makeSchemaParser } from "../database.ts";
import { NameSchema } from "../name.ts";
import { RoleSchema } from "./role.ts";
import { StrategySchema } from "./strategies.ts";
export const AccountSchema = z.object({
@@ -18,10 +18,8 @@ export const AccountSchema = z.object({
roles: z.array(RoleSchema).default([]),
});
export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.array(z.string()) });
export const toAccountDocument = makeSchemaParser(AccountDocumentSchema);
export const toAccountDocument = makeSchemaParser(AccountSchema);
export const fromAccountDocument = makeSchemaParser(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 { NameSchema } from "../name.ts";
import { AccountSchema } from "./account.ts";
import { AccountEmailClaimedError } from "./errors.ts";
export const create = route
@@ -15,6 +16,15 @@ export const create = route
.errors([AccountEmailClaimedError])
.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 = {
create,
getById,
};