feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
1 parent
094df63a7f
commit
80f7037d3a
124 files changed
+4644
-824
No files matched your search
@@ -1,4 +1,5 @@
|
||||
Feature: Every real action of the back-office is recorded in the activity log from inside its use case
|
||||
Superseded in part by roles_permissions_users_api: user.change_role and user.transfer_ownership (security) join the users category, and invite.create and invite.accept carry the params role_id and role (the role name).
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want articles, media, users and invites, and system tasks to leave an activity event
|
||||
|
||||
@@ -66,3 +66,8 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
|
||||
42. Real update helper on Linux systemd (or the Docker QA flow): check, download until `ready`, restart; one event each, real asset names absent. Skip in a container.
|
||||
43. Leave the scheduled purge due and wait for the hourly tick: a single `system.media_purge` event, source `system`, no ip or user agent.
|
||||
44. Remove the database file's write access during one article edit: edit result unchanged, process log names the audit write failure.
|
||||
|
||||
## Amendment from roles_permissions_users_api
|
||||
|
||||
- `user.change_role` and `user.transfer_ownership` (security true) are recorded in the `users` category by `ChangeUserRoleUseCase` and `TransferOwnershipUseCase`. A refusal and a no-op record nothing; a storage failure at the write records a failed event.
|
||||
- `invite.create` and `invite.accept` carry the params `role_id` and `role` (the role name). `invite.renew` is unchanged. A refused role (unknown, locked, above the caller) on create or renew records nothing.
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: A logged-in user fetches their own public profile without triggering the verified-account gate
|
||||
Superseded in part by roles_permissions_users_api: GET /v1/auth/me has no top-level role key; the role is access.role only. The public user has seven fields: id, username, email, is_verified, created_at, updated_at and display_name.
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the SPA to ask "who is currently logged in" via a single endpoint that answers from the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Feature: A verified user can invite another person by email, and the users list shows users and open invites
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
Superseded in part by roles_permissions_users_api: the invite body gains an optional role_id (absent or 0 means the Writer role), the invite response and the invite items of the users list carry role (a RoleRef, keys {id, name, built_in, locked}), the key lists of those shapes gain role, and the inviter must cover the role (403 escalation, role_locked, invalid_role). A user created by accepting an invite takes the role of the invite instead of Admin.
|
||||
|
||||
As a member of the shared RenewCMS workspace
|
||||
I want to invite someone by email and see who has an account or a pending invite
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Feature: The owner can cancel a pending invite and renew a pending or expired one
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
Superseded in part by roles_permissions_users_api: invite items and the renew response carry role (a RoleRef), a renewed invite keeps its role, and a renew is refused with 403 escalation when the invite role is above the caller grants (cancelling needs no coverage). The key lists of those shapes gain role.
|
||||
|
||||
As the owner of the shared RenewCMS workspace
|
||||
I want to cancel an invite I no longer want, or renew one that is stale or expired
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates
|
||||
Superseded in part by roles_permissions_users_api: a role is resolved by role_id only. The lines about the legacy resolution ("else the built-in named by the legacy string", "no role_id and a legacy string other than owner or admin") are replaced by: role_id 0 or an unknown role_id is an unresolvable role, refused as missing_permission.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want every owner and admin gate replaced by a check of the caller's role permissions
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
Superseded in part by roles_permissions_users_api: the legacy users.role string is no longer read. The scenarios about the fallback to the built-in role named by the legacy string, "users gain a nullable role_id; the legacy string is unchanged" and the BackfillUserRoleIDs backfills are replaced by the migration of roles_permissions_users_api (legacy backfill while the column exists, invites to Admin) and by the rule that role_id 0 or an unknown role_id is denied, never promoted.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity
|
||||
Superseded in part by roles_permissions_users_api: a user effective role is role_id only (user 10 is an Admin with a role_id, not a legacy admin), members count users plus open invites, and "Out of scope here" is now done: PUT /v1/user/{id}/role, invites with a role, pending-invite counting, role_has_members for invites and the ownership transfer exist.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want to manage roles over /v1/role with the checks the design asks for
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
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)
|
||||
@@ -0,0 +1,129 @@
|
||||
# QA procedure: roles_permissions_users_api
|
||||
|
||||
Package: `test` (black-box). New fakes need unique names, for example `usersAPIRoleRepo`, `usersAPIUserRepo`, `usersAPIInviteRepo`, `usersAPIRecorder`.
|
||||
Reuse `mintCookie`, `assertGate`, `assertRefusal`, `assertEscalation`, the API key minting helper, `grantsOf`, `openMigratedRolesDB`
|
||||
and the fakes of `test/role_support_test.go`, `test/roles_api_world_test.go` and `test/roles_content_scope_world_test.go`.
|
||||
Do not copy any scope-rank comparison into tests: call `GrantSet.Covers` and the exported helpers.
|
||||
No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Deferred to later slices
|
||||
|
||||
- 5-7: SPA (Users: role badges, Change role dialog, role picker in the invite dialog, Transfer ownership dialogs, profile role card, role-aware shell).
|
||||
The SPA reacts to the status, the body code and the X-Missing-Permission / X-Escalation-Key headers.
|
||||
- A user-removal route, which must refuse the Owner.
|
||||
- Dropping the physical users.role column (a later release).
|
||||
|
||||
## Design notes for the user
|
||||
|
||||
- docs/design names POST /v1/invite and GET /v1/me. The code routes are POST /v1/user/invite and GET /v1/auth/me and stay. POST /v1/site/transfer-ownership is the design's route.
|
||||
- docs/design names the events user.role_changed and site.ownership_transferred. The code convention is user.change_role and user.transfer_ownership (category users).
|
||||
- The design copy for "Owner can't be changed or removed" and "There is one owner" maps onto the existing 403 role_locked. The copy of the change-role dialog should key on that code.
|
||||
- New codes with no design copy: invalid_role, user_role_changed, invalid_transfer, confirmation_mismatch.
|
||||
- The design shows "Assigned by Marta Alves" on the profile role card. This slice stores no assigner. It is a design question for slice 7.
|
||||
- Agents never edit docs/design: these items are for you to relay to Claude Design.
|
||||
|
||||
## Inventory (route, key, session only, checks after the gate, Owner / Admin / Writer)
|
||||
|
||||
| Route | Key | Session only | After the gate | Owner / Admin / Writer |
|
||||
|---|---|---|---|---|
|
||||
| PUT /v1/user/{id}/role | users.change_role | yes | id, body, user 404, Locked current, role 400, Locked new, own account, covers current, covers new, no-op, CAS write | ok / ok / 403 |
|
||||
| POST /v1/user/invite | users.invite | no | body, role (default Writer) 400, Locked, covers, email has account, pending, create, mail | ok / ok / 403 |
|
||||
| POST /v1/user/invite/{id}/renew | users.invite | no | id, 404, 409, covers the invite's role, renew, mail | ok / ok / 403 |
|
||||
| DELETE /v1/user/invite/{id} | users.invite | no | id, 404, delete | ok / ok / 403 |
|
||||
| GET /v1/user | users.view | no | none, roles read once | ok / ok / 403 |
|
||||
| POST /v1/site/transfer-ownership | system.transfer_ownership | yes | body, target 400, confirm_email 400, CAS transaction | ok / 403 / 403 |
|
||||
| POST /v1/auth/invite/accept | public | no | invite's role read at accept | as before |
|
||||
| POST /v1/auth/register | public | no | creates the Owner (role_id) | as before |
|
||||
| GET /v1/auth/me | none | as before | no top-level role; access.role only | as before |
|
||||
| GET /v1/role, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign | as before | as before | members include open invites; reassign moves invites | as before |
|
||||
|
||||
## Test infrastructure first
|
||||
|
||||
1. Role fake (extends `mockRoleRepo`): adds `MemberCounts` (reads the shared user and invite fakes), `Delete` and `ReassignMembers` that count open invites, call counters for `List`, `Get`, `GetByName`, `MemberCounts`. Remove nothing else.
|
||||
2. User fake: `GetAll`, `Create` (stores role_id), `UpdateRole(id, from, to)` compare-and-swap (`user.ErrRoleChanged`, `user.ErrUserNotFound`, `role.ErrRoleNotFound` when the new role is gone), `TransferOwnership(from, to, ownerRoleID, formerRoleID)` in one step, a hook between read and write for race tests, a switchable failure per method. Users 3, 5, 9, 10, 12 (unverified), 13 (role_id 0), 14 (role_id 99), 31, 32, 33, 41-46.
|
||||
3. Invite fake: carries `RoleID`, `GetAll`, `Create`, `Renew`, `AcceptWithUser` (creates the user with the invite's role_id), invites 1-4 of the Background.
|
||||
4. Content type fake with "events" and "venues", a recording `audit.Recorder`, a mail fake that records template, recipient and data.
|
||||
5. HTTP harness: `validator.InitValidator()`, a fresh `chi.NewRouter()` mirroring `NewUserRouter`, the transfer route of `NewSiteRouter` and `NewAuthRouter` in api/router.go (Verifier, apiKeyAuth equivalent, AuditContext, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on session-only routes, `RequirePermission(...)` with the key constants), over real use cases and the fakes.
|
||||
6. Helper `userItem(t, rec)` and `roleRefOf(t, body)` to assert shapes, and `seededUser(id, builtInName)` so fixtures set `RoleID` instead of a string.
|
||||
|
||||
## A. Domain (no fakes)
|
||||
|
||||
7. `user.User` has no `Role` field and `user.Role`, `RoleOwner`, `RoleAdmin` do not exist (hygiene test over the source tree). `invite.Invite` carries `RoleID`.
|
||||
8. New sentinels exist and map with `errors.Is`: `user.ErrRoleChanged`, `user.ErrInvalidTransfer`, `invite.ErrInvalidRole` (or `role.ErrUnknownRole`), `user.ErrConfirmationMismatch`. Escalation is read with `errors.As` on `role.EscalationError`.
|
||||
9. `internal/domain/user` and `internal/domain/invite` import only the stdlib and domain packages. `audit` constants `ActionUserChangeRole`, `ActionUserTransferOwnership`, `FieldRole`, `ParamRole`, `ParamRoleID` exist.
|
||||
10. `GrantSet.Covers` rows used by this slice (People manager against Writer, Content lead, Admin, Intern, Contributor): first violation key `content.update` for the first three, none for Intern and Contributor.
|
||||
|
||||
## B. Use cases (fakes only)
|
||||
|
||||
11. `ChangeUserRole`: every row of the "Changing a user's role" Outline with `errors.Is` / `errors.As`. Assert the order with counters: for a refusal before the write, `UpdateRole` is never called. The no-op row: no write, no event. Race: the hook changes the target's role between read and write, expect `user.ErrRoleChanged`. Role deleted between lookup and write: `role.ErrRoleNotFound` maps to invalid_role. Events: change_role has changes role before/after names and params from/to; refusals and no-ops record nothing; a repository failure records a failed event.
|
||||
12. `TransferOwnership`: happy path (former owner gets the Admin role found by `builtInRole`, new owner gets the Owner role id, event with security true), all refusals of the Outline, a CAS failure, a repository failure (failed event, nobody changed), exactly one Owner afterwards.
|
||||
13. `CreateInvite`: every row of the "Inviting with a role" Outline. A refused call does not delete an expired invite and does not send a mail. The default role is the built-in Writer found by `builtInRole`. The stored invite has `RoleID`. invite.create params role_id and role. Existing failure paths (email has account, pending, mail failure rolls back) unchanged.
|
||||
14. `RenewInvite`: keeps the role, covers check after 404 and 409, no mail on a refusal. `CancelInvite`: no coverage check.
|
||||
15. `AcceptInvite`: creates the user with the invite's `RoleID` and no legacy string. `RoleID` 0 or unknown role gives `invite.ErrInviteInvalid`, no user, failed event. Grants edited before the accept are what the user gets.
|
||||
16. `CreateUser`: registration stores the Owner `RoleID`. Unseeded Owner role: error and no user.
|
||||
17. `ListUsersAndInvites`: role per row from one `List` call (counter), `null` for an unresolvable user, invite rows carry their role.
|
||||
18. `GetMyAccess`, `CheckPermission`, `resolveRole`: `RoleID` 0 gives `ErrRoleNotFound`, empty GrantSet in `CheckPermission`, an error in `GetMyAccess`. No legacy fallback rows remain.
|
||||
19. `ListRoles` / `GetRole` / `DeleteRole` / `UpdateRole`: members come from `MemberCounts` (counter 1, `GetAll` counter 0), include open invites, exclude accepted ones. `DeleteRole` is blocked by a pending or an expired invite. `ReassignRoleMembers` returns users plus invites and records `moved` with the total.
|
||||
20. Activity: the new actions and params validate through `audit.Event.Validate()`.
|
||||
|
||||
## C. HTTP
|
||||
|
||||
21. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key on session-only routes gets `apperr.SessionRequiredErrorMessage` before the gate. Live-role scenario with the same cookie.
|
||||
22. Each Outline of the feature: change role, invite with a role, renew and cancel, accept, transfer refusals. Assert status, exact body text, headers (`X-Missing-Permission`, `X-Escalation-Key`), the stores after the call and the recorder.
|
||||
23. Order: for a Writer, `PUT /v1/user/abc/role` and the transfer with no body are 403 before 400. For user 31 an unknown `role_id` is 400 before an escalation 403 on a higher role, and a Locked current role is 403 before an unknown `role_id`. For the invite, a refused role leaves expired invite 2 in place.
|
||||
24. Shapes: PUT returns the user item (RoleRef), invite create returns role, transfer returns owner and former_owner, `GET /v1/user` keys exactly as in the feature, `PublicUser` has 7 keys in login, verify, register, accept and `/auth/me`, with `access.role` intact.
|
||||
25. Live effect: after PUT, the target's next request on a gated route, and its API key, see the new grants with the same cookie. After the transfer, user 3 is refused `system.transfer_ownership` and holds the Admin grants; user 5's key holds the Owner grants.
|
||||
26. Concurrency: the race scenarios with a hook (one 200, one 409 user_role_changed).
|
||||
27. Loads per request: a gated request loads the user at most once and the role once, `MemberCounts` 0 times on the permission path.
|
||||
28. Regression: the foundation, enforcement, content-scope and roles-api suites stay green after fixtures move to `RoleID`.
|
||||
|
||||
## D. Persistence and migration (`openMigratedRolesDB`)
|
||||
|
||||
29. `UserRepository`: `Create` writes `role_id` (NULL for 0), the mapper has no role string; `UpdateRole` is a compare-and-swap (two calls with the same `from`: one wins) and returns `ErrRoleChanged` / `ErrUserNotFound` / `role.ErrRoleNotFound`; `TransferOwnership` updates both rows in one transaction, rolls both back when `from` is no longer the owner or `to` is unverified or missing, and two concurrent transfers give one winner; failure paths (DB closed).
|
||||
30. `InviteRepository`: `role_id` column on `invites` after `Migrate`, `Create` / `Renew` keep it, `AcceptWithUser` stores the user's `role_id` and marks the invite accepted in one transaction.
|
||||
31. `RoleRepository.MemberCounts`: users (verified or not), pending and expired invites count, accepted invites do not, NULL role_id users do not; a role with none is absent or 0; at most 2 SQL statements for any number of roles (count with a GORM callback). `Delete` refuses with `ErrRoleHasMembers` for a user or an open invite and succeeds for an accepted one. `ReassignMembers` moves users and open invites and returns the sum.
|
||||
32. Migration: build a legacy database with `ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''` plus rows for each Outline row, run `Migrate`: role_ids as in the feature, invites get Admin, `BackfillCreatedBy` finds the owner by role_id, a second `Migrate` is a no-op, the column is still there. A fresh database has no `users.role` column and `Migrate` succeeds. `Migrate` with no users succeeds.
|
||||
33. `sqlite_schema_unchanged_test.go`: the expected schema gains `invites.role_id` and loses `users.role` on a fresh database.
|
||||
|
||||
## Existing tests that must change (recheck with `go vet -tags nofrontend ./test/...`)
|
||||
|
||||
| File (cluster) | Change |
|
||||
|---|---|
|
||||
| Every fixture that builds `user.User{Role: user.RoleOwner|RoleAdmin}` or `"owner"`/`"admin"`: 44 files, about 217 occurrences, for example `user_roles_test.go`, `invitations_test.go`, `invite_management_test.go`, `get_current_user_http_test.go`, `api_keys_*_test.go`, `media_purge_http_test.go`, `update_http_test.go`, `activity_log_backend_http_test.go`, `session_revocation_http_test.go`, `custom_content_types_support*_test.go`, `image_variants_env_test.go`, `author_support_test.go`, `seo_data_support_test.go`, `mysql_server_test.go`, `postgres_server_test.go` | Set `RoleID` to the seeded id through `seededUser`. Delete the `Role` field assignments. Assertions on a `role` string become assertions on `RoleRef` or are removed |
|
||||
| `roles_permissions_foundation_test.go`, `roles_permissions_foundation_http_test.go` | Delete the "falls back to the built-in role named by the legacy role" rows and `BackfillUserRoleIDs` scenarios. Replace with the role_id 0 and role_id 99 denial rows and the new migration tests |
|
||||
| `roles_permissions_enforcement*_test.go` (legacy rows, "no role_id and a legacy string other than owner or admin") | Replace with the role_id 0 rows |
|
||||
| `roles_api_world_test.go`, `roles_api_usecases_test.go`, `roles_api_http_test.go`, `roles_api_http_write_test.go`, `roles_api_persistence_test.go`, `roles_api_hardening_test.go`, `roles_api_domain_test.go` | User 10 (role_id 0, legacy admin) gets `RoleID` Admin; Admin members stay correct; add `MemberCounts`, invites in `members`, and `GetAll` counter 0 |
|
||||
| `role_support_test.go`, `roles_content_scope_world_test.go`, `roles_content_scope_support_test.go`, `role_repository_gaps_test.go`, `role_hardening_test.go` | `mockRoleRepo` gains `MemberCounts`; `Delete` and `ReassignMembers` include open invites; the user fake gains `UpdateRole` and `TransferOwnership`; the invite fake gains `RoleID` |
|
||||
| `user_response_field_hygiene` tests (and any `publicuser` shape test) | Eight keys become seven (no `role`) |
|
||||
| `user_display_name_test.go`, `get_current_user_http_test.go` | `/auth/me` top-level `role` assertions removed; `access.role` kept |
|
||||
| `activity_usecases_test.go`, `activity_domain_test.go` | New actions, params and field |
|
||||
| tests of the builtin `user` type (`schema_builtin_view` tests, `builtin_type` tests) | The `role` field is text, not required, no options |
|
||||
| `sqlite_schema_unchanged_test.go` | See item 33 |
|
||||
| `api_catalogue_drift_test.go:52` | Add `PUT /v1/user/{id}/role` and `POST /v1/site/transfer-ownership` to `routesNotYetInTheCatalogue`. The pre-existing failure of `TestRouterRoutesAreInTheCatalogue` is not this slice's: report, do not fix |
|
||||
| `web/src` Vitest specs and kits that mock `role: 'owner'` | Untouched here (the SPA is slices 5-7). They stay green because `/v1` is mocked. Report them as "not run against the new API" |
|
||||
|
||||
## Existing features and docs to amend
|
||||
|
||||
- features/user_roles.feature: "role is exactly owner or admin", "AcceptInvite creates role admin", "/auth/me returns role", "users list role", "no endpoint transfers a role": add "superseded by roles_permissions_users_api" notes in place.
|
||||
- features/invitations.feature and features/invite_management.feature: invite body, item and response keys gain `role`; the item key lists.
|
||||
- features/get_current_user.feature and features/user_response_field_hygiene.feature: no `role` key; seven public fields.
|
||||
- features/roles_permissions_foundation.feature: the legacy fallback scenarios, "users gain a nullable role_id; the legacy string is unchanged", the backfill scenarios.
|
||||
- features/roles_permissions_enforcement.feature: lines on the legacy resolution and the unresolvable role.
|
||||
- features/roles_permissions_roles_api.feature: the Background line "else the built-in named by the legacy owner/admin string", user 10 as legacy admin, and "Out of scope here" (pending-invite counting and role_has_members for invites: now done).
|
||||
- features/schema_builtin_view.feature: the builtin user type's role field.
|
||||
- features/activity-log-producers.feature and .qa.md: the two actions and the invite params.
|
||||
- features/users_screen.feature and features/users_row_actions.feature (SPA): note that the `role` string they read becomes a RoleRef, to be handled in slices 5-7.
|
||||
- docs/api.md: Users and invites table, new routes, shapes, codes, headers, activity, members of a role, `/auth/me` and the seven public fields. docs/architecture.md: repository methods. web/src/data/apiCatalogue.ts and the generated llms docs (`task build:llms`): the two new routes and the changed shapes.
|
||||
- docs/configuration.md: no new env var.
|
||||
- docs/design: for you to relay, not edited by agents (see Design notes).
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Copy a real database from a pre-slice-4 build, run `task dev:api` on the copy: every user has a role_id, every invite an Admin role_id, the `users.role` column is still there, the activity log and created_by are intact.
|
||||
- As the Owner: invite with the default (Writer) and with Contributor, accept in a real browser from the mailed link, check the new user's role and `/auth/me`. Real SMTP delivery is a manual item.
|
||||
- As an Admin: change a Contributor to Writer and then to Admin, try Owner (role_locked) and your own role (own_role). As a People manager: try a Writer (escalation with the key in X-Escalation-Key).
|
||||
- Transfer ownership between two real sessions: the old owner's tab keeps working as Admin without re-login, the new owner's tab gains Owner permissions, the old owner's API key is an Admin key.
|
||||
- Delete a role that has only an expired invite (409), move members, delete (204).
|
||||
- Run the migration on MySQL/MariaDB and PostgreSQL with Docker (the Docker QA steps run for real here): the legacy backfill, the invite `role_id`, the CAS update and the transfer transaction behave like SQLite.
|
||||
- Known risk to check by hand: starting the previous binary on an upgraded database is not supported (post-upgrade users have `users.role` empty).
|
||||
- No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET, SMTP_HOST).
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Read-only view of a built-in content type
|
||||
Superseded in part by roles_permissions_users_api: the builtin user type role field is a plain optional text field with no options (the role is served as a RoleRef by the users API).
|
||||
As an owner or admin
|
||||
I want to open a built-in type from the Schema list and read its fields
|
||||
So that I can see the rules RenewCMS applies without being able to change them
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: API responses never expose a user's secret fields
|
||||
Superseded in part by roles_permissions_users_api: the public user has seven fields (id, username, email, is_verified, created_at, updated_at, display_name); the role key is gone from every response that includes a user.
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want every HTTP response that includes user data to carry only public fields, never a
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Feature: Users have a role, owner or admin, and only the owner can invite
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
Superseded in part by roles_permissions_users_api: a user has one stored role, role_id, and the user.Role type with the "owner" and "admin" strings no longer exists. AcceptInvite creates the user with the role of the invite (Writer by default, set by the inviter), not Admin. GET /v1/auth/me carries no top-level role (access.role only), the users list shows role as a RoleRef {id, name, built_in, locked} or null, and PUT /v1/user/{id}/role and POST /v1/site/transfer-ownership now exist, so "no endpoint transfers a role" no longer holds.
|
||||
|
||||
As the person who registered the workspace
|
||||
I want my account to be the owner and everyone I invite to be an admin
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Users row actions: Cancel invite and Renew invite
|
||||
Note from roles_permissions_users_api: invite and user items carry role as a RoleRef, and renewing an invite above the caller grants is 403 escalation. The screen is adapted in the SPA slices 5-7.
|
||||
|
||||
As the owner of a RenewCMS workspace
|
||||
I want to cancel a pending invite and renew an expired one from the Users list
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Users screen and InviteUserModal
|
||||
Note from roles_permissions_users_api: the role the users list returns is now a RoleRef {id, name, built_in, locked} (or null), not a string. The screen is adapted in the SPA slices 5-7.
|
||||
|
||||
As a verified RenewCMS user in the shared workspace
|
||||
I want /users to list everyone who has an account or an open invite, and let me invite someone
|
||||
|
||||
Reference in new issue
Block a user