Skip to content
BoringStack
GitHub

ACL & feature resolution

5 min read

Authorization and feature gates

ACL is multi-tenant from day one. Solo-user products are the degenerate case: every signup creates a personal account, owner membership, and Free plan row in one transaction. The team UI appears only when memberships make it real.

role

what the user can do

plan

what the account paid for

CASL

server-side ability builder

The three axes that feed every authorization check

Section titled “The three axes that feed every authorization check”
Authorization inputs
Axis
Question
Changes
Source
Role
What does this user play in this account?
Rarely
auth.account_memberships.role
Entitlement
What did this account pay for?
Per Stripe webhook
billing.account_plans + app.account_feature_overrides
Resource ownership
Does this row belong to this account?
Per request
CASL condition { accountId } on role rules

Stripe never knows about feature keys or roles. The app never queries Stripe at request time. The two systems touch only at the webhook boundary.

For every feature key at request time, resolved per account:

  1. Active override in account_feature_overrides (not expired, not revoked).
  2. plan_features row for the account’s current account_plans row.
  3. Catalog default from src/lib/acl/acl.constants.ts (FEATURES[key].default).

First match wins. The CASL ability is built from the resolved feature set after that pass. The resolver (resolveFeatures() in src/lib/acl/feature-resolution.ts) is pure: feed it the plan rows and override rows, get back a typed ResolvedFeatures object. Tested in isolation.

Four roles ship by default, per-membership (a user is owner of Account A, viewer of Account B):

owner

Account creator

Manages billing, deletes the account, transfers ownership. Exactly one active owner per account.

admin

Account admin

Manages members and settings, but does not bypass plan checks.

member

Product user

Default authenticated role. Reads and writes account-scoped resources they own.

viewer

Read-only access

Guest, support, and demo role. Never writes.

Role rules carry CASL conditions ({ accountId: membership.accountId }) so resource ownership is encoded right at the rule, not duplicated in every handler.

FEATURE_KEYS is a code const tuple. Adding a feature is a TypeScript edit + a bun run generate:acl-types round-trip:

src/lib/acl/acl.constants.ts
export const FEATURE_KEYS = [
"can_export",
"can_invite_team",
"max_seats",
"max_widgets",
] as const;
export const FEATURES = {
can_export: { kind: "boolean", default: false },
max_seats: { kind: "limit", default: 1 },
// ...
} as const;

Feature gates compose with role rules via CASL’s cannot(...) rules: a missing feature forbids the action regardless of role. Owner is not special here.

Every account-scoped Drizzle table carries a // @account-scoped accountId comment above its pgTable declaration. The companion ESLint rule (drizzle-conventions/account-scoped-tables-require-where, defense-in-depth, deferred from the main ACL pass) refuses to merge any db.query.<table>.findX that doesn’t include the scope column in WHERE.

The cross-account isolation matrix in tests/api/widgets/widgets.routes.test.ts is the proof: same user with two accounts, resource IDs unique across accounts, every method (GET, PATCH, DELETE) returns 404 when the resource belongs to the other account.

resolveActiveMembership(userId, accountId)

Per-request DB lookup with a 30s in-process TTL cache. Confirms the JWT-claimed (user, account) still maps to an active membership and that the parent account is not soft-deleted.

resolveFreshMembership(userId, accountId)

Cache-bypassing variant for the highest-stakes calls: account deletion, ownership transfer, billing-portal creation, member removal, role changes. Always refetches.

scopedTo(membership)

Returns { accountId } so account-scoped queries pull their WHERE argument from one place.

requireAbility(ability, action, subject)

Throws ApiErrors.forbidden() with the action+subject in the message when the ability denies. Wraps every privileged DB call.

enforceLimit(feature, current, limit)

Throws ApiErrors.limitExceeded (status 402) with { feature, current, limit } so the UI can render an inline upgrade CTA. Wrap inside a transaction for race safety.

{
"user": {
"id": "...",
"email": "...",
"firstName": "...",
"lastName": "...",
"emailVerified": true
},
"account": { "id": "...", "name": "..." },
"role": "owner",
"memberships": [
{ "accountId": "...", "accountName": "...", "role": "owner" }
],
"features": {
"can_export": false,
"can_invite_team": false,
"max_seats": 1,
"max_widgets": 5
}
}

Single endpoint, every piece the UI needs to render the right buttons. The features block is the resolved set; overrides have already been applied.

  • Multi-tenant model: accounts, memberships, invitations, owner lifecycle.
  • Authentication: JWT carries (user_id, account_id); account switch issues a fresh JWT.
  • Billing: Stripe webhook updates account_plans; plan status feeds effective-features mapping.