Permissions

Declarative, queryable model-level access control

The @justscale/permission package lets you declare whocan do what on a model in one place — next to the fields the rule is about. The same rule is used both as a route guard and as a query filter, so list endpoints, row guards, and field-level access stay in sync without repeating yourself.

Installation

Bash
pnpm add @justscale/permission

Three Moving Parts

Permissions are three concepts that plug together:

  • permit()— declares a rule on a model (“a Customer can close their own Ticket”).
  • AbstractPrincipalProvider — a contribution token. Each resolver returns the principals the current request acts as (a User, a Customer, an Agent…).
  • PermissionFeature — registers the runtime (PermissionService, explicit grants, the permissions middleware).

Declare Permissions on a Model

Use the permissions factory on defineModel. It receives the model's field expressions, so you can point at a field by name and the compiler checks it exists.

Files
src/models/ticket.tsTypeScript
import { defineModel, field } from '@justscale/core/models';
import { permit } from '@justscale/permission';
import { Customer } from './customer';
import { Agent } from './agent';

export class Ticket extends defineModel({
  fields: {
    subject:       field.string().max(255),
    body:          field.text(),
    customer:      field.ref(Customer),
    assignedAgent: field.ref(Agent).optional(),
    status:        field.enum('TicketStatus', ['open', 'resolved', 'closed']).default('open'),
  },

  // The arg is the field-expression record — destructure what you need.
  permissions: ({ customer }) => ({
    // Array = OR. A Customer who owns this ticket OR any Agent can view.
    view:  [permit(Customer).when(customer), permit(Agent).always()],
    close:  permit(Customer).when(customer),
    assign: permit(Agent).always(),
  }),
}) {}

Three modes are built in:

  • permit(Role).when(field)— the field on the resource must equal the principal's ref. Queryable — the repository can filter list endpoints automatically.
  • permit(Role).always()— any principal of this type passes. Queryable — yields “no extra filter” in queries.
  • permit(Role).check(fn) — a custom predicate. Runs as a guard; notqueryable (can't be pushed into SQL).

Guard Routes with Model Permissions

ticket.controller.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';
import { auth } from '@justscale/auth';
import { Ticket } from '../models/ticket';

export const TicketController = createController({
  routes: () => ({
    close: Post('/tickets/:ticket/close')
      .types({ Ticket })
      .use(auth)
      .guard(Ticket.can.close)       // principal must satisfy the rule
      .handle(({ params, res }) => {
        // Guard passed: the authenticated Customer owns params.ticket
        res.status(204).end();
      }),
  }),
});

The guard runs Ticket.can.close against the resolved :ticket param. On failure the response is 403.

Resolve Principals

A principal is just { type: Model, ref: Reference }. One request can act as several principals — AbstractPrincipalProvider is a contribution token, so each resolver stays small and composable.

Files
src/principals.tsTypeScript
import { createContribution } from '@justscale/core';
import { ModelRepository } from '@justscale/core/models';
import { AbstractPrincipalProvider } from '@justscale/permission';
import { User } from '@justscale/auth';
import { Customer } from './models/customer';

// Every authenticated request is a User principal.
export const UserPrincipalResolver = createContribution(AbstractPrincipalProvider, {
  inject: {},
  factory: () => ({
    resolve(ctx: { user?: InstanceType<typeof User> }) {
      if (!ctx.user) return [];
      return [{ type: User, ref: User.ref(ctx.user) }];
    },
  }),
});

// If the User also has a Customer record, contribute that principal too.
export const CustomerPrincipalResolver = createContribution(AbstractPrincipalProvider, {
  inject: { customers: ModelRepository.of(Customer) },
  factory: ({ customers }) => ({
    async resolve(ctx: { user?: InstanceType<typeof User> }) {
      if (!ctx.user) return [];
      const customer = await customers.findOne(
        Customer.fields.email.eq(ctx.user.email),
      );
      return customer ? [{ type: Customer, ref: Customer.ref(customer) }] : [];
    },
  }),
});

The built-in aggregator flat-maps all contributions. Adding an AgentPrincipalResolver later is purely additive — no existing code changes.

Query Filtering — the Same Rule Runs in SQL

Because .when() and .always() rules produce a Condition, .toCondition(principal) is what repositories call to add the filter to list endpoints. There is no second place to keep in sync.

list-my-tickets.tsTypeScript
// Ticket.can.view = [permit(Customer).when(customer), permit(Agent).always()]
//
// For a Customer principal, Ticket.can.view.toCondition(principal) →
//   EqCondition { field: 'customer', value: principal.ref.identifier }
//
// For an Agent principal, Ticket.can.view.toCondition(principal) →
//   AndCondition([]) — i.e. no filter, agents see everything.
//
// The repository applies the condition; the controller just guards the route.

listMine: Get('/tickets')
  .use(auth)
  .use(permissions)
  .handle(async ({ tickets, principals }) => {
    const mine = await tickets.find({
      where: Ticket.can.view.toCondition(principals[0]),
    });
    return mine;
  }),

Permission-Scoped Responses

The permissions middleware lets a single route return different schemas depending on which permission the caller satisfies. Declare each shape with .returns(status, schema, permission)and res.permission becomes a typed discriminant.

employee.controller.tsTypeScript
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';
import { permissions, assertNever } from '@justscale/permission';
import { Employee } from '../models/employee';
import { EmployeeFull, EmployeeLimited } from '../schemas/employee';

Get('/employees/:employee')
  .types({ Employee })
  .use(auth)
  .use(permissions)
  .guard(Employee.can.view)
  .returns(200, EmployeeFull,    Employee.can.fullAccess)
  .returns(200, EmployeeLimited, Employee.can.view)
  .handle(({ params, res }) => {
    const e = params.employee;
    // res.permission is typed as 'fullAccess' | 'view'
    switch (res.permission) {
      case 'fullAccess':
        res.json({ name: e.name, salary: e.salary, department: e.department });
        return;
      case 'view':
        res.json({ name: e.name });
        return;
      default:
        assertNever(res);    // compile error if a case is missing
    }
  });

The middleware walks the permission-scoped .returns()entries in declaration order and picks the first one whose rule matches the caller — so put the more privileged schema first.

Wire It Up

app.tsTypeScript
import JustScale from '@justscale/core';
import { AuthFeature } from '@justscale/auth';
import { PermissionFeature } from '@justscale/permission';
import { UserPrincipalResolver, CustomerPrincipalResolver } from './principals';
import { TicketController } from './controllers/ticket';

const app = JustScale()
  .add(AuthFeature)
  .add(PermissionFeature)          // requires AbstractPrincipalProvider + a PermissionGrant repo
  .add(UserPrincipalResolver)
  .add(CustomerPrincipalResolver)
  .add(TicketController)
  .build();

await app.serve({ http: 3000 });

PermissionFeature requires a repository for PermissionGrant — the model that backs explicit grants issued through PermissionService. In production wire a createPgRepository(PgPermissionGrant); tests can use the in-memory repository.

Best Practices

  • One rule, two call sites. Use .guard(Model.can.x) for row-level access and .toCondition(p) for list filtering — never branch on roles in handlers.
  • One principal resolver per principal type.Small contributions compose; monolithic providers don't.
  • Prefer .when() over .check(). .when() is queryable; .check() blocks the list-filter path and forces a post-fetch check.
  • Order permission-scoped returns by privilege. The middleware takes the first match — a broader rule listed first will shadow a narrower one.