Authorization Models skill for Claude Code

One decision point, default deny, tested matrix. Claude replaces scattered role checks with a single check() and filter() interface backed by the right model: plain RBAC in code for small apps, Cerbos or OPA when rules depend on attributes, OpenFGA when sharing and hierarchies appear. It writes the requirements matrix first, enforces default deny, and proves every allow and deny cell with table-driven tests.

  • nameauthorization-models
  • size282 lines · ≈2,800 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and implements application authorization - choosing RBAC, ABAC, or relationship-based (ReBAC/Zanzibar-style) models, writing policies in OpenFGA, Cerbos, OPA/Rego, or in-app code, central enforcement with default deny, filtering list queries by permission, and table-driven authorization tests. Use when adding roles or permissions, "who can see what" logic is scattered across handlers, sharing or org/team hierarchies are needed, choosing a policy engine, or auditing why a user can access something they should not.

Try asking

  • Our role checks are scattered across 40 handlers; design a proper permission model
  • We need Google-Docs-style sharing on folders and files; should we use OpenFGA?
  • Write a permission matrix test for org owner, admin, member, and guest roles

Use it when

  • Adding roles, teams, sharing, or org hierarchies to an app
  • Choosing between in-app checks and a policy engine like OpenFGA, Cerbos, or OPA
  • List endpoints and detail endpoints disagree about who can see a record
  • Auditing how a user got access they should not have

Skip it when

  • You need to test an existing API for BOLA and mass assignment - use api-security instead
  • The task is database-enforced tenant isolation in Postgres - use row-level-security instead

How to check the result

No ad-hoc role checks remain outside the authz module, the matrix test covers every allow and deny cell, engine tests pass in CI, and cross-tenant requests get 404 on detail routes and no rows on lists.

The common mistake it prevents

Storing a single global role on the user row, which silently makes someone an admin in every organization they later join.

~/.claude/skills/authorization-models/SKILL.md

--- name: authorization-models description: Designs and implements application authorization - choosing RBAC, ABAC, or relationship-based (ReBAC/Zanzibar-style) models, writing… license: MIT ---

Done well means: every permission question in the codebase goes through one decision function, the model can answer “can X do Y to Z” and “which Z can X do Y to”, denial is the default, and a permission matrix test proves both allow and deny for every role-action pair. Scattered if user.role == "admin" checks are the failure mode this skill removes.

Ground rules

  • Authentication says who; authorization says what. Never let a valid session imply access to a specific object.
  • Decide on the server, per request, per object. UI hiding is cosmetic.
  • Default deny. A missing policy, an engine timeout, or an unknown action returns deny and logs it. Fail closed.
  • Name actions, not roles, in code: can(user, "invoice:refund", invoice), never user.role in ("admin", "billing") at the call site. Roles map to actions in one place.
  • Return 404 rather than 403 for objects the caller cannot see, so existence does not leak.
  • Every change to the model ships with matrix tests in the same PR.

Workflow

- [ ] 1. Inventory resources, actions, and current checks
- [ ] 2. Write the permission requirements as a matrix (subject x action x resource)
- [ ] 3. Choose the model (RBAC / ABAC / ReBAC) and where it runs
- [ ] 4. Define the decision interface: check() and list/filter()
- [ ] 5. Implement policies and a single enforcement point
- [ ] 6. Migrate call sites; delete ad-hoc checks
- [ ] 7. Matrix tests, negative tests, and decision logging

1. Inventory

# Ad-hoc checks to replace
grep -rnE "role\s*(==|===|in)\s*|is_admin|isAdmin|hasRole\(|is_staff|is_superuser|@roles_required|\[Authorize\(Roles" --include=*.{py,ts,js,go,java,cs,rb} .
# Object lookups that may lack ownership scoping
grep -rnE "findById\(|findByPk\(|objects\.get\(|\.find\(\s*(req|params)|WHERE id\s*=" --include=*.{py,ts,js,go,java,rb} .

Produce a table: resource, actions, who should be able to do each, where it is checked today (file:line), and whether list endpoints filter by the same rule.

