Files
RenewCMS/features/roles_permissions_roles_api.feature
T

382 lines
39 KiB
Gherkin

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)