feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.

This commit is contained in:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
34. Secret sweep: after exercising every producer, marshal every stored event, the `/v1/activity` response and the `/v1/activity/export` file; assert none contains the password, hash, verification code, invite code, raw API key, article body, file bytes, asset URL or staging path. The log buffer is free of them too.
35. Allowlist: every `Change.Field` is in `{title, online, body, filename, display_name}` and every kind is `text` or `body`.
36. Read-back: list by `category=articles|media|users|system` and `outcome=failed`; compare action sets with the feature tables.
Superseded in part by roles_permissions_roles_api: the closed category list also holds `roles` (target `role`) and the actions are `role.create`, `role.edit`, `role.delete` and `role.reassign`, covered in test/roles_api_*_test.go.
## G. Mutation guard (gremlins stays clean)
37. `task mutate` scope is `./internal/infrastructure/useCases`. Each new branch needs a killing assertion: every no-op guard (body, online, filename, display name) with equal and differing values; delta sign and rune-versus-byte count; success versus failed outcome and recorded once after the change (event count and order against the repository call); every `errors.Is` row of the reason map plus the generic fallback; every `ctx` forwarding; the system-actor defaults. Bare-bool conditions produce no mutant, so cover both branches with tests regardless.
@@ -152,7 +152,7 @@ Feature: Roles and permissions content scope: own and all enforcement on article
Given a role repository that counts calls and a user repository that counts calls
When a gated request is sent with a session cookie
Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once
And ListGrants is called zero times by the permission check and by GetMyAccessUseCase
And List is called zero times by the permission check and by GetMyAccessUseCase (ListGrants was removed by roles_permissions_roles_api)
And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load)
And the status codes and the order of checks of the enforcement feature are unchanged
And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it
@@ -456,9 +456,9 @@ Feature: Roles and permissions content scope: own and all enforcement on article
Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all
When the client sends GET /v1/auth/me
Then access.permissions holds content.update at own and no settings.delete_site
And typed grants are not listed in this slice
And typed grants are not listed in this slice (superseded by roles_permissions_roles_api: typed entries, scope none included, are listed)
Scenario: Out of scope here
Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route
And typed grants are written by slice 3 and exposed to the SPA by a later slice
And rows of deleted content types in typed grants are cleaned up by slice 3
And typed grants are written by slice 3 and exposed to the SPA by a later slice (done by roles_permissions_roles_api: the API writes them and /auth/me lists them)
And rows of deleted content types in typed grants are cleaned up by slice 3 (done by roles_permissions_roles_api)
@@ -6,9 +6,9 @@ Do not copy any `Role.Allows`, `Role ==` or hand-rolled own/all comparison into
## Deferred to later slices
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types.
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types. Done by roles_permissions_roles_api.
- 4: role change, invite with role, ownership transfer, removal of the legacy role column.
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants.
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants (the API side is done by roles_permissions_roles_api).
- Not changed: GET /v1/media/{id}/usage still lists articles and entries for any media.view holder. media.delete own fails closed. Entry relation summaries are not gated by the related type.
## Design notes for the user
@@ -44,7 +44,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
## Test infrastructure first
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `ListGrants`. Its `Get` returns the role with `Grants` filled, like the persistence repository.
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `List` (ListGrants was removed by roles_permissions_roles_api). Its `Get` returns the role with `Grants` filled, like the persistence repository.
2. User fake with a `Get` call counter (existing `mockUserRepo` plus a counter, or a wrapper). Users 3, 5, 9, 12 (unverified Admin), 13 (role_id 99), 20 (hand-placed owner-only), 21-27.
3. Article fake and entry fake seeded with the ids, states and created_by values of the Background. Both record writes, so "article unchanged", "entry stored" and "nothing stored" are asserted on the store. A recording `audit.Recorder` to assert "no audit event".
4. HTTP harness: `validator.InitValidator()`. Build `NewArticleRouter` and `NewContentTypeRouter` equivalents mounted on a fresh `chi.NewRouter()` mirroring `api/router.go`: `authtoken.Verifier`, `container.apiKeyAuth()` equivalent, the new optional-access middleware on the public article group, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, the typed permission gate, and the handlers over real use cases wired to the fakes. No live server, no real SQLite, no real SMTP.
@@ -68,7 +68,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
## B. Use cases (fakes only)
13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls.
14. Counters: for one `Require` call, `ListGrants` is 0, `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
14. Counters: for one `Require` call, `List` is 0 (was `ListGrants`), `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
15. Article use cases (get, list, update, update-is-online, delete, create, SEO check): table of caller grants × article × expected error or result, per the feature Outlines. Assert for refusals: error is `role.MissingPermissionError{Key: "content.update"}` and so on, the store is unchanged, the recorder has no event, and no site/author/image lookup happened (counters on those fakes).
16. Entry use cases (list, get, create, update, delete): same, including `created_by` unchanged on update, own-filtered list totals and `?q=` over the own set, and `entry.ErrEntryNotFound` for a non-owned read under own.
17. Order: scope check after the load and before validation: PUT with an invalid body for a non-owned entry gives the scope error, not `FieldError`.
@@ -86,7 +86,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
26. Live role: edit grants and `role_id` between requests with the same cookie and the same API key.
27. API key: article created through the key has `created_by` of the creator.
28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes.
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `ListGrants`; with an API key at most 2 user `Get`. Run on one article route and one entry route.
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `List` (was `ListGrants`); with an API key at most 2 user `Get`. Run on one article route and one entry route.
30. Owner-only: user 20 with a hand-placed `system.apply_updates=all` gets 403 on `POST /v1/system/update/check`; `/auth/me` omits it.
31. Media rows of the Outline and the "usage not filtered" assertion.
32. Regression: run the 2a Outline "Existing owner and admin behaviour does not regress", the authors.edit Outline and the activity/api-key scope Outlines unchanged.
@@ -116,5 +116,5 @@ Add a "superseded by roles_permissions_content_scope" note or edit in place:
- Start `task dev:api` on a copy of a migrated database. As the Writer: publish returns 403, saving a draft works and stays a draft. As the Admin: publish works.
- With two accounts on a custom role holding content.update=own, confirm account B gets 403 on A's article and entry, and A can edit them.
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. This is the only way to try typed grants until slice 3.
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. Typed grants can now also be written through PUT /v1/role/{id} (roles_permissions_roles_api).
- Confirm no password or secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
@@ -12,7 +12,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
- Scope with values none, own and all
- Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants}
- the sentinels ErrRoleNotFound and ErrUnknownPermission
- the Repository interface (list, get by id, get by name, list grants, create, save)
- the Repository interface (list, get by id, get by name, create, save; list grants was removed and delete, reassign members and remove typed grants added by roles_permissions_roles_api)
- the pure function Role.Allows(key, owned bool) bool (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits)
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
| group | key | kind | owner-only |
@@ -51,7 +51,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
| system | system.edit_retention | B | yes |
| system | system.transfer_ownership | B | yes |
And content permissions are stored under wildcard keys meaning "all content types"; per-type
overrides of the form content.<action>:<type_key> are deferred to slice 3 (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
overrides of the form content.<action>:<type_key> are written by slice 3, roles_permissions_roles_api (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
And a role stores one row per granted key, with scope own or all. A key with no row is none or off.
An on/off key that is on is stored as scope all.
And three built-in roles are seeded by persistence/database (like SeedDefaultSite), idempotently,
@@ -0,0 +1,381 @@
Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity
As the owner of a RenewCMS site
I want to manage roles over /v1/role with the checks the design asks for
So that an admin can build a Contributor role in under a minute, nobody can grant what they do not hold,
and every change is audited
Background:
Given the spec of record is docs/design/screens/roles.md (sections 1, 2, 4, 7, 8, tables "No-escalation feedback" and "HTTP status to copy") and docs/design/routes.md "Roles and permissions"
And features/roles_permissions_foundation.feature provides the catalogue, the built-in roles and /auth/me,
features/roles_permissions_enforcement.feature provides the 403 contract (body "missing_permission\n", header X-Missing-Permission with the base key),
and features/roles_permissions_content_scope.feature provides GrantSet, typed keys and ValidateGrants
And "403" means status 403, body "missing_permission\n" and the header X-Missing-Permission, nothing stored, no audit event
And every other refusal is plain text from http.Error, one snake_case code per line below. Success bodies are JSON, with snake_case fields
And the codes are:
| situation | status | body | extra header |
| role id unknown | 404 | role_not_found | |
| edit or delete the Owner (Locked) role | 403 | role_locked | |
| edit-owner-only needed for a built-in role | 403 | missing_permission | X-Missing-Permission: roles.edit_owner_only |
| delete or reassign from a built-in role | 409 | role_built_in | |
| rename a built-in role | 400 | built_in_name | |
| edit, delete or reassign from the caller's own role | 403 | own_role | |
| grant or touch more than the caller holds | 403 | escalation | X-Escalation-Key: the offending key |
| name already used | 409 | role_name_taken | |
| version mismatch on PUT | 409 | role_conflict | |
| delete a role that still has members | 409 | role_has_members | |
| bad reassign target | 400 | invalid_reassign (existing apperr) | |
| blank name after trim | 400 | invalid_role_name | |
| unknown or malformed permission key | 400 | unknown_permission | |
| scope not valid for the key | 400 | invalid_scope | |
| owner-only key on a role that is not Locked | 400 | owner_only_permission | |
| the same key twice | 400 | duplicate_permission | |
| typed key whose type is not article or a content type | 400 | unknown_content_type | |
And an undecodable body is 400 apperr.BodyErrorMessage and a failed validate tag is 400 with the validator text, as in the site handler.
A non-numeric id is 400 apperr.IdUint32ErrorMessage
And the request structs carry validate tags: name "required,max=100", description "max=255", grants key "required,max=100", version "required,min=1"
And the routes are mounted in api/router.go under /v1/role (NewRoleRouter), behind authtoken.Verifier, container.apiKeyAuth(), Authenticator,
RequireCurrentToken and RequireVerifiedUser:
| route | key (role.Key constant) | session only |
| GET /v1/role | roles.view | no |
| GET /v1/role/{id} | roles.view | no |
| POST /v1/role | roles.manage | yes |
| PUT /v1/role/{id} | roles.manage | yes |
| DELETE /v1/role/{id} | roles.manage | yes |
| POST /v1/role/{id}/reassign | roles.manage | yes |
| POST /v1/role/{id}/reset | roles.manage | yes |
And the permission gate runs before the id, the body and any lookup. An API key on a write route gets 403 apperr.SessionRequiredErrorMessage
And handlers receive use cases only: ListRolesUseCase, GetRoleUseCase, CreateRoleUseCase, UpdateRoleUseCase, DeleteRoleUseCase, ReassignRoleMembersUseCase.
They depend on domain interfaces only: role.Repository, user.Repository, contenttype.Repository, audit.Recorder
And the role JSON is the domain Role (id, name, description, built_in, locked, version, grants) plus members (number), updated_at and updated_by ({id, name} or null)
And grants in every response are the normalised effective list: catalogue order, unknown keys and owner-only keys on unlocked roles dropped,
duplicates resolved to the most restrictive scope, a typed entry (content.<action>:<type_key>, scope none included) right after its wildcard in type-key order.
A response body is always valid as the grants of a PUT. One domain function produces this list, used by the role responses, the diff and /auth/me
And a user's effective role id is role_id, else the built-in named by the legacy owner/admin string. members counts users by that id
And the no-escalation comparison is one domain function GrantSet.Covers(other, typeKeys): for every catalogue key, and for each content.* key and each
type key in {article} plus the existing content types, the scope of other must not rank above the scope of the caller (none < own < all). The first violation, in catalogue order, names the key
And the order of checks on every write is: gate, id (400), body (400), load role (404), locked (403), built-in rules (403/409/400),
own role (403), existing role covered by the caller (403 escalation), version (409 role_conflict), ValidateGrants and type keys (400),
new grants covered by the caller (403 escalation), name taken (409), write
And the roles and users of the Background (all verified, valid jwt cookie, token_version 0):
| role | grants | members |
| Owner | built-in, Locked, 34 keys | user 3 |
| Admin | built-in, 29 keys | user 5, user 10 (role_id 0, legacy admin) |
| Writer | built-in, 9 keys | user 9 |
| Contributor | content.read=all, content.create, content.update=own | users 41, 42, 43, 44 |
| Intern | content.read=own | none |
| Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | user 45 |
| Role manager | roles.view, roles.manage, content.read=all, content.create, content.update=own, content.publish=own, schema.view | user 31 |
| Viewer | roles.view | user 32 |
| Typed manager | roles.view, roles.manage, content.read=all, content.update=all, content.update:article=none | user 33 |
| Narrow manager | roles.view, roles.manage, content.read:events=all | user 34 |
| Events editor | content.read=all, content.update:events=own, content.delete:events=none, content.update:venues=all | user 35 |
And user 12 is an unverified Admin, and the content types are "events" and "venues"
Scenario Outline: The gate decides who reads and who writes roles
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 3 Owner | GET /v1/role | 200 |
| user 5 Admin | GET /v1/role | 200 |
| user 32 Viewer | GET /v1/role | 200 |
| user 32 Viewer | GET /v1/role/<Writer id> | 200 |
| user 9 Writer | GET /v1/role | 403 roles.view |
| user 9 Writer | GET /v1/role/abc | 403 roles.view (before the id is read) |
| user 32 Viewer | POST /v1/role {"name": "X"} | 403 roles.manage |
| user 32 Viewer | PUT /v1/role/999 | 403 roles.manage (before any lookup) |
| user 32 Viewer | DELETE /v1/role/999 | 403 roles.manage |
| user 32 Viewer | POST /v1/role/999/reassign | 403 roles.manage |
| no cookie | GET /v1/role | 401 apperr.UnauthorizedErrorMessage |
| user 12 unverified Admin | GET /v1/role | 403 apperr.VerificationRequiredErrorMessage |
| API key created by user 5 | GET /v1/role | 200 |
| API key created by user 5 | POST /v1/role {"name": "Keyed"} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
| stale token_version | GET /v1/role | 401 |
And a session follows the role live: after Viewer gains roles.manage in the repository, the same cookie's next POST passes the gate, with no re-login
Scenario: The list returns every role with grants and member counts
When user 5 sends GET /v1/role
Then the status is 200 and the body is a JSON array ordered by id, built-ins first
And Owner has members 1, Admin 2 (user 10 counted through the legacy fallback), Writer 1, Contributor 4, Intern 0
And each item holds its normalised grants, built_in, locked and version
And the Owner item holds the 34 keys at all and Admin the 29 keys at all
And the permission check and /auth/me do not call List
Scenario: Reading one role
When user 32 sends GET /v1/role/<Contributor id>
Then the status is 200, members is 4, version 1 and grants are content.read all, content.create all, content.update own
And GET /v1/role/999 is 404 role_not_found and GET /v1/role/abc is 400 apperr.IdUint32ErrorMessage
Scenario: A role with a hand-placed owner-only key or duplicate rows is returned normalised
Given a custom role with the rows content.update=own, content.update=all and a hand-placed system.apply_updates=all
When user 5 sends GET /v1/role/<that id>
Then grants hold content.update at own and no system.apply_updates
Scenario: Creating the Contributor role (design flow, section 11)
Given user 5 holds roles.manage
When user 5 sends POST /v1/role with {"name": "Writer plus", "description": "Writes posts and edits only their own", "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}
Then the status is 201 and the body is the role with built_in false, locked false, version 1, members 0
And the grants are in catalogue order and the new role appears in GET /v1/role
And exactly one role.create audit event is recorded (see "Activity")
And a body field built_in or locked sent by the client is ignored
Scenario: A new role may start with nothing granted
When user 5 sends POST /v1/role with {"name": "Empty"} and with {"name": "Empty 2", "grants": []}
Then both answer 201 with grants []
Scenario Outline: Write-time validation of create and edit
When user 5 sends POST /v1/role with name "Probe" and grants <grants>
Then the result is <result>
And nothing is stored and no audit event is recorded when the result is not 201
Examples:
| grants | result |
| content.update=all | 201 |
| content.update:events=own | 201 |
| content.update:article=none | 201 |
| content.update:events=all, content.update=own | 201 |
| content.explode=all | 400 unknown_permission |
| schema.view:events=all | 400 unknown_permission |
| content.update:Bad Key=all | 400 unknown_permission |
| content.update:nope=all | 400 unknown_content_type |
| content.update:author=all | 400 unknown_content_type |
| content.update=none | 400 invalid_scope |
| schema.view=own | 400 invalid_scope |
| content.update=banana | 400 invalid_scope |
| content.create:events=own | 400 invalid_scope |
| settings.delete_site=all | 400 owner_only_permission |
| system.apply_updates=all | 400 owner_only_permission |
| content.update=all, content.update=own | 400 duplicate_permission |
| content.update:events=all, content.update:events=own| 400 duplicate_permission |
And the grants are validated by role.ValidateGrants(false, grants) in the use case, then the type keys against the content types plus the built-in key "article"
And the owner-only keys are refused on every role a client can write, including a built-in Admin or Writer
Scenario Outline: Name and description rules
When user 5 sends POST /v1/role with name <name> and description <description>
Then the result is <result>
Examples:
| name | description | result |
| "" | "" | 400 (validate tag required) |
| " " | "" | 400 invalid_role_name |
| (101 characters) | "" | 400 (validate tag max=100) |
| (100 characters) | "" | 201 |
| " Reviewer " | "" | 201, name stored as "Reviewer" |
| "Reviewer 2" | (256 characters) | 400 (validate tag max=255) |
| "Writer" | "" | 409 role_name_taken |
| "WRITER" | "" | 409 role_name_taken (case-insensitive) |
| " contributor " | "" | 409 role_name_taken |
And a malformed JSON body is 400 apperr.BodyErrorMessage
Scenario Outline: No-escalation on create and edit
Given the caller edits the role Contributor (read all, create, update own) with the extra grant <grant>
When <caller> sends PUT /v1/role/<Contributor id> with the current version and the Contributor grants plus <grant>
Then the result is <result>
Examples:
| caller | grant | result |
| user 31 Role manager | content.publish=own | 200 |
| user 31 Role manager | content.publish=all | 403 escalation, X-Escalation-Key content.publish |
| user 31 Role manager | content.delete=own | 403 escalation (caller holds none), X-Escalation-Key content.delete |
| user 31 Role manager | schema.edit=all | 403 escalation |
| user 31 Role manager | content.publish:events=none | 200 (denying never escalates) |
| user 31 Role manager | content.publish:events=all | 403 escalation, X-Escalation-Key content.publish:events |
| user 31 Role manager | content.update:events=own | 200 |
| user 31 Role manager | content.update:events=all | 403 escalation |
| user 33 Typed manager | content.update=all | 403 escalation (update would become all on article), X-Escalation-Key content.update:article |
| user 33 Typed manager | content.update=all, content.update:article=own | 403 escalation (own is above the caller's none on article), X-Escalation-Key content.update:article |
| user 33 Typed manager | content.update=all, content.update:article=none | 200 |
| user 34 Narrow manager | (no change) | 403 escalation (Contributor's wildcard read is above a typed-only reader) |
| user 5 Admin | content.delete=all | 200 |
| user 3 Owner | content.delete=all | 200 |
And the same comparison applies on POST /v1/role with the same grants
And a refused call changes no field, bumps no version and records no event
Scenario Outline: A caller cannot edit, delete or move members of a role above their own grants
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 31 Role manager | PUT /v1/role/<Content lead id> with no change | 403 escalation (Content lead publishes and updates at all) |
| user 31 Role manager | DELETE /v1/role/<Content lead id> | 403 escalation |
| user 31 Role manager | POST /v1/role/<Content lead id>/reassign {"target_role_id": <Intern id>} | 403 escalation |
| user 31 Role manager | PUT /v1/role/<Contributor id> with a harmless change | 200 (Contributor is covered) |
| user 5 Admin | PUT /v1/role/<Content lead id> with a harmless change | 200 |
Scenario Outline: Editing a role
Given the roles of the Background, each at version 1
When <caller> sends PUT /v1/role/<target> with <body>
Then the result is <result>
Examples:
| caller | target | body | result |
| user 5 Admin | Contributor | name same, grants plus content.delete=own, version 1 | 200, version 2, members 4, grants include content.delete own |
| user 5 Admin | Contributor | grants minus content.update, version 1 | 200, content.update absent |
| user 5 Admin | Contributor | description changed, version 1 | 200, version 2 |
| user 5 Admin | Contributor | nothing changed, version 1 | 200, version 1, no write, no event |
| user 5 Admin | Contributor | nothing changed, version 7 | 409 role_conflict |
| user 5 Admin | Contributor | body changed, version 7 | 409 role_conflict, role unchanged |
| user 5 Admin | Contributor | no version field | 400 (validate tag required) |
| user 5 Admin | Contributor | name "writer", version 1 | 409 role_name_taken |
| user 5 Admin | Contributor | name "contributor" (case only), version 1 | 200, name stored as sent |
| user 5 Admin | Contributor | name "Intern", version 1 | 409 role_name_taken |
| user 5 Admin | Contributor | content.explode=all | 400 unknown_permission, role unchanged |
| user 5 Admin | 999 | any valid body | 404 role_not_found |
| user 3 Owner | Writer | description changed, version 1 | 200, version 2, the role keeps built_in true |
| user 3 Owner | Writer | grants plus media.delete=own, version 1 | 200 |
| user 3 Owner | Writer | name "Author", version 1 | 400 built_in_name |
| user 3 Owner | Admin | grants minus users.remove, version 1 | 200 |
| user 3 Owner | Admin | grants plus system.apply_updates=all | 400 owner_only_permission |
| user 5 Admin | Writer | description changed, version 1 | 403 missing_permission, X-Missing-Permission roles.edit_owner_only |
| user 5 Admin | Admin | description changed, version 1 | 403 missing_permission, X-Missing-Permission roles.edit_owner_only (own role, built-in rule first) |
| user 3 Owner | Owner | any body | 403 role_locked |
| user 5 Admin | Owner | any body | 403 role_locked |
| user 31 Role manager | Role manager | description changed, version 1 | 403 own_role |
| user 5 Admin | Contributor | an Admin may edit a role they do not hold | 200 |
And a successful edit leaves the id, built_in and locked of the role unchanged
And members who hold the edited role see the new grants on their next request, with no re-login, and so do the API keys they created
And a stored built-in edit survives the next seed: SeedBuiltInRoles never overwrites a role whose version is not 1
Scenario: Who saved last
Given user 5 saved Contributor at 11:02
When user 32 sends GET /v1/role/<Contributor id>
Then updated_at is that time (RFC 3339, UTC) and updated_by is {"id": 5, "name": the display name of user 5, else the username}
And a role never edited since seeding has updated_by null
And when the saving user was deleted updated_by is null
Scenario: A stale editor gets a conflict, then reads the new state
Given user 5 and user 3 both loaded Contributor at version 1
When user 3 saves first and user 5 then saves the same version
Then user 3 gets 200 with version 2 and user 5 gets 409 role_conflict
And the stored role is user 3's
And the repository Save is a compare-and-swap on version: two concurrent saves of version 1 give exactly one 200 and one 409
Scenario Outline: Deleting a role
When <caller> sends DELETE /v1/role/<target>
Then the result is <result>
Examples:
| caller | target | result |
| user 3 Owner | Intern | 204, the role and its grants are gone, GET answers 404 role_not_found |
| user 5 Admin | Intern | 204 |
| user 5 Admin | Contributor | 409 role_has_members, nothing deleted |
| user 3 Owner | Writer | 409 role_built_in |
| user 3 Owner | Admin | 409 role_built_in |
| user 3 Owner | Owner | 403 role_locked |
| user 5 Admin | Owner | 403 role_locked |
| user 5 Admin | 999 | 404 role_not_found |
| user 31 Role manager | Role manager | 403 own_role |
| user 31 Role manager | Content lead | 403 escalation |
| user 31 Role manager | Intern | 204 |
And the Intern row of user 31 is 204, because Role manager holds content.read at all, which covers read own
And a delete never touches content: articles and entries created by the role's members keep their created_by
And the repository Delete re-checks members inside its transaction and returns role.ErrRoleHasMembers when a member appeared meanwhile, so the race answers 409 role_has_members
Scenario Outline: Moving members, then deleting
Given Contributor has members 41, 42, 43 and 44
When <caller> sends POST /v1/role/<source>/reassign with <body>
Then the result is <result>
Examples:
| caller | source | body | result |
| user 5 Admin | Contributor | {"target_role_id": <Writer id>} | 200 {"moved": 4}, users 41-44 now have the Writer role_id, tokens and cookies stay valid |
| user 5 Admin | Intern | {"target_role_id": <Writer id>} | 200 {"moved": 0}, no event |
| user 31 Role manager | Contributor | {"target_role_id": <Admin id>} | 403 escalation (Admin is above Role manager), nobody moved |
| user 31 Role manager | Contributor | {"target_role_id": <Intern id>} | 200 {"moved": 4} |
| user 5 Admin | Contributor | {"target_role_id": <Owner id>} | 400 invalid_reassign (there is one owner) |
| user 5 Admin | Contributor | {"target_role_id": <Contributor id>} | 400 invalid_reassign |
| user 5 Admin | Contributor | {"target_role_id": 999} | 400 invalid_reassign |
| user 5 Admin | Contributor | {} | 400 (validate tag required) |
| user 5 Admin | Writer | {"target_role_id": <Intern id>} | 409 role_built_in |
| user 5 Admin | Owner | {"target_role_id": <Intern id>} | 403 role_locked |
| user 5 Admin | 999 | {"target_role_id": <Writer id>} | 404 role_not_found |
| user 31 Role manager | Role manager | {"target_role_id": <Intern id>} | 403 own_role |
And after the first row, DELETE /v1/role/<Contributor id> by user 5 answers 204 and the role is gone
And the move is one statement: when the repository fails, nobody is moved and the answer is 500 "Internal server error" with a failed role.reassign event
And moving members changes only role_id. Their passwords, token_version, verification and legacy role string are untouched
Scenario Outline: Resetting a built-in role to its defaults
When <caller> sends POST /v1/role/<role>/reset with {"version": <version>}
Then the answer is <result>
Examples:
| caller | role | version | result |
| Owner | Writer, edited | current | 200 with the seeded Writer grants and description, version bumped by one, name and flags unchanged |
| Owner | Admin, edited | current | 200 with the seeded Admin grants, version bumped by one |
| Owner | Writer, pristine | current | 200, grants unchanged, no version bump, no event |
| Owner | Writer, edited | stale | 409 role_conflict |
| Owner | Owner | current | 403 role_locked |
| Owner | Contributor | current | 400 not_built_in |
| Owner | 999 | current | 404 role_not_found |
| user 5 Admin | Writer, edited | current | 403 missing_permission, X-Missing-Permission roles.edit_owner_only |
| user 32 Viewer | Writer, edited | current | 403 missing_permission, X-Missing-Permission roles.manage |
| API key of the Owner | Writer, edited | current | 403 session_required |
And the reset replaces the stored grants with BuiltInRoles() in one transaction, so the role is never seen half reset
And members of the reset role see the seeded grants on their next request, with no re-login
And a successful reset records a role.edit event with the role as target, and a refused one records nothing
Scenario: Activity events for roles
Given the audit recorder is enabled and user 5 is the actor
When user 5 creates "Reviewer", then edits it (adds content.delete=own, removes media.view, changes content.update from own to all, renames to "Reviewers"), then deletes it
Then three events are recorded in order, with category roles, outcome success, the actor user 5 and the target {"type": "role", "id": the role id, "name": the role name}
And role.create has params added = every granted entry
And role.edit has changes for name and description (text before and after) and params added [{key, scope}], removed [{key, scope}], changed [{key, from, to}] over the normalised grants,
plus members. The diff is computed on (key, scope) entries with an absent entry meaning no entry: absent to present is added, present to absent is removed, a different scope is changed
And role.delete has params members 0
And the example edit above gives added content.delete own, removed media.view all, changed content.update own to all
And the move of members records one role.reassign event with params from (role id), to (role id) and moved, only when moved is greater than 0
And a refusal (403, 404, 409, 400) before the write records nothing
And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure like the other use cases
And GET /v1/activity?category=roles is a valid filter, visible to a caller whose activity.view is all
And audit.Categories() gains "roles", audit.TargetRole is "role", and the actions are role.create, role.edit, role.delete, role.reassign
Scenario: /auth/me exposes typed grants
Given user 35 is on the Events editor role
When user 35 sends GET /v1/auth/me
Then access.permissions is, in order: {"key": "content.read", "scope": "all"}, {"key": "content.update:events", "scope": "own"}, {"key": "content.update:venues", "scope": "all"}, {"key": "content.delete:events", "scope": "none"}
And wildcard entries with scope none are still not listed
And owner-only keys on an unlocked role and duplicate rows are normalised as before
And the roles-content-scope scenario "typed grants are not listed in this slice" is superseded by this one
Scenario: Deleting a content type removes its typed grants
Given the Events editor role at version 1 and the content type "events" has no entries and is not referenced
When user 5 sends DELETE /v1/content-type/events
Then the status is 204
And Events editor no longer holds content.update:events or content.delete:events, still holds content.read all and content.update:venues all, and has version 2
And a role that held no typed row for "events" keeps its version
And user 35 loses the events override on the next request: content.update on events falls back to the wildcard (none)
Scenario: A refused content type delete keeps the typed grants
Given the content type "events" still has entries
When user 5 sends DELETE /v1/content-type/events
Then the status is 409 apperr.ContentTypeHasEntriesErrorMessage and the Events editor role is unchanged, version 1
Scenario: A content type created with the key of a deleted one starts clean
Given a stale row content.update:events=none is stored on a custom role although no content type "events" exists
When user 5 sends POST /v1/content-type with the key "events" and a valid body
Then the status is 201 and that row is removed, so the new type is governed by the wildcard
Scenario: Content type keys do not change
Then PUT /v1/content-type/{key} never writes the key, so no typed row needs renaming
And a PUT with a different key in the body leaves the stored key unchanged, as before
Scenario: ListGrants is removed
Then role.Repository no longer has ListGrants
And role.Repository holds List (with grants), Get, GetByName, Create, Save (compare-and-swap on version, also writing updated_at and updated_by),
Delete(id) (one transaction, role.ErrRoleHasMembers when a user still has the role_id, removes the role_permission rows),
ReassignMembers(from, to) (int, error) and RemoveTypedGrants(typeKey) (int, error)
And the persistence repository asserts var _ role.Repository = &RoleRepository{} and keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL
Scenario: The comparison logic lives once, in the domain
Then GrantSet.Covers is the only implementation of "does the caller hold at least these grants"
And no use case, handler or middleware compares scopes by rank or reads a GrantSet entry
And api/router.go uses role.KeyRolesView, role.KeyRolesManage and no string literal equal to a catalogue key
And no handler under api/handlers/role imports persistence, mailer, models or gorm
Scenario: Out of scope here
Then there is no PUT /v1/user/{id}/role, no invite with a role, no ownership transfer and no removal of the legacy users.role string (slice 4)
And there is no SPA change (slices 5-7)
And the pending-invite count in members, and role_has_members for invites, arrive with the invite role in slice 4
And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the new routes are only added to its exemption list)
+110
View File
@@ -0,0 +1,110 @@
# QA procedure: roles_permissions_roles_api
Package: `test` (black-box). New fakes need unique names, for example `rolesAPIRoleRepo`, `rolesAPIUserRepo`, `rolesAPITypeRepo`, `rolesAPIRecorder`.
Reuse `mintCookie`, `assertGate`, the API key minting helper and the seeded fakes of `test/role_support_test.go` and `test/roles_content_scope_world_test.go` where they fit.
Do not copy any scope-rank comparison into tests: call `GrantSet.Covers`, `ScopeFor` and the exported helpers.
## Deferred to later slices
- 4: PUT /v1/user/{id}/role, invites with a role (and counting pending invites as members), ownership transfer, removal of the legacy role string.
- 5-7: SPA (roles list, editor, preview, delete dialog, role-aware shell). The SPA reacts to status, body code and the X-Missing-Permission / X-Escalation-Key headers.
## Design notes for the user
- docs/design says the conflict copy names who saved and when: this slice adds updated_at and updated_by to the role (question 1). The 409 body stays plain text.
- docs/design names the events role.created / updated / deleted. The code convention is create / edit / delete (question 8). The SPA activity copy keys on the action, so the design board needs the code names.
- docs/design has no code for "built-in role can't be deleted", "own role" and the escalation key: role_built_in, own_role, X-Escalation-Key are new.
- The design asks the Owner role to be uneditable: role_locked is checked first, before own_role and the built-in rule.
- Agents never edit docs/design: these four items are for you to relay to Claude Design.
## Inventory (route, key, session only, checks after the load, Owner / Admin / Writer)
| Route | Key | Session only | After the load | Owner / Admin / Writer |
|---|---|---|---|---|
| GET /v1/role | roles.view | no | none | ok / ok / 403 |
| GET /v1/role/{id} | roles.view | no | 404 role_not_found | ok / ok / 403 |
| POST /v1/role | roles.manage | yes | ValidateGrants, type keys, covers, name taken | 201 / 201 / 403 |
| PUT /v1/role/{id} | roles.manage | yes | locked, built-in rule, own role, covers existing, version, validate, covers new, name taken | ok / ok (not built-in) / 403 |
| POST /v1/role/{id}/reset | roles.manage and roles.edit_owner_only | yes | locked, built-in only, version, restore BuiltInRoles() | 200 / 403 / 403 |
| DELETE /v1/role/{id} | roles.manage | yes | locked, built-in, own role, covers existing, members | ok / ok / 403 |
| POST /v1/role/{id}/reassign | roles.manage | yes | locked, built-in, own role, covers source, target valid, covers target | ok / ok / 403 |
| DELETE /v1/content-type/{key} | schema.delete | as before | removes typed rows after the delete | as before |
| POST /v1/content-type | schema.edit | as before | clears stale typed rows for the key | as before |
| GET /v1/auth/me | none | as before | lists typed grants | as before |
## Test infrastructure first
1. `rolesAPIRoleRepo`: in-memory `role.Repository` seeded with the three built-ins (same Go function the migration uses) plus the Background roles. Implements `List` (with grants), `Get`, `GetByName`, `Create` (new id, version 1), `Save` (compare-and-swap on version, error `role.ErrRoleConflict`, also sets updated_at/updated_by), `Delete` (`role.ErrRoleHasMembers` if a user in the shared user fake has the role_id), `ReassignMembers`, `RemoveTypedGrants`. A switchable failure per method, call counters for `List`, `Get`, `GetByName`, and a hook to run code between `Get` and `Save` (race tests).
2. User fake with `GetAll`, a role mover shared with the role fake, and users 3, 5, 9, 10 (role_id 0, legacy admin), 12 (unverified Admin), 31-35, 41-45.
3. Content type fake with "events" and "venues". Recording `audit.Recorder` to assert event count, action, category, target, params, changes.
4. HTTP harness: `validator.InitValidator()`, a fresh `chi.NewRouter()` mirroring `NewRoleRouter` in api/router.go (Verifier, apiKeyAuth equivalent, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on writes, `RequirePermission(role.KeyRolesView|KeyRolesManage)`, AuditContext) over real use cases and the fakes. No live server, no real SQLite, no real SMTP.
5. `assertGate(t, rec, key)` for the 403 contract. New helper `assertRefusal(t, rec, status, code)` for plain-text codes, and `assertEscalation(t, rec, key)` for the `escalation` body plus `X-Escalation-Key`.
6. `grantsOf("content.update=own", "content.update:events=none", ...)` from the content-scope tests, so rows read like the feature.
## A. Domain (no fakes)
7. `GrantSet.Covers`: table of caller grants x candidate grants x type keys, including typed none, caller `update=all, update:article=none` against candidate `update=all` (violation key `content.update:article`), caller with typed-only read, toggles, and the first-violation order. Symmetry check: Covers(x, x) is always true, Owner covers every role, Admin does not cover Owner.
8. Normalised grant list function: duplicates, owner-only on unlocked, unknown keys, typed none kept, order (wildcard then typed by type key).
9. New sentinels exist and map with `errors.Is`: role.ErrRoleHasMembers, ErrRoleNameTaken, ErrRoleLocked, ErrBuiltInRole, ErrBuiltInName, ErrOwnRole, ErrInvalidRoleName, ErrUnknownContentType, and the typed `EscalationError{Key}` read with `errors.As`.
10. Hygiene tests in the style of `*_hygiene_test.go`: `internal/domain/role` imports the stdlib only; `Repository` has no `ListGrants`; no scope-rank comparison outside `internal/domain/role`; `api/router.go` and `api/handlers/role` hold no catalogue key literal and no import of persistence, models, gorm or mailer; the drift test for role.Key constants still passes.
## B. Use cases (fakes only)
11. `ListRoles`/`GetRole`: members through the legacy fallback (user 10 counted in Admin), order by id, normalised grants, updated_by name (display name, else username, null for a deleted user), `List` called once, `ListGrants` does not exist.
12. `CreateRole`: every row of the validation Outline, with `errors.Is` on the sentinel; type keys against the content type fake plus "article"; the covers rows; name taken (case-insensitive, trimmed); the stored role is built_in false, locked false, version 1; one `role.create` event with the right target and params.
13. `UpdateRole`: every row of "Editing a role", "No-escalation" and "above their own grants" Outlines. Assert the order of checks with counters: for a refusal before the version check the fake's `Save` and the content type `List` are never called. No-op PUT: no `Save`, no event, same version. Race: the hook bumps the version between `Get` and `Save`, expect `role.ErrRoleConflict`. Own role resolved for a legacy user (role_id 0).
14. Diff: the example edit gives added/removed/changed exactly as in the feature, plus name and description text changes; created role: added only; deleted role: members param.
15. `DeleteRole`, `ReassignRoleMembers`: every row of their Outlines; `moved` counts; no event when moved is 0; repository failure gives a failed event and no partial move; delete race (`Delete` returns `role.ErrRoleHasMembers`).
16. `DeleteContentType`: removes typed rows after the type delete, bumps only affected roles, does nothing when the delete is refused (`contenttype.ErrHasEntries`, `ErrReferenced`). `CreateContentType`: clears stale typed rows. Both with the role fake's `RemoveTypedGrants` counter.
17. `GetMyAccess`: typed entries incl. none, order, wildcard none omitted.
18. Activity: `audit.Categories()` contains "roles"; `audit.Event.Validate()` accepts the new category; the filter accepts `category=roles`.
## C. HTTP
19. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key writes get `apperr.SessionRequiredErrorMessage`. Live-role scenario (grant added to Viewer between two requests with the same cookie).
20. Each Outline of the feature: validation, name rules, no-escalation, above-own-grants, editing, delete, reassign. Assert status, exact body text, headers (`X-Missing-Permission`, `X-Escalation-Key`), the store after the call, and the recorder.
21. Order: for a Writer, `PUT /v1/role/abc` and `DELETE /v1/role/999` are 403 before 400/404; for a Viewer too. For user 31 an unknown key in the body is 400 before the escalation 403; an escalation on the existing role (Content lead) is 403 before a stale version 409.
22. Responses: list shape, one-role shape (members, updated_at, updated_by), 201 on create, 204 on delete, `{"moved": n}` on reassign. Content-Type header is JSON for success bodies, errors are plain text.
23. Live effect: after PUT on Contributor, user 41's next request on a gated route sees the new grants with the same cookie and an API key created by user 41.
24. `/auth/me`: the Events editor example.
25. Content type delete/create through the real content type router with the role fake attached (rows removed, versions bumped, refused delete leaves rows).
26. Loads per request: a gated request on a role route still loads the user at most once and the role once (`Get`), `List` 0 times by the permission check.
27. Regression: the 2a and 2b suites (enforcement, content scope) stay green after the interface change.
## D. Persistence (existing repository-test style, in-memory migrated DB helper `openMigratedRolesDB`)
28. `RoleRepository`: `List` returns grants for every role in one pass; `Save` compare-and-swap (two saves of version 1: one wins), writes updated_at and updated_by; `Delete` removes rows and refuses with `ErrRoleHasMembers`; `ReassignMembers` returns the count and touches only role_id; `RemoveTypedGrants` removes the five `content.*:<key>` forms only, bumps version once per affected role, leaves wildcard and other types; failure paths (DB closed) for every new method; `Create` of a role with typed rows; seed idempotency unchanged (72 rows, edited built-in kept).
29. `role` and `role_permission` columns: updated_at and updated_by exist after `Migrate` (AutoMigrate list in `persistence/database/migrate.go`), seeded roles have updated_by NULL.
## Existing tests that must change (recheck with `go vet -tags nofrontend ./test/...`)
| File (lines) | Change |
|---|---|
| test/role_support_test.go:85 | `mockRoleRepo.ListGrants` removed. Add `Delete`, `ReassignMembers`, `RemoveTypedGrants`; `List` returns grants; `Save` keeps its conflict behaviour |
| test/roles_content_scope_world_test.go:79-81 and test/roles_content_scope_http_test.go:587, 599, 604, 616, 621 | `listGrantsN` and the `ListGrants` wrapper go away. Replace "0 ListGrants" with "0 List calls by the permission check and /auth/me" |
| test/role_repository_gaps_test.go:45-48 | The `ListGrants` failure case becomes a `List` failure case |
| test/roles_content_scope_usecases_test.go:481, test/roles_content_scope_world_test.go:222, test/custom_content_types_support_test.go:271, 275, test/custom_content_types_support_http_test.go:220, 426, test/custom_content_types_usecases_test.go:154, 596, test/relation_field_type_test.go:127, 234 | `NewCreateContentTypeUseCase` and `NewDeleteContentTypeUseCase` take the role repository (clearing typed rows). Pass a role fake or a no-op one |
| test/activity_domain_test.go:59 | Categories list gains "roles" |
| test/roles_content_scope_http_test.go (the /auth/me typed-grants test) and the feature scenario "/auth/me lists the effective grants" | "typed grants are not listed" is superseded by typed entries incl. none |
| test/api_catalogue_drift_test.go:52 | Add the 6 new routes (GET /v1/role, GET /v1/role/{id}, POST /v1/role, PUT /v1/role/{id}, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign) to `routesNotYetInTheCatalogue`. The existing failure of `TestRouterRoutesAreInTheCatalogue` is not this slice's: report, do not fix |
| any test that lists `role.Repository` method sets or builds a `role.Role` literal with positional fields | recheck after adding UpdatedAt and UpdatedBy |
## Existing features and docs to amend
- features/roles_permissions_content_scope.feature: the scenario "/auth/me lists the effective grants" last line ("typed grants are not listed in this slice") and "Out of scope here" (typed grants written by slice 3: now done); add a "superseded by roles_permissions_roles_api" note in place. Same for its `.qa.md` "Deferred" line 3 and the manual item about editing `role_permission` by hand.
- features/roles_permissions_foundation.feature: the Background sentence "per-type overrides ... deferred to slice 3" and the Repository interface line (list, get by id, get by name, list grants, create, save).
- features/activity-log-producers.qa.md: add the roles category and the four actions.
- docs/api.md: new "Roles" section (routes, codes, headers, response shapes), the `/v1/auth/me` typed entries, the activity category and actions, and the content-type delete/create side effect. docs/architecture.md: role repository methods.
- docs/design: for you to relay, not edited by agents (see Design notes).
## Manual verification
- Edit the Writer role, then POST /v1/role/{id}/reset as Owner with the current version: grants are back to the seed and a second reset is a no-op.
- `task dev:api` on a copy of a migrated database. As the Owner: create "Contributor" (Write own preset grants), edit it, move a member to Writer, delete it. Check the activity log shows role.create, role.edit (with added/removed/changed), role.reassign and role.delete.
- As an Admin: editing Writer answers 403 with X-Missing-Permission roles.edit_owner_only; editing Owner answers role_locked.
- With a role manager who publishes only own entries: granting publish all answers escalation with the key in X-Escalation-Key.
- Open two sessions on the same role, save from both: the second gets role_conflict.
- Restart the server after editing Writer: the edit is still there (seed does not overwrite).
- Delete a content type that has a typed row and check the role_permission rows are gone in the database copy.
- No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).