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
pnpm add @justscale/permissionThree 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, thepermissionsmiddleware).
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.
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
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.
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.
// 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.
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
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.