2. Requirements matrix

Write it before choosing tools. Example:

Action \ SubjectOrg ownerOrg adminMemberGuest (shared)Other org
project:readyesyesif member of projectif sharedno
project:updateyesyesif project editornono
project:deleteyesnononono
billing:manageyesnononono

Cells that say “if …” reveal the model: conditions on relationships mean ReBAC, conditions on attributes (region, time, plan, record status) mean ABAC.

3. Choose the model

Requirements look likeModelDefault implementation
Fixed roles per tenant, permissions follow the roleRBACIn-app role-to-permission map; roles stored per (user, org)
Rules depend on attributes: owner, status, region, plan, time, amountABAC (often RBAC + conditions)Cerbos or OPA, or in-app policy functions
Sharing, nested folders/teams, “editor of parent grants editor of child”, Google-Docs-styleReBACOpenFGA or SpiceDB
Need to explain and audit decisions across many servicesAny, externalizedPolicy engine as a sidecar or service (PDP)
Small app, one service, under ~10 roles and no sharingRBAC in codeA single permissions.py/policy.ts module; no engine

Engine choice:

EngineModel strengthData it needsWatch out for
OpenFGA (CNCF) / SpiceDBReBAC, hierarchies, list objectsRelationship tuples you write and keep in syncDual-write consistency between your DB and the tuple store
CerbosRBAC + ABAC via CEL conditions, derived rolesPrincipal and resource attributes sent per requestStateless: list filtering uses its query plan API, not a scan
OPA / RegoGeneral policy, infra and appinput document per request, optional bundled dataRego learning curve; keep app policies small and tested
Cedar / Amazon Verified PermissionsRBAC + ABAC, analyzable policiesEntities sent per requestEcosystem smaller outside AWS
In-app codeAnything simpleYour ORMDiscipline: one module, no checks elsewhere

Prefer one engine per organization. Two engines means two mental models and two test suites.

4. Decision interface

Define two functions and use nothing else at call sites:

// authz.ts
export type Action = "project:read" | "project:update" | "project:delete" | "billing:manage";
export async function check(subject: Subject, action: Action, resource: ResourceRef): Promise<boolean>;
export async function filter(subject: Subject, action: Action, type: ResourceType): Promise<FilterSpec>;
  • check answers single-object questions; call it after loading the object (or with its ID for ReBAC engines).
  • filter answers list questions. It returns either an ID list (ReBAC ListObjects, fine for small result sets), or a query condition to push into SQL (Cerbos query plan, OPA partial evaluation, or a hand-written scope like WHERE org_id = :org AND (owner_id = :me OR shared)).
  • Never list everything then check each row: it leaks timing and counts, and breaks pagination.

5. Policies by engine

RBAC in code (default for small apps):

ROLE_PERMISSIONS = {
    "owner":  {"project:read", "project:update", "project:delete", "billing:manage"},
    "admin":  {"project:read", "project:update"},
    "member": {"project:read"},
}

def check(user, action, project) -> bool:
    membership = user.membership_for(project.org_id)       # None if other org
    if membership is None:
        return False
    if action in ROLE_PERMISSIONS.get(membership.role, set()):
        return True
    if action == "project:update" and project.is_editor(user.id):
        return True
    return False                                           # default deny

OpenFGA (ReBAC):

model
  schema 1.1

type user

type org
  relations
    define owner: [user]
    define admin: [user] or owner
    define member: [user] or admin

