332 lines
26 KiB
Gherkin
332 lines
26 KiB
Gherkin
Feature: SPA foundation for roles and permissions (slice 5)
|
|
|
|
As a signed-in person with a custom or built-in role
|
|
I want the app to know exactly what my role allows, from the server's answer
|
|
So that every screen, route, control and error speaks in permissions, never in fixed role names
|
|
|
|
Background:
|
|
Given the backend contract of features/roles_permissions_{foundation,enforcement,content_scope,roles_api,users_api}.feature
|
|
And GET /v1/auth/me answers access {role: {id, name, built_in, locked}, permissions: [{key, scope}]} and no top-level role string
|
|
And the 34-key catalogue, scopes none|own|all, typed entries "<key>:<type_key>" (article for articles) that replace the wildcard for that type, a "none" entry denying
|
|
And toggle keys arrive with scope "all"; owner-only keys arrive only for the Owner role
|
|
And a refusal is a plain-text 403 body: missing_permission with X-Missing-Permission (base key), escalation with X-Escalation-Key, or role_locked, own_role, role_built_in, built_in_name, role_conflict (409), session_required ("This action needs a real session.")
|
|
And own means created_by equals the caller's user id; created_by 0 is owned by nobody
|
|
And the ownership data the SPA can see today is: entries carry created_by (GET /v1/content-type/{key}/entry and /{id}); articles did not (Article.CreatedBy was json:"-"); this slice exposes it to signed-in callers, see the backend Rule; media items have no owner and the media delete, merge and purge routes require scope all
|
|
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR copy in i18n/messages.ts, no comments in code
|
|
And the test runner is web/vitest.config.ts (jsdom) run by `task test:web`; it already exists, this slice runs a baseline first
|
|
|
|
Rule: The session carries access, and can() decides
|
|
|
|
Scenario: getMe maps the access object
|
|
Given GET /v1/auth/me answers 200 with access.role {id: 3, name: "Writer", built_in: true, locked: false} and 9 permission entries
|
|
When useApi().getMe() resolves
|
|
Then the session holds role {id 3, name "Writer", built_in true, locked false} and the 9 entries in order
|
|
And the session has no string role field and no isOwner / canManageContent getter exists
|
|
|
|
Scenario Outline: can(key, options) resolves the effective scope
|
|
Given the session grants <grants>
|
|
When can("<key>", <options>) is evaluated
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| grants | key | options | result |
|
|
| content.update=all | content.update | {} | true |
|
|
| content.update=own | content.update | {} | true |
|
|
| content.update=own | content.update | {scope: "all"} | false |
|
|
| content.update=own, session id 7 | content.update | {ownerId: 7} | true |
|
|
| content.update=own, session id 7 | content.update | {ownerId: 8} | false |
|
|
| content.update=own, session id 7 | content.update | {ownerId: 0} | false |
|
|
| content.update=all | content.update | {ownerId: 8} | true |
|
|
| content.update=none | content.update | {} | false |
|
|
| key absent | content.update | {} | false |
|
|
| content.update=own, content.update:events=all | content.update | {typeKey: "events", scope: "all"} | true |
|
|
| content.update=all, content.update:events=none | content.update | {typeKey: "events"} | false |
|
|
| content.update=all, content.update:events=none | content.update | {typeKey: "venues"} | true |
|
|
| content.read=all | content.read | {typeKey: "article"} | true |
|
|
| settings.view=all | content.explode | {} | false |
|
|
| no session | media.view | {} | false |
|
|
|
|
Scenario: scopeOf exposes none, own or all
|
|
Given the session grants content.read=all and content.read:events=own
|
|
Then scopeOf("content.read") is "all"
|
|
And scopeOf("content.read", {typeKey: "events"}) is "own"
|
|
And scopeOf("schema.view") is "none"
|
|
|
|
Scenario: Live refresh after a refused action
|
|
Given the session grants content.delete=all
|
|
And the server now answers 403 missing_permission with X-Missing-Permission "content.delete" and GET /v1/auth/me then lacks content.delete
|
|
When the SPA receives that 403
|
|
Then GET /v1/auth/me is requested once more (not more than once every 5 seconds)
|
|
And can("content.delete") becomes false without a reload
|
|
|
|
Scenario: Live refresh when the tab comes back
|
|
Given the last /v1/auth/me answer is 31 seconds old
|
|
When the document becomes visible
|
|
Then the session is refreshed
|
|
And when the last answer is 10 seconds old, it is not
|
|
|
|
Scenario: A failed refresh keeps the previous grants
|
|
Given GET /v1/auth/me fails with a network error
|
|
Then the session keeps its last grants and shows no error
|
|
|
|
Rule: Every fixed-role check is replaced by a permission
|
|
|
|
Scenario Outline: Permission replaces the role string
|
|
Given the session grants only <grants>
|
|
When <place> is rendered
|
|
Then <effect>
|
|
|
|
Examples:
|
|
| grants | place | effect |
|
|
| users.invite | Users, Invite action | the Invite button is shown |
|
|
| users.view | Users, Invite action | the Invite button is hidden |
|
|
| apikeys.view=all | API keys list | the Created by column is shown |
|
|
| apikeys.view=own | API keys list | the Created by column is hidden |
|
|
| media.delete=all | Media, Duplicates | the purge control is available |
|
|
| media.delete=own | Media, Duplicates | the purge control is hidden (purge routes need scope all) |
|
|
| settings.edit_general | Cache, Variants, Media cleanup | controls are editable |
|
|
| settings.view | Cache, Variants, Media cleanup | read-only with the ReadOnlyBanner |
|
|
| activity.view=all | Home recent activity, Activity page | full view |
|
|
| activity.view=own | Home recent activity, Activity page | own events only |
|
|
| activity.view=none | Home recent activity | the card is not shown |
|
|
| authors.create | Authors, Add author button, MetaSection | shown / inline create enabled |
|
|
| authors.view | Authors | read-only list and detail |
|
|
| system.apply_updates | Settings, Software card, Home banner | Apply and Restart are actionable, cue shown |
|
|
| settings.view | Settings, Software card | the Apply action is disabled with the "Owner only" tag |
|
|
| system.edit_retention | Settings, Retention card | editable |
|
|
| settings.view | Settings, Retention card | visible, locked, "Owner only" tag |
|
|
| settings.manage_sites | Sidebar, Settings children | the Sites child is shown |
|
|
| settings.manage_sites | Sites, Delete | Delete is disabled with the "Owner only" tag |
|
|
| settings.manage_sites, settings.delete_site | Sites, Delete | Delete is enabled |
|
|
|
|
Scenario: Sidebar lists items per permission
|
|
Given the session grants only content.read, media.view, authors.view, activity.view=own
|
|
Then the sidebar shows Home, Articles, Media, Authors, Activity and Integration
|
|
And it shows no Users, no Schema, no Settings
|
|
And the mobile drawer shows the same items
|
|
And the Access / Roles entry is not added in this slice
|
|
|
|
Scenario: The Writer built-in sees the Writer menu
|
|
Given the session is the built-in Writer (9 grants)
|
|
Then the sidebar shows Home, Articles, Media, Authors, Schema, Activity, Integration and no Users and no Settings
|
|
|
|
Scenario: The Admin built-in loses only the owner-only actions
|
|
Given the session is the built-in Admin (29 keys)
|
|
Then Sites is reachable and Delete site is disabled with the "Owner only" tag
|
|
And Apply update is disabled with "Only the owner can apply updates."
|
|
And the Retention card reads "Admins can read the log. Only the owner can change how long it is kept."
|
|
|
|
Rule: Route guard by permission
|
|
|
|
Scenario Outline: A direct link to a page the role lacks shows the page-level 403 state
|
|
Given the session grants <grants>
|
|
When the person opens <route>
|
|
Then the URL stays <route> and no redirect happens
|
|
And the page shows the icon-lock state titled "You don't have access to this page"
|
|
And the description is "Your role, <role>, doesn't include it. Ask an owner or admin if you need it."
|
|
And the single action is "<action>"
|
|
|
|
Examples:
|
|
| grants | role | route | action |
|
|
| content.read=all, no schema | Contributor | /schema | Go to Articles |
|
|
| content.read=all | Contributor | /schema/new | Go to Articles |
|
|
| content.read=all | Contributor | /users | Go to Articles |
|
|
| content.read=all | Contributor | /settings/sites | Go to Articles |
|
|
| content.read=all | Contributor | /integration/api-keys | Go to Articles |
|
|
| content.read:events=none | Contributor | /content/events | Go to Articles |
|
|
| media.view only | Media | /schema | Go to Home |
|
|
|
|
Scenario Outline: A page the role can use renders normally
|
|
Given the session grants <grants>
|
|
When the person opens <route>
|
|
Then the page renders and no 403 state shows
|
|
|
|
Examples:
|
|
| grants | route |
|
|
| schema.view | /schema |
|
|
| schema.edit | /schema/new |
|
|
| users.view | /users |
|
|
| settings.manage_sites | /settings/sites |
|
|
| content.read:events=own | /content/events |
|
|
| content.create:events=all | /content/events/new |
|
|
| apikeys.view=own | /integration/api-keys |
|
|
| settings.view | /settings/media/cache |
|
|
|
|
Scenario: The guard waits for the session
|
|
Given the session status is "checking"
|
|
When the person opens /schema
|
|
Then neither the page nor the 403 state flashes
|
|
And once the status is "ready" the matching state shows
|
|
|
|
Scenario: Unverified and signed-out rules are unchanged
|
|
Given an unverified session
|
|
Then the person is sent to /register/pending
|
|
And a missing session goes to /login with redirect and reason=expired
|
|
|
|
Scenario: Grants lowered while a page is open
|
|
Given the person is on /schema with schema.view
|
|
And a refresh then returns no schema.view
|
|
Then the page switches to the page-level 403 state without reload
|
|
|
|
Rule: Error handling in useApi
|
|
|
|
Scenario Outline: A 403 with a code
|
|
When the server answers 403 with body "<body>" and header <header>
|
|
Then the call rejects with ApiError status 403, code "<body>"<details>
|
|
And <toast>
|
|
|
|
Examples:
|
|
| body | header | details | toast |
|
|
| missing_permission | X-Missing-Permission: content.delete | permission "content.delete" | an error toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." |
|
|
| missing_permission | X-Missing-Permission: users.invite | permission "users.invite" | the toast names "Invite users" |
|
|
| missing_permission | (none) | permission undefined | the toast reads "Your role doesn't include this action. Ask an owner or admin." |
|
|
| escalation | X-Escalation-Key: content.publish:events| escalationKey kept | no global toast; the screen shows "You can't grant that" / "You can only grant what your role has." |
|
|
| role_locked | (none) | code kept | no global toast; copy "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." is available to screens |
|
|
| own_role | (none) | code kept | no global toast; copy "You can't change your own role" / "Ask another owner or admin." |
|
|
| role_built_in | (none) | code kept | no global toast; copy "Built-in roles can't be deleted" / "Duplicate it to make a custom role." |
|
|
| built_in_name | (none) | code kept | no global toast; copy "Built-in roles keep their name." |
|
|
| session_required | (none) | code kept | no global toast |
|
|
|
|
Scenario: A caller can opt out of the toast
|
|
Given a request sent with onMissingPermission "passthrough"
|
|
When the server answers 403 missing_permission
|
|
Then the ApiError carries the code and permission and no toast shows
|
|
And the session is still refreshed
|
|
|
|
Scenario: The unverified redirect still wins
|
|
Given the session is unverified
|
|
When any call answers 403
|
|
Then the person is sent to /register/pending, as before
|
|
|
|
Scenario: Unchanged statuses
|
|
Then 401 still redirects to /login, 429 still carries Retry-After, network failures still set isOffline
|
|
|
|
Scenario: 409 role_conflict and user_role_changed are mapped, not toasted
|
|
When the server answers 409 "role_conflict" or "user_role_changed"
|
|
Then ApiError.code carries it and useApi shows no toast
|
|
And a screen without its own handling shows "This changed somewhere else. Reload and try again."
|
|
|
|
Scenario: The old role-specific toasts are retired
|
|
Given the Sites or Authors write answers 403 missing_permission
|
|
Then the generic missing_permission toast shows
|
|
And "Only the owner can manage sites" no longer shows anywhere
|
|
|
|
Rule: Copy follows the new Admin rights
|
|
|
|
Scenario Outline: Owner-only copy
|
|
Then "<screen>" no longer says "<old>"
|
|
And it says "<new>"
|
|
|
|
Examples:
|
|
| screen | old | new |
|
|
| Sites 403 page | Only the owner can manage sites… | the page-level state |
|
|
| Schema 403 page | Only owners and admins can change content types… | the page-level state |
|
|
| Settings, Software | Ask the owner of this site to update RenewCMS. | Only the owner can apply updates. |
|
|
| Settings, Retention | Only owners can change it. (hidden for admin) | Admins can read the log. Only the owner can change how long it is kept. |
|
|
| Settings, Media cleanup | Only owners and admins can change this. | You can read these settings but not change them / Ask the owner to change them, or ask for a role that can edit settings. |
|
|
|
|
Rule: Own-scope and role-aware controls on content (design: roles.md section 6)
|
|
|
|
Scenario Outline: Entry list rows under each scope for content.update and content.delete
|
|
Given the session is user 21 with content.update:events=<update>, content.delete:events=<delete>
|
|
And entry 1 has created_by 21, entry 2 has created_by 9, entry 3 has created_by 0
|
|
When /content/events lists the three entries and the row menu opens on <row>
|
|
Then the "View only" mark with a lock icon is <mark>
|
|
And Edit is <edit>, with the reason "<editReason>"
|
|
And Delete is <del>, with the reason "<delReason>"
|
|
|
|
Examples:
|
|
| update | delete | row | mark | edit | editReason | del | delReason |
|
|
| own | own | entry 1 | absent | enabled | | enabled | |
|
|
| own | own | entry 2 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. |
|
|
| own | own | entry 3 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. |
|
|
| all | all | entry 2 | absent | enabled | | enabled | |
|
|
| all | none | entry 1 | absent | enabled | | disabled | Your role can't delete entries. |
|
|
| none | none | entry 1 | shown | disabled | Your role can't edit entries. | disabled | Your role can't delete entries. |
|
|
|
|
Scenario: The entry list explains the own scope
|
|
Given the session has content.create:events=all and content.update:events=own
|
|
Then the Events list reads "You can create entries and edit your own."
|
|
And a note reads "Entries by others are view only" / "Your role lets you edit entries you created."
|
|
And the New entry button is shown
|
|
And with content.create:events=none the New entry button is hidden
|
|
|
|
Scenario: Direct link to an entry the role can only view
|
|
Given the session has content.update:events=own and entry 2 has created_by 9
|
|
When the person opens /content/events/2
|
|
Then the entry is shown with the "View only" mark and no Save action
|
|
And the exact layout beyond the mark is a design gap (not specified in roles.md)
|
|
|
|
Scenario Outline: Article controls under all and none (ownership not needed)
|
|
Given the session has content.publish:article=<publish>, content.delete:article=<delete>, content.update:article=<update>
|
|
When the Articles list and the editor of an article render
|
|
Then Publish is <publishState>
|
|
And the status reads <status>
|
|
And Delete in the row menu is <del>, with the reason "<delReason>"
|
|
And Edit is <edit>
|
|
|
|
Examples:
|
|
| publish | delete | update | publishState | status | del | delReason | edit |
|
|
| all | all | all | available | Draft or Published as today | enabled | | enabled |
|
|
| none | none | all | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | enabled |
|
|
| none | none | none | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | disabled |
|
|
|
|
Scenario: The Writer built-in Articles list
|
|
Given the session is the built-in Writer
|
|
Then the Articles header reads "Articles" with "You can write and edit all articles. Publishing is done by an editor."
|
|
And the row menu shows Edit enabled and Delete disabled with "Your role can't delete articles."
|
|
And the editor shows Save draft, Status "Draft" and "Ask an editor to publish." and no Publish
|
|
|
|
Scenario Outline: Article controls under own scope
|
|
Given the session is user 21 with content.update:article=own, content.delete:article=own, content.publish:article=own
|
|
And article 1 has created_by 21 and article 2 has created_by 9, both exposed by GET /v1/article/
|
|
When the Articles list renders
|
|
Then on article 1 Edit, Delete and Publish are available
|
|
And on article 2 Edit and Delete are disabled with "You can only edit content you created." and Publish is hidden with "Ask an editor to publish."
|
|
|
|
Scenario Outline: Media controls
|
|
Given the session grants <grants>
|
|
When Media renders
|
|
Then <effect>
|
|
|
|
Examples:
|
|
| grants | effect |
|
|
| media.view | the library is read-only: no upload control, no Rename, no Delete, no Merge, no purge |
|
|
| media.view, media.upload | upload and Rename available; Delete, Merge and purge hidden |
|
|
| media.view, media.delete=all | Delete, Merge and purge available |
|
|
| media.view, media.delete=own | Delete, Merge and purge unavailable: media has no owner and the server requires all |
|
|
| no media.view | the page-level 403 state |
|
|
|
|
Scenario: A stale menu still ends well
|
|
Given the person sees Delete on an article and the role was just lowered
|
|
When Delete answers 403 missing_permission
|
|
Then the toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." shows
|
|
And the session refreshes and the control becomes disabled with its reason
|
|
|
|
Rule: Shapes of the changed API are documented in the catalogue
|
|
|
|
Scenario: The catalogue samples match the backend
|
|
Then the /auth/me sample shows access.role and access.permissions and no role string
|
|
And the /user list sample shows role {id, name, built_in, locked}
|
|
And the invite create request documents the optional role_id (absent means Writer) and the response carries role
|
|
And docs/llms output is regenerated by `task build:llms` from the catalogue, never edited by hand
|
|
|
|
Rule: The backend exposes created_by on articles to signed-in callers (Go, Go test suite)
|
|
|
|
Scenario Outline: created_by on article responses
|
|
When <caller> calls <route>
|
|
Then the article objects <result>
|
|
|
|
Examples:
|
|
| caller | route | result |
|
|
| a signed-in session | GET /v1/article/ | carry created_by (the user id) |
|
|
| a signed-in session | GET /v1/article/{id} | carry created_by |
|
|
| a signed-in session | PATCH /v1/article/{id} (response) | carry created_by |
|
|
| a signed-in session | POST /v1/article (response) | carry created_by, the caller's id |
|
|
| an API key | GET /v1/article/ | carry created_by (a key acts as its creator, authenticated) |
|
|
| an anonymous caller | GET /v1/article/ and GET /v1/article/{id} | carry no created_by field at all |
|
|
| an unverified or stale session | GET /v1/article/ | carry no created_by field (treated as anonymous, as in the public read rule) |
|
|
And created_by 0 is returned as 0 for a signed-in caller
|
|
And the same article encoded for an anonymous caller and a signed-in caller differ only by that field
|
|
And docs/api.md and docs/api/articles.md document the field, the audience and the SPA use; the Go suite covers every row with the existing optionalAccess middleware deciding authenticated versus anonymous
|