Files
RenewCMS/features/roles_permissions_users_api.feature
T

360 lines
35 KiB
Gherkin

Feature: Roles and permissions users API: assign a role to a user, invite with a role, transfer ownership, and retire the legacy role string
As the owner of a RenewCMS site
I want to give every user and every invite exactly one stored role, change it under the no-escalation rule,
and hand the Owner role to another member
So that Users shows real roles, nobody grants a role they do not cover, and the old owner/admin string is gone
Background:
Given the spec of record is docs/design/screens/roles.md (sections 5, 7, 8, 9 and the 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 and the built-in roles,
features/roles_permissions_enforcement.feature the 403 contract (body "missing_permission\n", header X-Missing-Permission),
features/roles_permissions_content_scope.feature the GrantSet, and features/roles_permissions_roles_api.feature
the role routes, GrantSet.Covers, the escalation contract (body "escalation", header X-Escalation-Key) and the activity conventions
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, and writes nothing, not even the deletion of an expired invite
And success bodies are JSON with snake_case fields and Cache-Control "no-store" on the new routes
And the new refusals are:
| situation | status | body | extra header |
| user id unknown | 404 | user_not_found (existing apperr) | |
| role_id unknown (PUT role, invite) | 400 | invalid_role | |
| the target user holds the Owner role, or the role to give is the Owner role | 403 | role_locked (existing) | |
| change the role of your own account | 403 | own_role (existing) | |
| the current or the new role is above the caller's grants | 403 | escalation (existing) | X-Escalation-Key: first offending key |
| the target's role changed between read and write | 409 | user_role_changed | |
| transfer target unknown, self, unverified, or not a user | 400 | invalid_transfer | |
| confirm_email differs from the target's email | 400 | confirmation_mismatch | |
| delete a role that still has members, users or open invites | 409 | role_has_members (existing) | |
| an API key on a session-only route | 403 | apperr.SessionRequiredErrorMessage | |
And an undecodable body is 400 apperr.BodyErrorMessage, a failed validate tag is 400 with the validator text, a non-numeric id is 400 apperr.IdUint32ErrorMessage
And the request structs carry validate tags: PUT role "role_id" required, transfer "user_id" required and "confirm_email" required.
The invite body keeps "email" required,inviteemail and gains the optional "role_id" (absent or 0 means the Writer role)
And the routes are:
| route | key (role.Key constant) | session only | mounted in |
| PUT /v1/user/{id}/role | users.change_role | yes | NewUserRouter |
| POST /v1/user/invite | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
| POST /v1/user/invite/{id}/renew | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
| DELETE /v1/user/invite/{id} | users.invite (unchanged) | no | NewUserRouter, with the rate limiter |
| GET /v1/user | users.view (unchanged) | no | NewUserRouter |
| POST /v1/site/transfer-ownership | system.transfer_ownership | yes | NewSiteRouter, session group |
And the permission gate runs before the id, the body and any lookup. An API key on a session-only route gets 403 session_required before the gate
And handlers receive use cases only: ChangeUserRoleUseCase, TransferOwnershipUseCase, and the amended CreateInviteUseCase, RenewInviteUseCase,
ListUsersAndInvitesUseCase. They depend on domain interfaces only (user.Repository, invite.Repository, role.Repository, contenttype.Repository, audit.Recorder)
And the transfer handler lives in its own package (api/handlers/ownership), built by NewHandler(TransferOwnershipUseCase) and mounted by NewSiteRouter
And RoleRef is the one role summary shape {id, name, built_in, locked}. It is used by /auth/me access.role, by user items, by invite items and by the responses below
And a user's role is role_id, and nothing else. There is no fallback on a string
And the built-in roles are found by a single helper builtInRole(roles, name): GetByName, then built_in must be true, else role.ErrRoleNotFound
And the order of checks on PUT /v1/user/{id}/role is: session, gate users.change_role, id (400), body (400), load the target user (404),
the target holds a Locked role (403 role_locked), load role_id (400 invalid_role), the new role is Locked (403 role_locked),
the target is the caller (403 own_role), the target's current role covered by the caller (403 escalation),
the new role covered by the caller (403 escalation), same role (200, no write), write (409 user_role_changed on a stale read)
And the order of checks on POST /v1/user/invite is: gate users.invite, rate limiter, body (400), load the inviter, load the role (default Writer, 400 invalid_role),
the role is Locked (403 role_locked), the role covered by the caller (403 escalation), email already has an account (409), pending invite (409),
create, send the mail (the existing rollback on a send failure is unchanged)
And the order of checks on POST /v1/user/invite/{id}/renew is: the existing steps up to 404 and 409 (already accepted),
then the invite's role covered by the caller (403 escalation), then renew. DELETE /v1/user/invite/{id} needs no coverage
And the order of checks on POST /v1/site/transfer-ownership is: session, gate system.transfer_ownership, body (400), the target (400 invalid_transfer),
confirm_email (400 confirmation_mismatch), write (409 user_role_changed when the caller no longer holds the Owner role)
And the roles and people of the world (all verified, valid jwt cookie, token_version 0, role_id set, unless noted):
| role | grants | users | open invites |
| Owner | built-in, Locked, 34 keys | user 3 marta | none |
| Admin | built-in, 29 keys | 5 jonas, 10 lea, 12 sam (unverified) | none |
| Writer | built-in, 9 keys | 9 ines | invite 1 |
| Contributor | content.read=all, content.create, content.update=own | 41, 42, 43, 44 | invite 2 (expired), invite 3 (pending) |
| Intern | content.read=own | 46 | none |
| Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | 45 | none |
| People manager | users.view, users.invite, users.change_role, content.read=all, content.create, content.update=own, content.publish=own, schema.view | 31 | none |
| Viewer | users.view, roles.view | 32 | none |
| Inviter | users.invite | 33 | none |
And invite 1 is "new1@renew.example" for Writer, invite 2 is "old2@renew.example" for Contributor and expired, invite 3 is "new3@renew.example" for Contributor and pending,
invite 4 is accepted (user 9). Accepted invites are history and never count as members
And members, as counted for GET /v1/role, are: Owner 1, Admin 3, Writer 2, Contributor 6, Intern 1, Content lead 1, People manager 1, Viewer 1, Inviter 1
Scenario Outline: The gate decides who changes roles, invites and transfers
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 3 Owner | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
| user 5 Admin | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
| user 31 People manager | PUT /v1/user/41/role {"role_id": <Intern id>} | 200 |
| user 9 Writer | PUT /v1/user/41/role {"role_id": <Intern id>} | 403 users.change_role |
| user 9 Writer | PUT /v1/user/abc/role | 403 users.change_role (before the id is read) |
| user 32 Viewer | PUT /v1/user/999/role | 403 users.change_role (before any lookup) |
| user 5 Admin | PUT /v1/user/abc/role | 400 apperr.IdUint32ErrorMessage |
| API key created by user 5 | PUT /v1/user/41/role {"role_id": <Intern id>} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
| no cookie | PUT /v1/user/41/role | 401 apperr.UnauthorizedErrorMessage |
| user 12 unverified Admin | PUT /v1/user/41/role | 403 apperr.VerificationRequiredErrorMessage |
| user 3 Owner | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": <email of 5>} | 200 |
| user 5 Admin | POST /v1/site/transfer-ownership {"user_id": 10, "confirm_email": <email of 10>} | 403 system.transfer_ownership |
| user 9 Writer | POST /v1/site/transfer-ownership | 403 system.transfer_ownership (before the body) |
| API key created by user 3 | POST /v1/site/transfer-ownership {"user_id": 5, "confirm_email": <email of 5>} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
| user 32 Viewer | POST /v1/user/invite {"email": "x@renew.example"} | 403 users.invite |
| user 33 Inviter | GET /v1/user | 403 users.view |
And the gate follows the role live: after Viewer gains users.change_role in the repository, the same cookie's next PUT passes the gate, with no re-login
Scenario Outline: Changing a user's role
When <caller> sends PUT /v1/user/<id>/role with {"role_id": <role>}
Then the result is <result>
Examples:
| caller | id | role | result |
| user 5 Admin | 41 | Writer | 200, the user item of user 41 with role Writer |
| user 5 Admin | 41 | Admin | 200 (an Admin covers Admin) |
| user 5 Admin | 10 | Contributor | 200 (demoting another Admin is allowed, Admin covers Admin) |
| user 3 Owner | 5 | Content lead | 200 |
| user 31 People manager | 41 | Intern | 200 (Contributor and Intern are both covered) |
| user 31 People manager | 41 | Writer | 403 escalation, X-Escalation-Key content.update (Writer updates at all, the caller at own) |
| user 31 People manager | 41 | Content lead | 403 escalation, X-Escalation-Key content.update |
| user 31 People manager | 45 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Content lead is not covered) |
| user 31 People manager | 5 | Intern | 403 escalation, X-Escalation-Key content.update (the current role Admin is not covered) |
| user 5 Admin | 41 | Owner | 403 role_locked |
| user 3 Owner | 41 | Owner | 403 role_locked (one owner, transfer instead) |
| user 5 Admin | 3 | Admin | 403 role_locked (the owner cannot be changed) |
| user 3 Owner | 3 | Admin | 403 role_locked (the Owner role is checked before the own account) |
| user 5 Admin | 5 | Writer | 403 own_role |
| user 31 People manager | 31 | Intern | 403 own_role |
| user 5 Admin | 999| Writer | 404 user_not_found |
| user 5 Admin | 41 | 999 | 400 invalid_role |
| user 5 Admin | 41 | (no role_id) | 400 (validate tag required) |
| user 5 Admin | 41 | 0 | 400 (validate tag required) |
| user 5 Admin | 12 | Writer | 200 (an unverified user may be given a role) |
And a refused call changes no role_id, bumps no token_version and records no event
And the success body is the user item of GET /v1/user: id, kind "user", username, display_name, email, role (RoleRef), status "accepted", created_at
Scenario: Giving the same role again is a no-op
When user 5 sends PUT /v1/user/41/role with the Contributor id
Then the status is 200 and the body is the unchanged user item
And no write happens and no event is recorded
And a caller who does not cover the Contributor role (a Viewer-like role with users.change_role only) still gets 403 escalation, because the current role is checked first
Scenario: A role change takes effect at once and keeps sessions and keys
Given user 41 has a valid cookie and an API key, and holds the Contributor role
When user 5 moves user 41 to the Writer role
Then user 41's token_version is unchanged and the same cookie and the same API key still authenticate
And user 41's next request is decided by the Writer grants, with no re-login
And GET /v1/auth/me for user 41 shows access.role Writer
Scenario: Two concurrent changes on the same user give one winner
Given user 5 and user 3 both read user 41 on the Contributor role
When both send a PUT with a different new role at the same time
Then exactly one answers 200 and the other 409 user_role_changed
And the stored role is the winner's
And the repository UpdateRole(id, from, to) is a compare-and-swap on the role the use case read
Scenario: A role deleted meanwhile is an invalid role, not a dangling reference
Given the custom role "Temp" has no member and is deleted between the lookup and the write of a PUT
Then the answer is 400 invalid_role and user 41 keeps the Contributor role
Scenario: The Owner role always has exactly one verified holder
Given two registrants hold the Owner role and neither is verified
When the first one verifies
Then the second one cannot be verified while a verified Owner exists, and the answer is the existing verification refusal
And the repository performs the check and the verification in one transaction, so two concurrent verifications leave one winner
Then no call of this feature leaves zero or two verified holders of the Owner role
And PUT role to or from the Owner is 403 role_locked, DELETE and edit of the Owner role stay role_locked, reassign from or to the Owner role stays refused
And only POST /v1/site/transfer-ownership moves the Owner role, in one transaction
Scenario Outline: Inviting with a role
When <caller> sends POST /v1/user/invite with <body>
Then the result is <result>
Examples:
| caller | body | result |
| user 5 Admin | {"email": "a@renew.example"} | 201, role is the Writer RoleRef (default), one "invitation" mail sent |
| user 5 Admin | {"email": "a@renew.example", "role_id": 0} | 201, role Writer |
| user 5 Admin | {"email": "a@renew.example", "role_id": <Contributor id>} | 201, role Contributor |
| user 5 Admin | {"email": "a@renew.example", "role_id": <Admin id>} | 201, role Admin |
| user 3 Owner | {"email": "a@renew.example", "role_id": <Content lead id>} | 201 |
| user 5 Admin | {"email": "a@renew.example", "role_id": <Owner id>} | 403 role_locked, no invite, no mail |
| user 5 Admin | {"email": "a@renew.example", "role_id": 999} | 400 invalid_role, no invite, no mail |
| user 31 People manager | {"email": "a@renew.example", "role_id": <Intern id>} | 201 |
| user 31 People manager | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.update (the default Writer is above the caller) |
| user 31 People manager | {"email": "a@renew.example", "role_id": <Content lead id>} | 403 escalation, X-Escalation-Key content.update |
| user 33 Inviter | {"email": "a@renew.example"} | 403 escalation, X-Escalation-Key content.read |
| user 5 Admin | {"email": "old2@renew.example", "role_id": 999} | 400 invalid_role, and expired invite 2 is not deleted |
| user 5 Admin | {"email": "old2@renew.example", "role_id": <Writer id>} | 201, expired invite 2 is replaced by a new invite for Writer |
| user 5 Admin | {"email": "new3@renew.example"} | 409 apperr.PendingInviteErrorMessage (existing) |
| user 5 Admin | {"email": "ines@renew.example"} (user 9's address) | 409 apperr.EmailHasAccountErrorMessage (existing) |
| user 5 Admin | {"email": "not-an-email"} | 400 (validate tag inviteemail) |
And the invite response is {id, kind "invite", email, status, role (RoleRef), created_at, expires_at}
And an API key may still create invites, and the no-escalation check then uses the live role of the key's creator
Scenario: Renewing keeps the invite's role
Given invite 2 is expired and for Contributor
When user 5 sends POST /v1/user/invite/2/renew
Then the status is 200, the invite is pending again, its role is still Contributor and the response carries the role
And a new code is mailed as before and the old code no longer works
Scenario Outline: Renewing and cancelling against the no-escalation rule
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 31 People manager | POST /v1/user/invite/3/renew (Contributor) | 200 |
| user 31 People manager | POST /v1/user/invite/1/renew (Writer) | 403 escalation, X-Escalation-Key content.update, nothing renewed, no mail |
| user 31 People manager | DELETE /v1/user/invite/1 (Writer) | 204 (cancelling removes power, no coverage needed) |
| user 5 Admin | POST /v1/user/invite/999/renew | 404 apperr.InviteNotFoundErrorMessage (before any coverage check) |
| user 5 Admin | POST /v1/user/invite/4/renew (accepted) | 409 apperr.InviteAlreadyAcceptedErrorMessage |
Scenario Outline: Accepting an invite creates a user with the invite's role
Given a pending invite for "new3@renew.example" for <role>
When the client sends POST /v1/auth/invite/accept with its code, username "kim" and a valid password
Then the status is 200 and the stored user "kim" has the role_id of <role>
And the user is verified and has no legacy role string
And GET /v1/auth/me for "kim" shows access.role <role> with that role's grants
Examples:
| role |
| Writer |
| Contributor |
| Admin |
And an invite whose role_id is 0 or names a role that no longer exists is refused as apperr.InviteInvalidErrorMessage, no user is created, and a failed invite.accept event is recorded
And the invite's role is read at accept time, so editing the role's grants before the accept changes what the new user gets
Scenario: Open invites count as role members
When user 5 sends GET /v1/role
Then Writer has members 2 (user 9 and invite 1) and Contributor has members 6 (users 41-44, expired invite 2, pending invite 3)
And accepted invite 4 counts for nobody
And the members of a role are counted by role_id on users plus role_id on invites with accepted_at NULL, with no GetAll scan
Scenario Outline: Deleting and moving roles that hold invites
Given the custom role "Temp" has no user and one open invite (<state>)
When user 5 sends DELETE /v1/role/<Temp id>
Then the answer is 409 role_has_members and nothing is deleted
Examples:
| state |
| pending |
| expired |
And after POST /v1/role/<Temp id>/reassign {"target_role_id": <Intern id>} the answer is {"moved": 1}, the invite's role is Intern and DELETE answers 204
And moved counts users and invites together, so Contributor reassigned to Intern answers {"moved": 6}
And the reassign event params moved is that total
And an accepted invite that still names the role does not block the delete
Scenario: Transferring ownership
Given user 3 holds the Owner role and user 5 is a verified Admin with email "jonas@renew.example"
When user 3 sends POST /v1/site/transfer-ownership with {"user_id": 5, "confirm_email": "jonas@renew.example"}
Then the status is 200 and the body is {"owner": {"id": 5, "name": ...}, "former_owner": {"id": 3, "name": ..., "role": the Admin RoleRef}}
And user 5 holds the Owner role and user 3 holds the Admin role, in one transaction
And both users keep their token_version, cookies and API keys
And user 3's next request is decided by the Admin grants and user 5's by the Owner grants
And GET /v1/role shows Owner members 1 and Admin members 3 (jonas left Admin, marta joined it)
And a second POST by user 3 answers 403 missing_permission with X-Missing-Permission system.transfer_ownership
Scenario Outline: Transfer refusals
When user 3 sends POST /v1/site/transfer-ownership with <body>
Then the result is <result>
Examples:
| body | result |
| {} | 400 (validate tag required) |
| {"user_id": 5} | 400 (validate tag required on confirm_email) |
| {"user_id": 999, "confirm_email": "x@renew.example"} | 400 invalid_transfer |
| {"user_id": 3, "confirm_email": <email of 3>} | 400 invalid_transfer (yourself) |
| {"user_id": 12, "confirm_email": <email of 12>} | 400 invalid_transfer (unverified) |
| {"user_id": 5, "confirm_email": "JONAS@renew.example"} | 400 confirmation_mismatch (exact match) |
| {"user_id": 5, "confirm_email": ""} | 400 (validate tag required) |
And a refused call changes no role_id and records no event
And an invite id is never a valid target, only members can be chosen
Scenario: Two simultaneous transfers give one new owner
Given user 3 sends a transfer to user 5 and a transfer to user 10 at the same time
Then exactly one answers 200 and the other 409 user_role_changed
And exactly one verified user holds the Owner role afterwards
And the repository TransferOwnership(from, to, ownerRoleID, formerRoleID) updates both rows in one transaction and fails the whole transaction when from no longer holds the Owner role
Scenario: The list of users and invites shows roles
When user 5 sends GET /v1/user
Then user items have exactly the keys id, kind, username, display_name, email, role, status, created_at, and role is a RoleRef
And invite items have exactly the keys id, kind, email, role, status, created_at, and role is the invite's RoleRef
And user 3's role is {"id": <Owner id>, "name": "Owner", "built_in": true, "locked": true}
And the roles are read with one role List call, not one per row
And a user whose role cannot be resolved shows role null
And no item carries password, password_reset_code, verification_code, verification_expiration, password_reset_expiration, token_version, code, hashed_code, invited_by or accepted_at
Scenario: The public user shape has no role string
Then PublicUser is exactly id, username, email, is_verified, created_at, updated_at and display_name, in every response that includes a user
And GET /v1/auth/me keeps access.role as its only role field, and holds the keys id, username, email, is_verified, created_at, updated_at, display_name, author and access
And POST /v1/auth/login, /verify, /register and /invite/accept carry no role key
Scenario: The legacy role string is gone from the code
Then the type user.Role, the constants user.RoleOwner and user.RoleAdmin and the field user.User.Role do not exist
And resolveRole(role_id) is the only resolution: role_id 0 gives role.ErrRoleNotFound, and legacyBuiltInNames does not exist
And CheckPermission gives an empty GrantSet for role.ErrRoleNotFound, as before, and GetMyAccess returns the error
And ListUsersAndInvites, memberCounts, CreateUser, AcceptInvite, the GORM User model, the user mapper and the user repository read and write no role string
And userRepository.Create writes role_id, with NULL when the domain role_id is 0
And the builtin "user" content type keeps a field "role", now a text field that is not required and has no options
Scenario: Registration creates the Owner
Given the user repository is empty
When the client sends POST /v1/auth/register with a valid body
Then the stored user has the role_id of the built-in Owner role and is unverified, as before
And when the Owner role is not seeded the answer is 500 "Internal server error" and no user is created
Scenario: A user without a usable role is denied, never promoted
Given user 13 has role_id 0 and user 14 has role_id 99 (no such role)
Then a gated route answers 403 missing_permission for both
And GET /v1/auth/me answers 500 "Internal server error" for both
And neither is counted in any role's members and both show role null in GET /v1/user
Scenario Outline: The migration gives every user and every invite a role_id
Given a database that predates this slice, with a users.role column holding <legacy> and no role_id
When Migrate runs
Then the user has the role_id of <role>
And running Migrate again changes nothing
And a user created after the upgrade with no role_id is never promoted by a later Migrate, and neither is an invite without a role_id
And the legacy steps only touch rows whose legacy users.role value still decides them, and are skipped when the column is absent
Examples:
| legacy | role |
| "owner" (lowest id of the owners) | Owner |
| "owner" (any other id) | Admin |
| "admin" | Admin |
| "" (lowest id) | Owner |
| "" (any other id) | Admin |
| "something-else" | Writer |
And a user that already has a role_id keeps it, even when the legacy string disagrees
And every existing invite without a role_id gets the Admin role, accepted ones included
And the backfill of created_by attributes orphans to the lowest user id holding the Owner role (no longer "role = 'owner'")
And a fresh database never has a users.role column, and the legacy steps are skipped when the column is absent
And a database that still has the column keeps it, untouched and unused (never dropped by this slice)
And the steps run in this order: AutoMigrate, seed built-in roles, legacy role backfill, invite role backfill, created_by backfill, the rest
Scenario: The member counts are one bulk query
Then role.Repository gains MemberCounts() (map[uint32]int, error): users grouped by role_id plus invites with accepted_at NULL grouped by role_id, two statements for any number of roles
And roleViewer.views and DeleteRole use it, and no role use case calls user.Repository.GetAll
And Delete re-counts users and open invites inside its transaction and returns role.ErrRoleHasMembers
And ReassignMembers(from, to) moves users and open invites in one transaction and returns their sum
And the persistence repository keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL
Scenario: Activity events for role assignment and ownership
Given the audit recorder is enabled
When user 5 moves user 41 from Contributor to Writer
Then one user.change_role event is recorded in category users, outcome success, actor user 5, target {"type": "user", "id": "41", "name": the username}
And its changes hold {"field": "role", "kind": "text", "before": "Contributor", "after": "Writer"} and its params from and to hold the two role ids
When user 3 transfers the Owner role to user 5
Then one user.transfer_ownership event is recorded in category users, security true, actor user 3, target user 5, params from 3 and to 5
When user 5 creates an invite for Contributor and the invitee accepts it
Then invite.create and invite.accept carry the params role_id and role (the role name), and invite.renew is unchanged
And a refusal (403, 404, 400, 409) records nothing, a no-op records nothing
And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure
And audit gains ActionUserChangeRole "user.change_role", ActionUserTransferOwnership "user.transfer_ownership", FieldRole "role", ParamRole "role" and ParamRoleID "role_id"
And GET /v1/activity?category=users returns the new events to a caller whose activity.view is all
Scenario: Layering holds
Then internal/domain/user and internal/domain/invite import the stdlib and other domain packages only
And the use cases import no persistence, mailer, models, gorm, net/http or api package
And api/router.go uses role.KeyUsersChangeRole and role.KeySystemTransferOwnership, and no string equal to a catalogue key
And no handler imports persistence, mailer, models or gorm
And the handlers validate with validate tags and validator.Validate.Struct, never by hand
Scenario: Out of scope here
Then there is no SPA change (slices 5-7), and the SPA that reads a role string is adapted there
And there is no DELETE /v1/user/{id}, "Remove user" has no route yet
And the physical users.role column is not dropped
And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the two new routes are only added to its exemption list)