feat: roles system backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 05:02:49 +01:00
1 parent 44cca46d5e
commit 8c6c6456da
50 files changed
+2578 -22

No files matched your search

@@ -0,0 +1,293 @@
Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints
As the owner of a RenewCMS site
I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope)
So that later slices can replace the fixed owner/admin checks without changing what existing
owners and admins can do today
Background:
Given the domain package internal/domain/role holds, with the stdlib only:
- the catalogue: a fixed ordered list of 34 permission keys in 9 groups
- Scope with values none, own and all
- Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants}
- the sentinels ErrRoleNotFound and ErrUnknownPermission
- the Repository interface (list, get by id, get by name, list grants, create, save)
- the pure function Role.Allows(key, owned bool) bool
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
| group | key | kind | owner-only |
| content | content.read | S | no |
| content | content.create | B | no |
| content | content.update | S | no |
| content | content.delete | S | no |
| content | content.publish | S | no |
| schema | schema.view | B | no |
| schema | schema.edit | B | no |
| schema | schema.delete | B | no |
| media | media.view | B | no |
| media | media.upload | B | no |
| media | media.delete | S | no |
| authors | authors.view | B | no |
| authors | authors.create | B | no |
| authors | authors.edit | S | no |
| authors | authors.delete | B | no |
| activity | activity.view | S | no |
| activity | activity.export | B | no |
| users | users.view | B | no |
| users | users.invite | B | no |
| users | users.change_role | B | no |
| users | users.remove | B | no |
| settings | settings.view | B | no |
| settings | settings.edit_general | B | no |
| settings | settings.manage_sites | B | no |
| settings | settings.delete_site | B | yes |
| settings | apikeys.view | S | no |
| settings | apikeys.create | B | no |
| settings | apikeys.revoke | S | no |
| roles | roles.view | B | no |
| roles | roles.manage | B | no |
| roles | roles.edit_owner_only | B | yes |
| system | system.apply_updates | B | yes |
| system | system.edit_retention | B | yes |
| system | system.transfer_ownership | B | yes |
And content permissions are stored under wildcard keys meaning "all content types"; per-type
overrides of the form content.<action>:<type_key> are deferred to slice 3
And a role stores one row per granted key, with scope own or all. A key with no row is none or off.
An on/off key that is on is stored as scope all.
And three built-in roles are seeded by persistence/database (like SeedDefaultSite), idempotently,
with built_in true and version 1:
| name | locked | granted keys |
| Owner | true | all 34 keys at all |
| Admin | false | the 29 keys that are not owner-only, at all |
| Writer | false | content.read=all, content.create=all, content.update=all, schema.view=all, media.view=all, media.upload=all, authors.view=all, authors.edit=own, activity.view=own |
And Admin keeps roles.view and roles.manage (confirmed by the user)
And users gain a nullable-by-default role_id referencing role. The legacy users.role string, the
domain user.Role constants and every RequireOwner / RequireOwnerOrAdmin check are unchanged
in this slice
And articles gain created_by (user id). Entries already carry created_by
And Migrate registers role and role_permission in AutoMigrate, then runs in this order:
seed built-in roles, the existing BackfillUserRoles, BackfillUserRoleIDs, BackfillCreatedBy
And the use cases are GetMyAccessUseCase and ListPermissionsUseCase. They depend on domain
interfaces only. Handlers receive use cases through constructors
And GET /v1/auth/me keeps its current contract (features/get_current_user.feature) and adds one
top-level field, access. No /v1/me route is added
And GET /v1/permission is mounted in api/router.go (NewPermissionRouter) behind
authtoken.Verifier, container.apiKeyAuth(), authtoken.Authenticator, RequireCurrentToken and
RequireVerifiedUser. It has no role check yet
Scenario Outline: Role.Allows evaluates scope and ownership
Given a role whose grant for "<key>" is "<scope>"
When Allows("<key>", owned=<owned>) is evaluated
Then the result is <allowed>
Examples:
| key | scope | owned | allowed |
| content.update | all | false | true |
| content.update | all | true | true |
| content.update | own | true | true |
| content.update | own | false | false |
| content.update | none | true | false |
| content.create | all | false | true |
| schema.view | none | false | false |
Scenario: A key absent from the role is not allowed
Given a role with no grants
When Allows("content.read", owned=true) is evaluated
Then the result is false
Scenario: An unknown key is rejected, never silently allowed
Given a role with grants for every catalogue key at all
When Allows("content.explode", owned=true) is evaluated
Then the result is false
And looking the key up in the catalogue returns ErrUnknownPermission (errors.Is)
Scenario: The catalogue is stable and complete
Then it has 34 keys in the order and groups of the Background table
And exactly these 5 keys are owner-only: settings.delete_site, roles.edit_owner_only,
system.apply_updates, system.edit_retention, system.transfer_ownership
And content.create is the only content key of kind B
And the scoped keys are content.read, content.update, content.delete, content.publish, media.delete,
authors.edit, activity.view, apikeys.view, apikeys.revoke
Scenario: Seeding an empty database creates the three built-in roles
Given an empty database after AutoMigrate
When the built-in roles are seeded
Then roles "Owner", "Admin" and "Writer" exist with built_in true and version 1
And only Owner has locked true
And Owner has 34 grants, all at scope all
And Admin has 29 grants, all at scope all, and none of the 5 owner-only keys
And Writer has exactly the 9 grants of the Background table
Scenario: Seeding twice changes nothing
Given a database where the built-in roles are already seeded
When the built-in roles are seeded again
Then there are still exactly 3 roles with the same ids and 72 role_permission rows
Scenario: Seeding never overwrites an edited built-in role
Given a seeded database where the Writer role carries version 4 and an extra grant media.delete=own
When the built-in roles are seeded again
Then the Writer role keeps version 4 and the extra grant
Scenario: Seeding never touches a custom role that reuses a built-in name
Given a custom role named "Writer" with built_in false exists before seeding
When the built-in roles are seeded
Then startup is not aborted and the existing row is left unchanged
And the built-in Writer is still created or found by built_in true
Scenario: Existing owner and admin users get the matching built-in role
Given the built-in roles are seeded
And users 3 (legacy role "owner"), 5 (legacy role "admin") and 9 (legacy role "admin") with no role_id
When BackfillUserRoleIDs runs
Then user 3 has role_id of the Owner role
And users 5 and 9 have role_id of the Admin role
And the legacy role string of every user is unchanged
Scenario: The backfill runs after BackfillUserRoles on a database that predates both
Given users 2 and 4 with legacy role "" and no role_id
When Migrate runs
Then user 2 has legacy role "owner" and the Owner role_id
And user 4 has legacy role "admin" and the Admin role_id
Scenario: The role_id backfill is idempotent and respects an existing role_id
Given user 5 has legacy role "admin" and role_id of a custom role "Contributor"
When BackfillUserRoleIDs runs twice
Then user 5 keeps the role_id of "Contributor"
Scenario: A database with no users is left alone
Given a seeded database with no users
When BackfillUserRoleIDs runs
Then no error is returned
Scenario: Existing articles are attributed to the first owner
Given the owner is user 3 and articles 10 and 11 with created_by 0
When BackfillCreatedBy runs
Then articles 10 and 11 have created_by 3
Scenario: Entries without an author are attributed to the first owner
Given the owner is user 3, entry 20 with created_by 0 and entry 21 with created_by 7
When BackfillCreatedBy runs
Then entry 20 has created_by 3 and entry 21 keeps created_by 7
Scenario: The created_by backfill never rewrites an existing value, and is idempotent
Given article 10 with created_by 7
When BackfillCreatedBy runs twice
Then article 10 keeps created_by 7
Scenario: With no owner yet the backfill does nothing
Given articles with created_by 0 and no users
When BackfillCreatedBy runs
Then no error is returned and every created_by is still 0
Scenario: New articles record their creator
Given user 7 creates an article through CreateArticleUseCase
Then the stored article has created_by 7
Scenario: The owner's /me carries the Owner role and all 34 permissions
Given user 7, verified, legacy role "owner", role_id of the Owner role, valid jwt cookie, token_version 0
When the client sends GET /v1/auth/me with that cookie
Then the response status is 200 and Cache-Control is "no-store"
And the body keeps id, username, email, is_verified, display_name, role "owner" and author
And access.role is {"id": <Owner id>, "name": "Owner", "built_in": true, "locked": true}
And access.permissions lists the 34 catalogue keys in catalogue order, each as {"key", "scope": "all"}
Scenario: An admin's /me omits the owner-only keys
Given user 8, verified, legacy role "admin", role_id of the Admin role, valid jwt cookie
When the client sends GET /v1/auth/me
Then access.role.name is "Admin", built_in true, locked false
And access.permissions has 29 entries, none of them owner-only
And roles.view and roles.manage are present at scope "all"
Scenario: A writer's /me lists only granted keys with their scope
Given user 9, verified, role_id of the Writer role, valid jwt cookie
When the client sends GET /v1/auth/me
Then access.permissions is, in catalogue order:
| key | scope |
| content.read | all |
| content.create | all |
| content.update | all |
| schema.view | all |
| media.view | all |
| media.upload | all |
| authors.view | all |
| authors.edit | own |
| activity.view | own |
Scenario: A user on a custom role sees that role
Given user 10, verified, role_id of a custom role "Contributor" (not built-in, not locked) granting content.read=all and content.update=own
When the client sends GET /v1/auth/me
Then access.role.name is "Contributor", built_in false
And access.permissions is [{"key":"content.read","scope":"all"},{"key":"content.update","scope":"own"}]
Scenario Outline: A user with no role_id falls back to the built-in role named by the legacy role
Given user 11, verified, legacy role "<legacy>", role_id 0, valid jwt cookie
When the client sends GET /v1/auth/me
Then access.role.name is "<name>"
Examples:
| legacy | name |
| owner | Owner |
| admin | Admin |
Scenario: The permissions follow the role live
Given user 9 is on the Writer role, and the Writer role's media.delete grant is then set to own in the repository
When the client sends GET /v1/auth/me again with the same cookie
Then access.permissions now contains {"key":"media.delete","scope":"own"}
Scenario: An unverified user still gets /me, with access
Given user 12, is_verified false, role_id of the Writer role, valid jwt cookie
When the client sends GET /v1/auth/me
Then the response status is 200, is_verified is false and access.role.name is "Writer"
Scenario: A user whose role cannot be resolved gets a server error, not an empty access block
Given user 13, role_id 99 (no such role), legacy role "", valid jwt cookie
When the client sends GET /v1/auth/me
Then the response status is 500 and the body is "Internal server error"
Scenario: The existing /me failures are unchanged
Then no cookie gives 401, a stale token_version gives 401 apperr.UnauthorizedErrorMessage, and a deleted account gives 401 apperr.UnauthorizedErrorMessage, exactly as in features/get_current_user.feature
Scenario: A signed-in verified user reads the catalogue
Given user 9 on the Writer role, verified, valid jwt cookie
When the client sends GET /v1/permission with that cookie
Then the response status is 200
And the body is {"groups":[{"key":"content","items":[...]}, ...]} with the 9 groups in the order content, schema, media, authors, activity, users, settings, roles, system
And each item is {"key": "content.read", "scope_kind": "scoped"|"toggle", "owner_only": bool, "per_type": bool}
And per_type is true only for the 5 content.* items
And scope_kind is "toggle" for on/off keys and "scoped" for none/own/all keys
And the 5 owner-only keys have owner_only true
And the body holds no human-readable names (copy lives in the SPA)
Scenario: The catalogue is the same for every role
Given the Writer and the Owner each request GET /v1/permission
Then both bodies are byte-identical
Scenario: An API key may read the catalogue
Given a valid API key whose creator is verified
When the client sends GET /v1/permission with that key
Then the response status is 200
Scenario: No cookie is refused
When the client sends GET /v1/permission with no jwt cookie
Then the response status is 401
Scenario: A stale token is refused
Given a stored token_version 1 and a cookie encoding 0
When the client sends GET /v1/permission
Then the response status is 401 and the body is apperr.UnauthorizedErrorMessage
Scenario: An unverified user is refused
Given user 12, is_verified false
When the client sends GET /v1/permission
Then the response status is 403 and the body is apperr.VerificationRequiredErrorMessage
Scenario Outline: Existing role checks are untouched in this slice
Given the migration has run and user <id> has legacy role "<legacy>"
When the client calls <call> with a valid session for that user
Then the status is <status> exactly as before the migration
Examples:
| id | legacy | call | status |
| 3 | owner | POST /v1/user/invite | allowed (not 403) |
| 5 | admin | POST /v1/user/invite | 403 apperr.OwnerRequiredErrorMessage |
| 5 | admin | POST /v1/content-type | allowed (not 403) |
| 5 | admin | PATCH /v1/settings | 403 apperr.OwnerRequiredErrorMessage |
| 5 | admin | POST /v1/system/update/check | 403 apperr.OwnerRequiredErrorMessage |
@@ -0,0 +1,57 @@
# QA procedure: roles_permissions_foundation
Package: `test` (black-box). Name new fakes uniquely, for example `mockRoleRepo`, `rolesFoundationUserRepo`.
Do not copy the old `Role ==` checks into new code.
## Deferred to later slices
- 2a: permission-based route and middleware checks, API keys on the creator's live role, admin relaxations, 403 missing_permission, Writer effective.
- 2b: own/all enforcement after loading the item, publish separate from update, Draft and "Ask an editor to publish."
- 3: /v1/role CRUD, reassign, no-escalation, role_locked, role_has_members, role_name_taken, role_conflict, per-type content override keys `content.<action>:<type_key>`, role events.
- 4: PUT /v1/user/{id}/role, invite role (default Writer), transfer ownership, protected owner, removal of the legacy role string.
- 5-7: all SPA states in docs/design/screens/roles.md.
## Design note for the user
The "Owner-only items" state in docs/design/screens/roles.md disables View roles and Manage roles for an admin. This slice follows the user's decision that Admin keeps roles.view and roles.manage (29 of 34 keys), matching the catalogue table. roles.md needs a design fix by the user; no stage edits docs/design.
## Test infrastructure needed first
1. `mockRoleRepo` implementing the role domain `Repository` in memory. Seed it with the three built-ins through the same Go function the migration uses, or assert it matches the Background tables.
2. Extend the user fake with `RoleID`, and the article fake with `CreatedBy`.
3. The persistence tests use an in-memory SQLite DSN (as `TestBackfillUserRoles` in `test/user_roles_test.go`), never a file. Register `models.Role`, `models.RolePermission`, `models.User`, `models.Article` and `models.Entry` via `database.Migrate` where the whole sequence is asserted, and via targeted AutoMigrate for single-backfill tests.
## A. Domain (pure Go, no HTTP, no GORM)
4. Table-test `Role.Allows` with the Examples of "Role.Allows evaluates scope and ownership", plus the absent-key and unknown-key cases. Assert `errors.Is(err, role.ErrUnknownPermission)` for the lookup.
5. Catalogue test: length 34, group order, key order, the 5 owner-only keys, the 9 scoped keys, and `per_type` only for the 5 content keys. A unique-keys check guards against duplicates.
6. A hygiene test that `internal/domain/role` imports only the stdlib (grep the import list, as in the existing `*_hygiene_test.go` files).
## B. Persistence (in-memory SQLite)
7. Seed on an empty DB: assert 3 roles, flags, versions, and the 34 / 29 / 9 grant counts and scopes. Run again: still 3 roles with the same ids and 72 role_permission rows.
8. Edited built-in: bump Writer's `version` to 4 and add `media.delete=own`, seed again, assert untouched.
9. Name collision: pre-insert a custom "Writer" (built_in false), seed, assert no error and the row is unchanged.
10. `BackfillUserRoleIDs`: cases for owner/admin mapping, the legacy string unchanged, an existing `role_id` kept, double run, empty DB.
11. Full `database.Migrate` on a DB created with the old schema (`users.role = ""`, no `role_id`): assert ordering (legacy role first, then role_id).
12. `BackfillCreatedBy`: articles and entries with 0, entries with an existing value, double run, no owner.
13. Portability: assert there is no raw SQL specific to SQLite in the new repositories (code review item); MySQL and PostgreSQL are not exercised here.
## C. Use cases (fakes only)
14. `GetMyAccessUseCase`: owner, admin, writer, custom role, legacy fallback (role_id 0 with "owner" and "admin"), live change after a grant edit, unresolvable role (expect a non-sentinel error).
15. `ListPermissionsUseCase`: returns the domain catalogue unchanged.
16. `CreateArticleUseCase` sets `CreatedBy` to the acting user id.
## D. HTTP (`httptest`, fresh `chi.NewRouter()` mirroring `api/router.go`)
17. Call `validator.InitValidator()`. Use the token helper the current `/me` tests use. Mint cookies as `get_current_user.qa.md` step 7 does.
18. Mount `/v1/auth/me` as in `NewAuthRouter` (Verifier, apiKeyAuth, Authenticator, RequireCurrentToken) with `auth.NewMeHandler(getUser, getMyAuthor, getMyAccess)`. Re-run all `get_current_user` cases unchanged (additive field) and add the access cases: owner, admin, writer, custom, fallback, live change, unverified, unresolvable role (500).
19. Mount `/v1/permission` as in the new `NewPermissionRouter` (adds RequireVerifiedUser). Cases: Writer 200 with the full shape, Owner and Writer bodies equal, API key 200, no cookie 401, stale token 401, unverified 403.
20. "Unchanged behaviour" Outline: build the existing `NewUserRouter`, `NewSettingsRouter`, `NewContentTypeRouter` and `NewSystemRouter` groups over users carrying role_id, and assert the statuses are as before. The simplest source is the existing router tests; add a seeded-role_id variant.
## Manual verification
- Start a dev build (`task dev:api`) on a copy of a database created before this change. Check that the startup migration succeeds, that no data is lost, and that `GET /v1/auth/me` and `GET /v1/permission` respond for the existing owner and admin.
- MySQL and PostgreSQL migration of the new tables is a manual item; the repo has no CI and no automated run for it.
- The SPA is untouched in this slice. SPA states in docs/design/screens/roles.md are informational.