security-design
Write specs/design/security.json when a design has sign-in, permissions, roles or test users — the permission catalog every API operation is gated on.
Files1
View on GitHubSKILL.mdskills/security-design/SKILL.md
GitHubSecurity design
Write specs/design/security.json when the design has sign-in, permissions or
test users. It is the permission catalog for the project: every scope an
operation requires and every role the platform provisions is authored here and
referenced everywhere else. openapi.yaml references handles from this file; it
never defines them. Nothing about screens is written here: a screen is gated by
the operation it loads, whose one handle is already in openapi.yaml
(authorization-model states the invariants this file lives under).
There is exactly one control point. A caller's permissions are the intersection of the roles their groups hold with the resource server the token is asked for — group → role → granted permissions. Nothing else narrows: the OAuth client's own scope allowlist is written for truthfulness and is not a gate (the identity provider stores it, returns it, and ignores it), and an unknown or ungranted handle is dropped from a token silently — no error, no runtime signal. This file, and the gate that checks it, is the only defence against a scope nobody can hold.
The platform provisions from it at Build (resource server, actions, project
roles, group bindings, test users) and the console renders it as the Security
page. architecture declares the thunder-app dependency on the SPA and each
protected API; this file says what the people who sign in through it may do.
Build the catalog from the PRD's capabilities
Walk the PRD's capabilities, not its endpoints:
- One resource per business object the app protects —
claims,reports. Each is owned by exactly one service component, named incomponent. Moving an endpoint between services later does not rename a scope. - One action per capability —
submit,approve,reject,export. The grain is the capability, not the operation and not read/write per service. Several operations may require the same handle; every operation requires exactly one. - A handle is
<resource>:<action>, unique in the project. Each segment is[a-z][a-z0-9-]*.
An action says what a caller may do. Which rows it reaches is not in this
file. Reach is the operation's path in openapi.yaml: an operation under
/me/ reaches the caller's rows (or, through a relation noun, rows of theirs —
/me/team/claims); an operation anywhere else reaches every row. So a
capability that exists at two reaches is two actions guarding two
operations — read on GET /me/claims and read-all on GET /claims — and
nothing about either action says so; the paths do. openapi-conventions owns
the rule and the gate that refuses one handle guarding both sides of /me/.
There is no ownership, no own/any, and no widening: a role that needs both
reaches holds both handles, and nothing implies anything.
Name the every-row action so a reader can tell it from the caller's-rows one
(read-all, manage, export), and keep the description honest about it —
"every claim", not "claims". The Security page shows each handle's reach beside
it, read off the contract; a description that contradicts the path is the one
thing the page cannot catch.
openid, profile, email, group and ou are reserved OIDC scopes. They
ride every access token, so one of them as a handle would admit every signed-in
person in the organisation. They can never be a resource or an action.
Reuse a group before you declare one
A role is the project's: it is created per project, its permission set is
replaced on every build, and its name is a PRD actor noun (Approver), unique
in the project, never a group name. A group is the organisation's: it is a
set of people, shared across projects, and the platform creates it but never
renames or deletes it.
The binding between the two is assignTo, and it is a declared decision,
not a name match. So reuse is deliberate:
Call list_groups before you write a single groups[] entry. It lists the
directory's groups with memberCount, projects (how many projects already
bind roles to it) and platformCreated. Reuse a name verbatim when the people
the role is for already form that group — Finance beats a fresh
Approvers that means the same people — and declare a new group in groups[]
only when no existing one is those people. A group already in the directory is
never redeclared.
The tool's own description says what each field means; it is the contract between the tool and this skill, and it is not copied here — the two ship on different clocks and a copy would go stale with nothing to catch it.
A group with platformCreated: false was made by hand. Bind to one only when it
genuinely is the population the role is for.
Whether a new group may be introduced at all is the organisation's call, not
this skill's. organization's Security & compliance section holds it,
along with the groups this org prefers and the ones a role may never be assigned
to. Read it before you write groups[]: a filled line there is the decision.
Every PRD actor gets a role, and no role exists without an actor
Roles come from the PRD's Actors section. Define no role the PRD has no actor
for, and give every actor a row. When the actor noun is also the group's name
— a PRD actor Finance whose people are the org's Finance group — the group
keeps the name and the role takes what the actor DOES here (FinanceReviewer),
naming the actor in its description. A role and a group cannot share a name,
and the group's is the organisation's to keep. Each role cites in stories the IDs
of the stories it serves ("F1.2") — at least one, and the build gate checks
one direction only: every story a role cites must be a real story in the
feature files, or the design and the requirements have drifted. The reverse is
not checked here — a story no role names is not an error, because coverage is
carried by each component's
design.json stories, not by this file. So read the stories once more before
you finish and ask whether an actor-bearing story is really served by the role
you gave it.
Grants follow the screens' loads
Scopes are compared as whole strings, everywhere — the gateway and the
directory. Nothing implies anything: a token carrying claims:read-all is
refused by an operation that requires claims:read.
A screen is reachable for whoever holds the scope of the operation it loads
— the list or detail call whose answer the screen renders on open. Nothing
about that is written here: the SPA reads it off openapi.yaml. What this file
decides is whether each role holds that scope. So for a role's grants, walk
each flow that the web application's prototype gives that role
(prototype.json flows), open the openapi.yaml of the component behind
each screen in it, and grant the handle of the operation each screen loads — at
the reach the screen shows. The design writes this file before the prototype
and the contracts exist: grant then what each role's stories need, and the
design's grants pass does this walk when the screens and the operations exist. An
Approvals queue that lists every claim loads GET /claims, so the Approver
holds claims:read-all; a My Claims page loads GET /me/claims, so the
Employee holds claims:read. Then grant the actions the screen's controls call.
No gate refuses a role that is one handle short at design time; the build's
mock walk does, opening each flow's entry screen as its role and naming the
screen, the operation and the handle it wanted. On a plane where the gateway
answers 401 for every failure — no token, expired token, missing scope, all
byte-identical — the same omission in a deployed app is not a tidy 403 either:
the SPA hides the screen, or a typed URL lands on Forbidden, and the fix is
one more entry in grants.
One scope per operation is the invariant this guidance protects. Never propose listing alternatives on an operation, and never ask for a handle to imply another.
Every user role gets a test user
A test user is an account that exists so a role's behaviour can be exercised — the validation agent signs in as one to judge role-gated acceptance criteria. The platform generates its password at Build, seals it, and publishes it in the Roles gate ticket beside that role's granted scopes; that ticket is where the validation agent reads its login. A test user is a disposable account for automated agents, readable by anyone who can read the repository — never a person's account.
Emit one per kind: user role, whatever its enrolment, named test-<role-slug>
(Compliance Admin → test-compliance-admin), so the user sees them in
Security and can rename them before Build. The platform supplies any you
omit, so a missing test user is never a blocked build — but naming them
yourself is what lets the user recognise and change them. roles is a list: a
user holding two roles is how the design exercises somebody who both files and
approves.
A self-service role gets one too — enrolment says how real accounts
arrive, never whether the role can be exercised (authorization-model).
Only kind: service roles get none: their principal is an application.
A username the platform did not create (jsmith) is a refusal, not a password
reset: that role has no working login, and a real person's name lands in a
published ticket. Invent no password anywhere in the design; there is no
property at any level where one could go, and a write that adds one is rejected.
The file
{
"version": 3,
"permissions": [
{
"resource": "claims",
"component": "expense-api",
"description": "Expense claims and their approval",
"actions": [
{ "handle": "read", "description": "The caller's own claims" },
{ "handle": "read-all", "description": "Every claim" },
{ "handle": "submit", "description": "Create and send a claim" },
{ "handle": "approve", "description": "Approve a submitted claim" },
{ "handle": "reject", "description": "Reject a submitted claim" }
]
},
{
"resource": "reports",
"component": "expense-api",
"actions": [
{ "handle": "read", "description": "Monthly totals" },
{ "handle": "export", "description": "Download CSV" }
]
}
],
"groups": [
{ "name": "Employees", "description": "Everyone on payroll" }
],
"roles": [
{
"name": "Employee",
"description": "Submits and follows their own claims.",
"stories": ["F1.1", "F1.2", "F1.3"],
"grants": ["claims:read", "claims:submit"],
"assignTo": ["Employees"]
},
{
"name": "Approver",
"description": "Approves or rejects submitted claims and reads monthly reports.",
"stories": ["F2.1", "F2.2", "F2.3", "F3.1"],
"grants": ["claims:read", "claims:read-all", "claims:approve", "claims:reject", "reports:read"],
"assignTo": ["Finance"],
"assignableBy": ["Approver"]
}
],
"testUsers": [
{ "username": "test-employee", "roles": ["Employee"] },
{ "username": "test-approver", "roles": ["Approver"] }
]
}
Finance is not in groups[]: list_groups returned it, so Approver is
assigned to the people who already are Finance. Employees is new to this
project and is declared. Approver grants claims:read-all because the
Approvals screen loads GET /claims — every claim — and claims:read because
an approver has claims of their own too; neither grant is implied by the other,
and the openapi.yaml beside this file is where GET /me/claims and
GET /claims say which rows each returns. No screen is named in this file: the
SPA gates each one on the operation it loads.
| Field | Rule |
|---|---|
version |
Always 3. |
permissions[] |
The catalog. resource unique in the project; component names a service in the cell; at least one action; action handles unique within their resource (claims:read and reports:read legally coexist). |
groups[] |
Organisation groups this project introduces: name, description. Created if absent, never renamed or deleted. A group already in the directory is not redeclared. |
roles[].name |
A PRD actor noun, unique in the project (case-insensitively), never a group name. It becomes <project>/<name> on the directory. |
roles[].description |
What the role does and what it never sees or does, in plain words (see below). Project-owned: the platform writes it on every build. |
roles[].stories |
Story IDs ("F2.3") this role serves. At least one. |
roles[].grants |
Handles from permissions[]. At least one. A handle no role grants is a warning ("unreachable by any role"); a handle no operation requires is a warning ("declared, used nowhere"). |
roles[].assignTo |
Organisation groups the role is assigned to. Each must be declared in groups[] or exist in the directory (list_groups) — anything else is refused, so a typo cannot create a group. Required for enrolment: admin user roles; absent for self-service and service roles. |
roles[].enrolment |
Optional. admin (default) or self-service. See self-service actors. |
roles[].assignableBy |
Optional role names, validated against roles[]. Records who may hand this role out. |
roles[].kind |
Optional. user (default) or service. A service role is assigned to an application principal, never to a group, and gets no test user. |
testUsers[].username |
Lowercase letters, digits, ., _, -. |
testUsers[].roles |
One or more declared kind: user roles, of either enrolment. The account is enrolled in every assignTo group of every role listed; a role with no assignTo is bound to the account directly. |
The descriptions are what a business reviewer checks. The console shows
Roles and permissions from the descriptions of the roles and the actions, above
the grants. So write each one in plain words, with its limits. A role's
description says what the role does and what it never sees or does:
"Approves or rejects the claims of their own team, never their own. Sees no
other team's claims." An action's description says what it lets a caller do,
and to which records: "Every claim", "The caller's own claims". A limit that no
screen can show, such as what a role never sees, is written only here.
A role that reaches rows by path gets two test users, not one. Where a role
grants a /me/ operation, the thing worth proving is that one caller cannot see
another's rows — and a single account per role makes that unprovable everywhere
downstream: the mock walk, a wired run and validation alike can only ever check
the caller against themselves. A second account is what gives the isolation
something to fail against.
A self-service role is usually the /me/ actor, and the platform supplies only
one login per role — so the pair is yours to author or isolation goes unproven.
Nothing else goes in the file. There is no screens[], no ownership, no
coldStartRole, no publicComponents, no thunder block, no grantedBy:
which rows an operation reaches is its path, a component is protected because
it depends on sign-in, an operation is public because its security is empty,
a screen is gated by the operation it loads and is public when its flow carries
no role, the OAuth client's name and scope list are derived, and
assignableBy records who hands a role out. A document that carries
screens[] is refused with the key named.
The signed-in baseline
There is no role a caller falls back to. A person whose groups hold no project role gets a valid token with the OIDC scopes and no permission scopes — and that is a designed state, not a hole:
- Operations every signed-in person may call are the baseline: an operation
with no
securityoverride inherits the document default and needs a valid token and no scope.GET /meis the usual one. Nothing in this file declares them;openapi.yamldoes, by saying nothing. - Screens work the same way: a screen whose load operation is one of those, or that loads nothing, is every signed-in user's.
- A caller who can reach nothing sees the SPA's
NoAccesspage — signed in, told which groups would help, given their username to quote — instead of an empty shell. The API agrees with it: the same scope is missing at both ends.
Inferring a role from the absence of one is fail-open, which is why the platform does not do it. Real people are admin-created (somebody adds them to a group) or self-registered.
Self-service actors
An actor the organisation does not enrol — a Patient booking an appointment, a
customer opening an account — gets "enrolment": "self-service" and no
assignTo. A registration flow assigns the role at account creation, which is
the legitimate cold start and a concept every identity provider has. Such a role
gets no group binding.
It still gets test users, two where the role reaches /me/ rows.
Use it only where the PRD genuinely describes people who sign themselves up,
and only where organization's Security & compliance section permits it.
Everything else is admin.
The platform provisions no registration flow today, so those test logins are
the role's only principals and no sign-up affordance appears on the sign-in
page. authorization-model invariant 11 has the consequence for criteria.
What the user has to know about a grant change
A refresh narrows and never widens. A grant removed from a role disappears from the caller's token at the next silent renew; a grant added to a role — or a person added to a group — does not appear until they sign out and sign in again. Say so wherever the design explains an access change; it is the first thing an operator hits after adding somebody to a group, and it looks exactly like a broken deployment.
The organization skill's Security & compliance and Authentication defaults
apply before you invent policy — a filled org entry is the decision. Nothing
here creates anything: the platform creates the resource server, the roles, the
groups and the test users when the user clicks Build. openapi-conventions owns
how an operation names a handle and which rows it reaches (its path),
prototype owns which screens exist and which role's flow walks them,
thunder-authentication owns how the SPA gates each screen on the operation it
loads; this skill owns the decisions all three consume, and
authorization-model states the invariants they share.