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.

SKILL.md

GitHub

Security 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 in component. 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 security override inherits the document default and needs a valid token and no scope. GET /me is the usual one. Nothing in this file declares them; openapi.yaml does, 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 NoAccess page — 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.