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.
- name
authorization-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.
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), neveruser.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 \ Subject | Org owner | Org admin | Member | Guest (shared) | Other org |
|---|---|---|---|---|---|
project:read | yes | yes | if member of project | if shared | no |
project:update | yes | yes | if project editor | no | no |
project:delete | yes | no | no | no | no |
billing:manage | yes | no | no | no | no |
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 like | Model | Default implementation |
|---|---|---|
| Fixed roles per tenant, permissions follow the role | RBAC | In-app role-to-permission map; roles stored per (user, org) |
| Rules depend on attributes: owner, status, region, plan, time, amount | ABAC (often RBAC + conditions) | Cerbos or OPA, or in-app policy functions |
| Sharing, nested folders/teams, “editor of parent grants editor of child”, Google-Docs-style | ReBAC | OpenFGA or SpiceDB |
| Need to explain and audit decisions across many services | Any, externalized | Policy engine as a sidecar or service (PDP) |
| Small app, one service, under ~10 roles and no sharing | RBAC in code | A single permissions.py/policy.ts module; no engine |
Engine choice:
| Engine | Model strength | Data it needs | Watch out for |
|---|---|---|---|
| OpenFGA (CNCF) / SpiceDB | ReBAC, hierarchies, list objects | Relationship tuples you write and keep in sync | Dual-write consistency between your DB and the tuple store |
| Cerbos | RBAC + ABAC via CEL conditions, derived roles | Principal and resource attributes sent per request | Stateless: list filtering uses its query plan API, not a scan |
| OPA / Rego | General policy, infra and app | input document per request, optional bundled data | Rego learning curve; keep app policies small and tested |
| Cedar / Amazon Verified Permissions | RBAC + ABAC, analyzable policies | Entities sent per request | Ecosystem smaller outside AWS |
| In-app code | Anything simple | Your ORM | Discipline: 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>;
checkanswers single-object questions; call it after loading the object (or with its ID for ReBAC engines).filteranswers list questions. It returns either an ID list (ReBACListObjects, fine for small result sets), or a query condition to push into SQL (Cerbos query plan, OPA partial evaluation, or a hand-written scope likeWHERE org_id = :org AND (owner_id = :me OR shared)).- Never list everything then
checkeach 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.
filterandcheckdiverging (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
- Grep from step 1 returns no ad-hoc role checks outside the authz module.
- Matrix test covers every cell of the requirements matrix, including deny cells.
- Engine tests pass in CI (
fga model test,cerbos compile, oropa test). - A cross-tenant HTTP test returns 404 for detail routes and excludes rows from lists.
- 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.