382 lines
39 KiB
Gherkin
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)
|