Design the authorization model — the permission system itself — rather than reviewing one that exists. The job is to decide what capabilities exist, how they compose into roles, how far each check is scoped, and where enforcement lives — so that application code asks one question, "can this actor perform this action on this resource?", instead of the brittle if (user.isAdmin) checks that breed across handlers and rot the moment requirements change. The skill reads the codebase to find the resources, actions, and existing role checks, then produces a concrete permission/role model, a single central enforcement design, and explicit decisions on hierarchy, default-deny, tenant isolation, and audit.
When to use this skill
- Building authorization for a multi-user or multi-tenant (SaaS) product, where access depends on both who the actor is and which org/project/resource they are touching.
- When ad-hoc role checks —
if (user.role === 'admin'), user.isManager, @RequireRole("OWNER") — are sprawling through controllers and every new rule means a code hunt.
- When "who can do what" is tribal knowledge with no single model, or a customer/security review asks you to document the permission matrix.
- Before adding roles, a permissions UI, custom roles, or an admin-impersonation feature on top of a system that hardcodes role names.
[!WARNING]
Scattering role-name checks (isAdmin, role === "manager") through the codebase instead of checking granular permissions makes every permission change a risky code hunt and guarantees missed spots — the endpoint you forget is the privilege-escalation bug. Model permissions, compose them into roles, and enforce in one place so a grant change is one edit and coverage is greppable.
Instructions
- Inventory resources and actions before inventing roles. Glob the routers, controllers, and data models (
**/routes/**, **/*controller*, **/models/**, **/entities/**) and list every resource (invoice, project, user, billing-account) and every action on it (read, create, update, delete, approve, export, invite). Permissions are these resource:action pairs — invoice:read, invoice:approve, member:invite. Name them after the capability, not the role, so the same permission can be granted to many roles. This list is the vocabulary; everything else composes it.
- Compose permissions into roles — never the reverse. Define roles as named sets of permissions (
viewer = {invoice:read, project:read}, approver = viewer ∪ {invoice:approve}). Code checks can(actor, "invoice:approve", invoice), never actor.role === "approver". This is the whole point: when product says "approvers can now export", you edit one role→permission map, not every handler. Grep the codebase for existing role ===, isAdmin, hasRole, @Role, @PreAuthorize sites and list each as a call site to migrate to a permission check.
- Pick the granularity you actually need — and stop there. Choose explicitly among three:
- Pure RBAC (roles → permissions, global) — fine for single-tenant internal tools where a role means the same thing everywhere.
- Scoped RBAC (role within an org/project/workspace) — the default for SaaS: a user is
admin of org A and viewer of org B, and every check is scoped to the resource's tenant. Model the assignment as (actor, role, scope).
- ReBAC / ABAC (permission depends on the specific object's relationship or attributes — "owner of THIS document", "assignee of THIS ticket") — reach for this only for the per-object rules; let scoped RBAC carry the rest. Do not stand up a full policy engine if scoped RBAC suffices.
State the choice and the reason; mixing scoped RBAC for the 90% with a handful of ReBAC ownership rules is usually correct.
- Centralize enforcement in one authorization layer. Design a single policy function/middleware —
authorize(actor, action, resource) (or a guard/policy class) — that every entry point routes through: HTTP handlers, GraphQL resolvers, queue/cron jobs, and admin scripts. No handler should make its own role decision. Specify where it sits (e.g. middleware that resolves the resource, computes the actor's permissions in that scope, and allows/denies) so coverage is provable by reading one module, not auditing hundreds.
- Default-deny, explicitly. The policy layer returns deny unless a rule grants. A new route with no policy attached must fail closed (no access), never fall through to allowed. Specify how an un-annotated/un-checked endpoint is detected and rejected (e.g. a route-level assertion that a policy was declared) so "forgot to add a check" becomes a deny, not a hole.
- Decide role hierarchy and inheritance deliberately. If
admin should imply everything editor can do, model it as permission inheritance (admin's permission set ⊇ editor's) computed when permissions are resolved — not as a chain of if role >= X comparisons, which reintroduce role-name logic. Keep the hierarchy shallow and flatten to an effective permission set at check time; document the partial order so "what can role X do" is answerable from the model alone.
- Scope every check to the resource — at the API and data layer. A valid role on tenant A must never act on tenant B's data. The permission check answers "may this actor approve invoices?"; the data layer must additionally bind the query to the resource's owner/tenant (
WHERE org_id = :actorOrg, a tenant filter, or row-level security), so changing an id in the URL cannot reach another tenant's row. Specify both: the policy check and the scoped query. Skipping the data-layer scope is the classic IDOR — the permission passed, but the object belonged to someone else.
- Make it auditable. Design the model so authorization decisions are explainable and logged: who has which role in which scope (queryable), what permissions a role grants (the map), and a decision log for sensitive actions (actor, action, resource, allow/deny, why). A model nobody can answer "who can approve invoices in org X?" about is not finished.
[!NOTE]
RBAC without per-tenant/resource scoping is the most common real failure: a legitimate admin of org A passes the invoice:approve permission check and then approves org B's invoice because the query fetched by id alone. The permission says what the actor may do; the scope says to which objects. Both are required — design them together, not as an afterthought.
Output
A concrete authorization design with four parts:
- The permission/role model — the resource×action permission list, the role→permission map (with inheritance), and the assignment shape (
(actor, role) for pure RBAC or (actor, role, scope) for scoped/multi-tenant).
- The central enforcement design — the single
authorize(actor, action, resource) entry point, where it sits, what it resolves, and the list of existing scattered role checks to migrate into it.
- Granularity decision — pure RBAC vs scoped RBAC vs ReBAC/ABAC, stated with the reason, including which specific rules (if any) need per-object relationship checks.
- The hardening decisions — default-deny mechanism, role hierarchy/partial order, the API-and-data-layer scoping rule per resource, and the audit/decision-log plan.
Authorization model — scope: src/routes/**, src/models/** (multi-tenant SaaS)
Granularity: SCOPED RBAC (role within org) + ReBAC for document ownership
PERMISSIONS (resource:action)
invoice: read, create, update, delete, approve, export
member: read, invite, remove
doc: read, edit (edit also gated by ownership — see ReBAC)
ROLES → PERMISSIONS (within an org)
viewer = {invoice:read, member:read, doc:read}
editor = viewer ∪ {invoice:create, invoice:update, doc:edit}
approver = editor ∪ {invoice:approve, invoice:export}
admin = approver ∪ {member:invite, member:remove} # inherits all above
ASSIGNMENT: (user_id, role, org_id) # scoped — same user differs per org
ENFORCEMENT (one layer)
authorize(actor, action, resource):
1. resolve actor's role in resource.org_id -> effective permission set
2. deny if action ∉ permissions # DEFAULT-DENY
3. ReBAC rule: doc:edit also requires resource.owner_id == actor.id
Every route/resolver/job calls authorize(); routes with no policy → fail closed.
MIGRATE these scattered checks into authorize():
- src/routes/invoices.ts:41 if (user.isAdmin) -> can(..,"invoice:approve",inv)
- src/routes/members.ts:88 user.role === "owner" -> can(..,"member:invite",org)
DATA-LAYER SCOPING (prevents IDOR — required alongside the permission check)
invoices: WHERE id = :id AND org_id = :actorOrg # not findById(id) alone
docs: WHERE id = :id AND org_id = :actorOrg # + ReBAC owner check above
AUDIT
- role assignments queryable: "who can invoice:approve in org X?"
- decision log on approve/export/remove: actor, action, resource, allow/deny
1---2name: rbac-designer3description: Design the authorization model itself — fine-grained permissions on resources composed into roles, with the right amount of resource/tenant scoping — instead of scattering role-name checks through handlers. Use when building multi-user or multi-tenant authorization, when `if user.isAdmin` checks are sprawling across the codebase, or when 'who can do what' needs a real model rather than ad-hoc gates.4---56Design the authorization model — the permission system itself — rather than reviewing one that exists. The job is to decide *what capabilities exist*, *how they compose into roles*, *how far each check is scoped*, and *where enforcement lives* — so that application code asks one question, **"can this actor perform this action on this resource?"**, instead of the brittle `if (user.isAdmin)` checks that breed across handlers and rot the moment requirements change. The skill reads the codebase to find the resources, actions, and existing role checks, then produces a concrete permission/role model, a single central enforcement design, and explicit decisions on hierarchy, default-deny, tenant isolation, and audit.78## When to use this skill910- Building authorization for a multi-user or multi-tenant (SaaS) product, where access depends on both *who* the actor is and *which org/project/resource* they are touching.11- When ad-hoc role checks — `if (user.role === 'admin')`, `user.isManager`, `@RequireRole("OWNER")` — are sprawling through controllers and every new rule means a code hunt.12- When "who can do what" is tribal knowledge with no single model, or a customer/security review asks you to document the permission matrix.13- Before adding roles, a permissions UI, custom roles, or an admin-impersonation feature on top of a system that hardcodes role names.1415> [!WARNING]16> Scattering role-name checks (`isAdmin`, `role === "manager"`) through the codebase instead of checking granular permissions makes every permission change a risky code hunt and guarantees missed spots — the endpoint you forget is the privilege-escalation bug. Model permissions, compose them into roles, and enforce in one place so a grant change is one edit and coverage is greppable.1718## Instructions19201. **Inventory resources and actions before inventing roles.** Glob the routers, controllers, and data models (`**/routes/**`, `**/*controller*`, `**/models/**`, `**/entities/**`) and list every *resource* (invoice, project, user, billing-account) and every *action* on it (read, create, update, delete, approve, export, invite). Permissions are these `resource:action` pairs — `invoice:read`, `invoice:approve`, `member:invite`. Name them after the capability, not the role, so the same permission can be granted to many roles. This list is the vocabulary; everything else composes it.212. **Compose permissions into roles — never the reverse.** Define roles as *named sets of permissions* (`viewer = {invoice:read, project:read}`, `approver = viewer ∪ {invoice:approve}`). Code checks `can(actor, "invoice:approve", invoice)`, never `actor.role === "approver"`. This is the whole point: when product says "approvers can now export", you edit one role→permission map, not every handler. Grep the codebase for existing `role ===`, `isAdmin`, `hasRole`, `@Role`, `@PreAuthorize` sites and list each as a call site to migrate to a permission check.223. **Pick the granularity you actually need — and stop there.** Choose explicitly among three:23 - **Pure RBAC** (roles → permissions, global) — fine for single-tenant internal tools where a role means the same thing everywhere.24 - **Scoped RBAC** (role *within* an org/project/workspace) — the default for SaaS: a user is `admin` of org A and `viewer` of org B, and every check is scoped to the resource's tenant. Model the assignment as `(actor, role, scope)`.25 - **ReBAC / ABAC** (permission depends on the specific object's relationship or attributes — "owner of THIS document", "assignee of THIS ticket") — reach for this *only* for the per-object rules; let scoped RBAC carry the rest. Do **not** stand up a full policy engine if scoped RBAC suffices.26 State the choice and the reason; mixing scoped RBAC for the 90% with a handful of ReBAC ownership rules is usually correct.274. **Centralize enforcement in one authorization layer.** Design a single policy function/middleware — `authorize(actor, action, resource)` (or a guard/policy class) — that every entry point routes through: HTTP handlers, GraphQL resolvers, queue/cron jobs, and admin scripts. No handler should make its own role decision. Specify *where* it sits (e.g. middleware that resolves the resource, computes the actor's permissions in that scope, and allows/denies) so coverage is provable by reading one module, not auditing hundreds.285. **Default-deny, explicitly.** The policy layer returns deny unless a rule grants. A new route with no policy attached must fail closed (no access), never fall through to allowed. Specify how an un-annotated/un-checked endpoint is detected and rejected (e.g. a route-level assertion that a policy was declared) so "forgot to add a check" becomes a *deny*, not a hole.296. **Decide role hierarchy and inheritance deliberately.** If `admin` should imply everything `editor` can do, model it as *permission inheritance* (admin's permission set ⊇ editor's) computed when permissions are resolved — not as a chain of `if role >= X` comparisons, which reintroduce role-name logic. Keep the hierarchy shallow and flatten to an effective permission set at check time; document the partial order so "what can role X do" is answerable from the model alone.307. **Scope every check to the resource — at the API *and* data layer.** A valid role on tenant A must never act on tenant B's data. The permission check answers "may this actor approve invoices?"; the *data* layer must additionally bind the query to the resource's owner/tenant (`WHERE org_id = :actorOrg`, a tenant filter, or row-level security), so changing an id in the URL cannot reach another tenant's row. Specify both: the policy check *and* the scoped query. Skipping the data-layer scope is the classic IDOR — the permission passed, but the object belonged to someone else.318. **Make it auditable.** Design the model so authorization decisions are explainable and logged: who has which role in which scope (queryable), what permissions a role grants (the map), and a decision log for sensitive actions (actor, action, resource, allow/deny, why). A model nobody can answer "who can approve invoices in org X?" about is not finished.3233> [!NOTE]34> RBAC without per-tenant/resource scoping is the most common real failure: a legitimate `admin` of org A passes the `invoice:approve` permission check and then approves org B's invoice because the query fetched by id alone. The permission says *what* the actor may do; the scope says *to which objects*. Both are required — design them together, not as an afterthought.3536## Output3738A concrete authorization design with four parts:39401. **The permission/role model** — the resource×action permission list, the role→permission map (with inheritance), and the assignment shape (`(actor, role)` for pure RBAC or `(actor, role, scope)` for scoped/multi-tenant).412. **The central enforcement design** — the single `authorize(actor, action, resource)` entry point, where it sits, what it resolves, and the list of existing scattered role checks to migrate into it.423. **Granularity decision** — pure RBAC vs scoped RBAC vs ReBAC/ABAC, stated with the reason, including which specific rules (if any) need per-object relationship checks.434. **The hardening decisions** — default-deny mechanism, role hierarchy/partial order, the API-and-data-layer scoping rule per resource, and the audit/decision-log plan.4445```text46Authorization model — scope: src/routes/**, src/models/** (multi-tenant SaaS)47Granularity: SCOPED RBAC (role within org) + ReBAC for document ownership4849PERMISSIONS (resource:action)50 invoice: read, create, update, delete, approve, export51 member: read, invite, remove52 doc: read, edit (edit also gated by ownership — see ReBAC)5354ROLES → PERMISSIONS (within an org)55 viewer = {invoice:read, member:read, doc:read}56 editor = viewer ∪ {invoice:create, invoice:update, doc:edit}57 approver = editor ∪ {invoice:approve, invoice:export}58 admin = approver ∪ {member:invite, member:remove} # inherits all above5960ASSIGNMENT: (user_id, role, org_id) # scoped — same user differs per org6162ENFORCEMENT (one layer)63 authorize(actor, action, resource):64 1. resolve actor's role in resource.org_id -> effective permission set65 2. deny if action ∉ permissions # DEFAULT-DENY66 3. ReBAC rule: doc:edit also requires resource.owner_id == actor.id67 Every route/resolver/job calls authorize(); routes with no policy → fail closed.6869MIGRATE these scattered checks into authorize():70 - src/routes/invoices.ts:41 if (user.isAdmin) -> can(..,"invoice:approve",inv)71 - src/routes/members.ts:88 user.role === "owner" -> can(..,"member:invite",org)7273DATA-LAYER SCOPING (prevents IDOR — required alongside the permission check)74 invoices: WHERE id = :id AND org_id = :actorOrg # not findById(id) alone75 docs: WHERE id = :id AND org_id = :actorOrg # + ReBAC owner check above7677AUDIT78 - role assignments queryable: "who can invoice:approve in org X?"79 - decision log on approve/export/remove: actor, action, resource, allow/deny80```