type project
  relations
    define org: [org]
    define editor: [user] or admin from org
    define viewer: [user, org#member] or editor
    define can_delete: owner from org

Write tuples on every relationship change (user:anne editor project:42), and treat the tuple write as part of the same unit of work (outbox pattern) so DB and tuples cannot drift.

Cerbos (ABAC conditions, shorthand R = resource, P = principal):

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: invoice
  version: default
  rules:
    - actions: ["read"]
      effect: EFFECT_ALLOW
      roles: ["member"]
      condition:
        match:
          expr: R.attr.org_id == P.attr.org_id
    - actions: ["refund"]
      effect: EFFECT_ALLOW
      roles: ["billing_admin"]
      condition:
        match:
          all:
            of:
              - expr: R.attr.org_id == P.attr.org_id
              - expr: R.attr.status == "paid"
              - expr: R.attr.amount_cents <= 100000

OPA (Rego v1 syntax, the default since OPA 1.0):

package app.authz

default allow := false

allow if {
    input.action == "invoice:read"
    input.resource.org_id == input.subject.org_id
}

allow if {
    input.action == "invoice:refund"
    "billing_admin" in input.subject.roles
    input.resource.status == "paid"
}

6. Enforcement point

  • One middleware, decorator, or service-layer guard calls check; handlers do not re-implement it. Prefer enforcing in the service layer so jobs, GraphQL resolvers, and CLIs share it.
  • Load the object scoped by tenant first, then check. Tenant scoping is the first wall, the policy the second (row-level-security can be a third).
  • Cache decisions only per request. Cross-request caches must be invalidated on role or relationship change, which is rarely worth it.
  • Admin and support impersonation is its own action (support:impersonate) with its own audit trail, not a superuser bypass.

Testing

Table-driven matrix test, generated from the requirements matrix:

import pytest

CASES = [
    # subject,        action,           resource,           expected
    ("org_owner",     "project:delete", "project_in_org",    True),
    ("org_admin",     "project:delete", "project_in_org",    False),
    ("member",        "project:read",   "project_member_of", True),
    ("member",        "project:read",   "project_not_member", False),
    ("other_org_owner","project:read",  "project_in_org",    False),
    ("guest_shared",  "project:update", "project_shared",    False),
]

@pytest.mark.parametrize("subject,action,resource,expected", CASES)
def test_permission_matrix(fixtures, subject, action, resource, expected):
    assert check(fixtures[subject], action, fixtures[resource]) is expected

Engine-native tests:

fga model test --tests store.fga.yaml      # OpenFGA: check / list_objects assertions
cerbos compile ./policies                  # Cerbos: compiles and runs *_test.yaml suites
opa test ./policy -v                       # OPA: runs test_* rules
opa check --strict ./policy

Also add an HTTP-level test per sensitive route: user from org B requests org A’s object and gets 404; list endpoints never return org A rows to org B.

Gotchas

  • Role explosion (project_admin_eu_readonly) means attributes are hiding in role names; move them to ABAC conditions.
  • Org-scoped roles stored globally (users.role = 'admin') make a user admin in every org they join. Store roles on the membership row.
  • ReBAC tuple drift: deleting a project in the DB without deleting tuples leaves ghost access; a reused ID then inherits it. Use non-reusable IDs (UUIDs) and delete tuples in the same outbox transaction.
  • Policy engines evaluated over the network add latency: batch checks for a page, run the PDP as a sidecar, and set a timeout that fails closed.
  • filter and check diverging (list shows a row the detail page denies) is a bug class; derive both from the same policy and test both.
  • Field-level rules (salary visible only to HR) need serializer-level enforcement too; a correct object check still over-shares fields.
  • GraphQL: authorize in resolvers or the service layer, not only at the top-level query.

Verification

  1. Grep from step 1 returns no ad-hoc role checks outside the authz module.
  2. Matrix test covers every cell of the requirements matrix, including deny cells.
  3. Engine tests pass in CI (fga model test, cerbos compile, or opa test).
  4. A cross-tenant HTTP test returns 404 for detail routes and excludes rows from lists.
  5. Deny decisions are logged with subject, action, resource, and policy version (see security-logging for the event format).

Output contract

## Authorization design: <service>
**Model:** RBAC + ABAC conditions   **Engine:** Cerbos (sidecar)   **Default:** deny
### Matrix
<subject x action table>
### Enforcement
- Single point: services/authz.py `check()` / `filter()`
- Call sites migrated: 23 (list); remaining ad-hoc checks: 0
### Tests
- Matrix cases: 48 (allow 19 / deny 29); engine suite: passing
### Open risks
- <e.g. tuple sync on project delete pending outbox work>

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc