Skip to main content

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.

Source facts

Repository
wso2/labs-agentic-engineer
Last source activity
September 20, 2026 at 08:36
Detected SKILL.md language
English
Stars
24
Forks
24

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
security-design
description
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.
metadata
{"aep":{"kind":"platform","audience":["design"]}}
# 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 PRD story numbers it serves — **at least one, and the build gate checks one direction only**: every story a role cites must be a real PRD story, or the design and the requirements have drifted. The reverse is not checked here — a story no role names is not an error, because PRD 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 before writing a role's `grants`, walk the flow `wireframes.dsl` gives that role, 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. 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 ```json { "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": [1, 2, 6], "grants": ["claims:read", "claims:submit"], "assignTo": ["Employees"] }, { "name": "Approver", "description": "Approves or rejects submitted claims and reads monthly reports.", "stories": [3, 4, 5, 7], "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 is for. Project-owned: the platform writes it on every build. | | `roles[].stories` | PRD story numbers 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. | **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), `wireframes` 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.
View on GitHub