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 sends Then the result is Examples: | caller | call | result | | user 3 Owner | PUT /v1/user/41/role {"role_id": } | 200 | | user 5 Admin | PUT /v1/user/41/role {"role_id": } | 200 | | user 31 People manager | PUT /v1/user/41/role {"role_id": } | 200 | | user 9 Writer | PUT /v1/user/41/role {"role_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": } | 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": } | 200 | | user 5 Admin | POST /v1/site/transfer-ownership {"user_id": 10, "confirm_email": } | 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": } | 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 sends PUT /v1/user//role with {"role_id": } Then the result is 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 sends POST /v1/user/invite with Then the result is 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": } | 201, role Contributor | | user 5 Admin | {"email": "a@renew.example", "role_id": } | 201, role Admin | | user 3 Owner | {"email": "a@renew.example", "role_id": } | 201 | | user 5 Admin | {"email": "a@renew.example", "role_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": } | 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": } | 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": } | 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 sends Then the result is 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 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 And the user is verified and has no legacy role string And GET /v1/auth/me for "kim" shows access.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 () When user 5 sends DELETE /v1/role/ Then the answer is 409 role_has_members and nothing is deleted Examples: | state | | pending | | expired | And after POST /v1/role//reassign {"target_role_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 Then the result is 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": } | 400 invalid_transfer (yourself) | | {"user_id": 12, "confirm_email": } | 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": , "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 and no role_id When Migrate runs Then the user has the role_id of 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)