Access Control and Policies
Secure actions and CRUD operations with role-based authorization and row scoping.
Brick-TS provides two complementary layers of access control: operation-level authorization guards on actions and row-level scoping on CRUD resources.
Action Authorization (authorize)
Actions support an authorize guard that runs after input validation and before execute. It receives { user, input, ctx }, where user is ctx.user:
import { defineService, t } from "@brickkit/core";
interface User {
id: string;
role: "reader" | "editor" | "admin";
}
export const articlesService = defineService("articles", {
context: async (baseCtx): Promise<{ user: User | null }> => {
// Resolve the user from baseCtx.request here
return { user: null };
},
});
export const publishArticle = articlesService.action({
name: "publishArticle",
input: t.Object({ articleId: t.String() }),
output: t.Object({ published: t.Boolean() }),
authorize: ({ user }) => {
// Return true to allow execution, false to reject
return user?.role === "editor" || user?.role === "admin";
},
execute: ({ ctx }) => {
// ctx.user is typed User here, not User | null
ctx.logger.info(`Article published by ${ctx.user.id}`);
return { published: true };
},
});Automatic Type Narrowing
When an action declares an authorize guard, Brick's types use NarrowAuthorizedContext<TCtx>: inside execute({ ctx }), ctx.user is narrowed from User | null to User. This is a type-level assumption. Brick doesn't check ctx.user at runtime, so your authorize must return false when there's no user. Narrowing only applies when your context resolver returns a typed user.
CRUD Access Policies
CRUD resources from @brickkit/crud are deny-by-default. access is a required option, and an access object without authorize rejects every operation with FORBIDDEN (403). CRUD authorize runs after input validation and receives { operation, input, ctx }.
1. Public Access
Allow unrestricted access across all CRUD operations:
crud({
access: "public",
fields: {
id: { read: true },
title: { read: true },
},
});2. Operation Authorization (authorize)
Verify caller identity and role for specific CRUD operations:
crud({
access: {
authorize: ({ operation, input, ctx }) => {
// Anyone can read posts
if (
operation === "list" ||
operation === "get" ||
operation === "findOne"
) {
return true;
}
// Only authenticated users can write
if (!ctx.user) return false;
// Only admins can delete posts
if (operation === "delete") {
return ctx.user.role === "admin";
}
return true;
},
},
fields: {
id: { read: true },
title: { read: true, create: true },
},
});operation discriminates the authorization input type. For example, after
operation === "list", input.where, input.select, and input.limit are
available; after operation === "update", input.id and input.data are
available. Field policies constrain field names and filter operators, and
explicit field schemas supply value types. Column-derived values and IDs
remain unknown because the table is supplied after crud() is configured.
Create fields are optional in this callback type because column defaults and
nullability are not yet known. The package exports CrudAuthorizeArgs<F> and
CrudInputs<F> for reusable policies and per-operation input types.
3. Row-Level Tenant and Owner Scoping (scope)
Return a trusted SQL condition to isolate multi-tenant or user-owned rows. scope only runs when authorize allowed the call, and receives { operation, ctx }:
import { eq } from "@brickkit/core";
import { documentsTable } from "./schema";
crud({
access: {
authorize: ({ ctx }) => !!ctx.user,
scope: ({ operation, ctx }) => {
// Admins bypass row-level isolation
if (ctx.user?.role === "admin") return undefined;
// A scope can't constrain an INSERT; set ownerId in a beforeCreate hook
if (operation === "create") return undefined;
// Regular users only see and mutate their own documents
return eq(documentsTable.ownerId, ctx.user.id);
},
},
fields: {
id: { read: true },
title: { read: true, create: true, update: true },
ownerId: { read: true },
},
});When scope returns a Drizzle SQL condition, Brick combines it with the client's where filter and, for get, update, and delete, the ID match, using and(...). A user can't read, update, or delete another tenant's rows even if they know the record ID; those calls fail with NOT_FOUND (404).
If scope returns a condition for create, the call is rejected with FORBIDDEN (403): row-scoped creation needs an explicit create policy. Return undefined for create and assign the owner or tenant column in a beforeCreate hook.
Status Codes and Error Codes Today
Understanding current runtime behavior:
| Mechanism | Code | Status | Location in Code |
|---|---|---|---|
Action authorize rejection | UNAUTHORIZED | 403 Forbidden | packages/core/src/action.ts |
CRUD authorize rejection or missing | FORBIDDEN | 403 Forbidden | packages/crud/src/index.ts |
CRUD scope returned a condition on create | FORBIDDEN | 403 Forbidden | packages/crud/src/index.ts |
error.UNAUTHORIZED() in your own code | UNAUTHORIZED | 401 | packages/core/src/action.ts |
Planned Status Code Alignment: Currently, failed action authorization
throws code UNAUTHORIZED with HTTP status 403. A planned update tracked in
GitHub Issue #53
will align unauthenticated requests to HTTP 401 and forbidden role/scope
rejections to HTTP 403.
Schema
The owner-scoping example expects these columns on the application-owned documentsTable. Brick does not generate owner_id; populate it in a beforeCreate hook from the authenticated context.
Table Name: documents
| Field | Type | Description |
|---|---|---|
id | text | Document identifier. PK |
title | text | Document title. |
owner_id | text | Owner identifier used by the scope policy. |