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.: 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 "" is "" When Allows("", owned=) is evaluated Then the result is 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": , "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 "", role_id 0, valid jwt cookie When the client sends GET /v1/auth/me Then access.role.name is "" 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 has legacy role "" When the client calls with a valid session for that user Then the status is 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 |