feat: roles system backend
This commit is contained in:
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.
|
||||
Reference in new issue
Block a user