brick.ts

Access Control and Policies

Secure actions and CRUD operations with role-based authorization and row scoping.

llms.txt

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:

src/services/articles.ts
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:

src/services/catalog.ts
crud({
  access: "public",
  fields: {
    id: { read: true },
    title: { read: true },
  },
});

2. Operation Authorization (authorize)

Verify caller identity and role for specific CRUD operations:

src/services/posts.ts
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 }:

src/services/documents.ts
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:

MechanismCodeStatusLocation in Code
Action authorize rejectionUNAUTHORIZED403 Forbiddenpackages/core/src/action.ts
CRUD authorize rejection or missingFORBIDDEN403 Forbiddenpackages/crud/src/index.ts
CRUD scope returned a condition on createFORBIDDEN403 Forbiddenpackages/crud/src/index.ts
error.UNAUTHORIZED() in your own codeUNAUTHORIZED401packages/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

FieldTypeDescription
idtextDocument identifier. PK
titletextDocument title.
owner_idtextOwner identifier used by the scope policy.