Files
RenewCMS/features/roles_permissions_foundation.feature
T

295 lines
17 KiB
Gherkin

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.
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 (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits)
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 (the read side is implemented by roles_permissions_content_scope; writing them stays in 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 (superseded by roles_permissions_content_scope: the same rows are decided by GrantSet.Permits)
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 (superseded by roles_permissions_content_scope: decided by GrantSet.Permits)
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 (superseded by roles_permissions_content_scope: decided by GrantSet.Permits)
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 | allowed (not 403) |
| 5 | admin | POST /v1/content-type | allowed (not 403) |
| 5 | admin | PATCH /v1/settings | 403 apperr.MissingPermissionErrorMessage |
| 5 | admin | POST /v1/system/update/check | 403 apperr.MissingPermissionErrorMessage |