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:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -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)
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user