465 lines
38 KiB
Gherkin
465 lines
38 KiB
Gherkin
Feature: Roles and permissions content scope: own and all enforcement on articles and entries, Publish as its own permission, typed grants
|
|
|
|
As the owner of a RenewCMS site
|
|
I want articles and entries to be checked against the caller's content permissions, by owner and by content type
|
|
So that a Contributor edits only their own work, a Writer cannot publish, and a role can differ per content type
|
|
|
|
Background:
|
|
Given the spec of record is docs/design/screens/roles.md (catalogue, "Hidden or disabled", "HTTP status to copy", sections 6 and 7) and docs/design/routes.md "Roles and permissions" (created_by, Enforcement)
|
|
And features/roles_permissions_foundation.feature provides the catalogue and built-in roles, and features/roles_permissions_enforcement.feature provides the 403 contract:
|
|
status 403, body "missing_permission\n", header X-Missing-Permission, decided from the caller's live role on every request, API keys following their creator
|
|
And this slice supersedes two clauses of the enforcement feature: owner-only keys are effective only on a Locked role (see "Owner-only keys are effective only on a locked role"),
|
|
and articles and entries are no longer ungated
|
|
And "ok" means the request passes the gate and the scope check. A later 400, 404 or 409 from the handler is not a refusal
|
|
And "403" means status 403, body "missing_permission\n", the header X-Missing-Permission holding the BASE catalogue key (content.update, never content.update:events),
|
|
no side effect, no audit event, and the use case writes nothing
|
|
And a refusal by scope is a role.MissingPermissionError returned by the use case (errors.Is role.ErrMissingPermission, errors.As for the key), written by access.WriteIfMissingPermission
|
|
And the typed key form is content.<action>:<type_key>. The type key of articles is the built-in key "article". The type key of an entry is the content type key of the URL
|
|
And a typed grant replaces the wildcard for that type, whether it lowers or raises it. Only the wildcard applies to a type with no typed row
|
|
And typed keys exist only for the 5 content.* keys. A typed row of scope none is an explicit deny for that type
|
|
And each action reads its own key, with no implied keys: content.publish does not imply content.update, content.update does not imply content.read, content.read implies nothing
|
|
And own means the caller's user id equals CreatedBy. All means any. None means no. created_by 0 (unattributed) is owned by nobody, and caller id 0 owns nothing
|
|
And scope is decided AFTER loading the article or entry, by the use case, from the caller's resolved grants passed in by the handler. Use cases never read user.Role
|
|
And the handler path for a scoped write is: Authenticator, RequireCurrentToken, RequireVerifiedUser, then a gate that refuses when the typed scope is none (before any lookup), then id/body validation (400), then the use case:
|
|
load (404), scope check (403) immediately after the load and before any other lookup or validation, then the write
|
|
And an id that does not exist answers 404 even for a caller whose scope is own, because ownership is unknown
|
|
And entries have no status. content.publish applies to articles only (PATCH .../publish and .../unpublish). No schedule route exists. A future one must use content.publish
|
|
And the type key "article" is shared with entry routes. Creating a content type whose key equals a built-in key (article, author, site, media, user) answers 409 apperr.ContentTypeKeyTakenErrorMessage
|
|
And the users (all verified, valid jwt cookie, token_version 0) are the foundation's user 3 Owner, user 5 Admin, user 9 Writer, plus these on custom, unlocked roles:
|
|
| user | role | grants |
|
|
| 21 | Contributor | content.read=all, content.create=all, content.update=own |
|
|
| 22 | Reader own | content.read=own, content.create=all |
|
|
| 23 | Editor | content.read=all, content.update=all, content.publish=all, content.delete=own |
|
|
| 24 | Publisher own | content.read=all, content.publish=own |
|
|
| 25 | Typed A | content.read=all, content.create=all, content.update=all, content.update:events=own, content.update:article=none |
|
|
| 26 | Typed B | content.read:events=all, content.create:events=all, content.update:events=own |
|
|
| 27 | No content | schema.view=all |
|
|
And the articles are:
|
|
| id | state | created_by |
|
|
| 10 | draft | 21 |
|
|
| 11 | draft | 9 |
|
|
| 12 | online| 3 |
|
|
| 13 | draft | 3 |
|
|
| 14 | draft | 0 |
|
|
| 15 | online| 21 |
|
|
| 16 | draft | 22 |
|
|
| 17 | draft | 24 |
|
|
And the entries are, in content types "events" and "venues":
|
|
| id | type | created_by |
|
|
| 1 | events | 21 |
|
|
| 2 | events | 9 |
|
|
| 3 | events | 0 |
|
|
| 4 | events | 25 |
|
|
| 5 | venues | 3 |
|
|
| 6 | events | 22 |
|
|
| 7 | events | 26 |
|
|
| 8 | events | 23 |
|
|
|
|
Scenario Outline: GrantSet.ScopeFor resolves wildcard and typed keys
|
|
Given a role (not locked) with grants <grants>
|
|
When ScopeFor("<key>", "<type>") is evaluated
|
|
Then the scope is <scope>
|
|
|
|
Examples:
|
|
| grants | key | type | scope |
|
|
| content.update=all | content.update | | all |
|
|
| content.update=all | content.update | blog | all |
|
|
| content.update=all, content.update:blog=own | content.update | blog | own |
|
|
| content.update=all, content.update:blog=own | content.update | news | all |
|
|
| content.update=all, content.update:blog=none | content.update | blog | none |
|
|
| content.update=own, content.update:blog=all | content.update | blog | all |
|
|
| content.update:blog=all | content.update | blog | all |
|
|
| content.update:blog=all | content.update | news | none |
|
|
| content.update:blog=all | content.update | | none |
|
|
| content.create=all, content.create:blog=none | content.create | blog | none |
|
|
| content.create=all, content.create:blog=all | content.create | blog | all |
|
|
| content.read=all | content.read | Bad Key | none |
|
|
| content.read=all | content.read | (101 characters) | none |
|
|
| schema.view=all | schema.view | blog | none |
|
|
| schema.view=all | schema.view | | all |
|
|
| content.explode=all | content.explode| | none |
|
|
| (no grants) | content.read | | none |
|
|
|
|
Scenario Outline: One helper decides own and all
|
|
Given a role holding content.update at <scope>
|
|
When Permits("content.update", "blog", caller=<caller>, createdBy=<created_by>) is evaluated
|
|
Then the result is <allowed>
|
|
|
|
Examples:
|
|
| scope | caller | created_by | allowed |
|
|
| all | 9 | 21 | true |
|
|
| all | 9 | 0 | true |
|
|
| own | 21 | 21 | true |
|
|
| own | 9 | 21 | false |
|
|
| own | 21 | 0 | false |
|
|
| own | 0 | 0 | false |
|
|
| none | 21 | 21 | false |
|
|
|
|
Scenario: The own and all rule has exactly one implementation
|
|
Then internal/domain/role holds one exported helper deciding ownership (GrantSet.Permits, built on GrantSet.ScopeFor)
|
|
And no use case, handler or middleware compares a caller id with CreatedBy, or reads a GrantSet entry, for an authorization decision. They call ScopeFor, Permits, Holds or HoldsAll
|
|
And the 2a decisions for activity.view, apikeys.view, apikeys.revoke and authors.edit are evaluated through the same helper, with the same results as in the enforcement feature
|
|
And for authors.edit own means "the author linked to the caller" (existing rule), derived from ScopeFor, not from CreatedBy
|
|
|
|
Scenario: Role.Allows and AllowsFor are removed
|
|
Then Role no longer has Allows, AllowsFor or scopeOf
|
|
And a role is evaluated only through Role.GrantSet(), the single constructor of a GrantSet. NewGrantSet is unexported or removed
|
|
And Role.GrantSet() drops owner-only keys when the role is not Locked, drops unknown keys, drops typed keys on non-content keys or with an invalid type key,
|
|
and normalises duplicates (see next scenarios)
|
|
And a toggle key stored at scope own, or any key stored at a scope other than none, own or all, is treated as none
|
|
|
|
Scenario Outline: Duplicate rows resolve to the most restrictive scope
|
|
Given a stored role with the rows <rows> in this order
|
|
When the GrantSet is built
|
|
Then ScopeFor("<key>", "<type>") is <scope>
|
|
And the result is the same when the rows are in the opposite order
|
|
|
|
Examples:
|
|
| rows | key | type | scope |
|
|
| content.update=own, content.update=all | content.update | | own |
|
|
| content.update=all, content.update=own | content.update | | own |
|
|
| content.update:blog=all, content.update:blog=own | content.update | blog | own |
|
|
| content.update=all, content.update:blog=own | content.update | blog | own |
|
|
| content.read=all, content.read=none | content.read | | none |
|
|
|
|
Scenario Outline: ValidateGrants rejects duplicates and validates typed keys
|
|
Given a role to be written with the grants <grants>
|
|
When ValidateGrants(locked=false, grants) is evaluated
|
|
Then the result is <result> (errors.Is)
|
|
|
|
Examples:
|
|
| grants | result |
|
|
| content.update=all, content.update=own | role.ErrDuplicateGrant |
|
|
| content.update:blog=all, content.update:blog=own| role.ErrDuplicateGrant |
|
|
| content.update=all, content.update:blog=own | nil |
|
|
| content.update:blog=none | nil |
|
|
| content.update=none | role.ErrInvalidScope |
|
|
| content.create:blog=none | nil |
|
|
| content.create:blog=own | role.ErrInvalidScope |
|
|
| content.update:Bad Key=all | role.ErrUnknownPermission |
|
|
| schema.view:blog=all | role.ErrUnknownPermission |
|
|
| settings.delete_site=all | role.ErrOwnerOnlyGrant |
|
|
|
|
Scenario: Owner-only keys are effective only on a locked role
|
|
Given a custom role, not locked, with the hand-placed grant system.apply_updates=all
|
|
When user 20 sends POST /v1/system/update/check
|
|
Then the response is 403 missing_permission with X-Missing-Permission "system.apply_updates"
|
|
And GET /v1/auth/me for that user does not list system.apply_updates
|
|
And the built-in Owner role, which is Locked, still holds all 34 keys
|
|
And the same refusal applies to settings.delete_site, roles.edit_owner_only, system.edit_retention and system.transfer_ownership on an unlocked role
|
|
|
|
Scenario: The permission check loads the role once and no grants separately
|
|
Given a role repository that counts calls and a user repository that counts calls
|
|
When a gated request is sent with a session cookie
|
|
Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once
|
|
And List is called zero times by the permission check and by GetMyAccessUseCase (ListGrants was removed by roles_permissions_roles_api)
|
|
And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load)
|
|
And the status codes and the order of checks of the enforcement feature are unchanged
|
|
And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it
|
|
|
|
Scenario: The router uses role.Key constants
|
|
Then internal/domain/role/roleKeys.go holds one exported constant per catalogue key (34), named from the key (content.read is KeyContentRead)
|
|
And api/router.go and api/middleware contain no string literal equal to a catalogue key
|
|
And a drift test fails when a catalogue key has no constant, or a constant has no catalogue key
|
|
|
|
Scenario Outline: Reading articles shows what the caller may see
|
|
Given the articles of the Background
|
|
When <caller> sends GET /v1/article/<id>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | id | result |
|
|
| no cookie | 12 | 200 |
|
|
| no cookie | 10 | 404 Article not found |
|
|
| user 12 unverified, Admin role | 10 | 404 Article not found |
|
|
| user 9 Writer (read all) | 10 | 200 |
|
|
| user 9 Writer | 14 | 200 |
|
|
| user 22 (read own) | 16 | 200 |
|
|
| user 22 (read own) | 12 | 200 |
|
|
| user 22 (read own) | 10 | 404 Article not found |
|
|
| user 22 (read own) | 14 | 404 Article not found |
|
|
| user 27 (no content.read) | 12 | 200 |
|
|
| user 27 (no content.read) | 10 | 404 Article not found |
|
|
| user 26 (typed read on events only) | 10 | 404 Article not found |
|
|
| user 13, role_id 99, unresolvable | 12 | 200 |
|
|
| user 13, role_id 99, unresolvable | 10 | 404 Article not found |
|
|
| API key created by user 9 | 10 | 200 |
|
|
| API key created by user 22 | 10 | 404 Article not found |
|
|
|
|
Scenario: Public article routes never answer 403
|
|
Given user 27 holds no content.read
|
|
When the client sends GET /v1/article, GET /v1/article/12 and GET /v1/article/12/seo with that cookie
|
|
Then every response is 200 and none is 403
|
|
And a role repository failure while resolving the caller's role answers 500 "Internal server error"
|
|
And a request with no cookie never touches the role repository
|
|
|
|
Scenario Outline: The article list is the visible set
|
|
Given the articles of the Background
|
|
When <caller> sends GET /v1/article<query>
|
|
Then the ids returned are <ids>
|
|
And total is <total> and counts are <counts>
|
|
|
|
Examples:
|
|
| caller | query | ids | total | counts |
|
|
| no cookie | | 12, 15 | 2 | all 2, online 2, draft 0 |
|
|
| user 27 | | 12, 15 | 2 | all 2, online 2, draft 0 |
|
|
| user 9 Writer | | 10, 11, 12, 13, 14, 15, 16, 17 | 8 | all 8, online 2, draft 6 |
|
|
| user 22 (read own) | | 12, 15, 16 | 3 | all 3, online 2, draft 1 |
|
|
| user 22 (read own) | ?status=draft | 16 | 1 | all 3, online 2, draft 1 |
|
|
| user 22 (read own) | ?status=online| 12, 15 | 2 | all 3, online 2, draft 1 |
|
|
| user 21 (read all) | ?status=draft | 10, 11, 13, 14, 16, 17 | 6 | all 8, online 2, draft 6 |
|
|
And counts.suggestions and a ?seo= filter consider only the visible articles
|
|
And the ids are ordered by the existing default sort
|
|
|
|
Scenario Outline: Reading an article's SEO follows the same visibility
|
|
When <caller> sends GET /v1/article/<id>/seo
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | id | result |
|
|
| no cookie | 12 | 200 |
|
|
| no cookie | 10 | 404 Article not found |
|
|
| user 22 (read own) | 16 | 200 |
|
|
| user 22 (read own) | 10 | 404 Article not found |
|
|
| user 9 Writer | 10 | 200 |
|
|
|
|
Scenario Outline: The SEO check is a read of a visible article
|
|
When <caller> sends POST /v1/article/<id>/seo/check with body {"excerpt": "A short summary"}
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | id | result |
|
|
| user 27 | 12 | 403 missing_permission, header content.read |
|
|
| user 22 (read own) | 16 | 200 |
|
|
| user 22 (read own) | 12 | 200 |
|
|
| user 22 (read own) | 10 | 404 Article not found |
|
|
| user 9 Writer | 10 | 200 |
|
|
| user 9 Writer | 999 | 404 Article not found |
|
|
|
|
Scenario Outline: Creating an article needs content.create for the article type
|
|
When <caller> sends POST /v1/article with body {"title": "A new draft"}
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | result |
|
|
| user 21 Contributor | 201, is_online false, stored created_by 21 |
|
|
| user 22 Reader own | 201, is_online false, stored created_by 22 |
|
|
| user 9 Writer | 201, is_online false, stored created_by 9 |
|
|
| user 25 Typed A | 201, update:article=none does not affect create |
|
|
| user 24 Publisher own | 403 missing_permission, header content.create, nothing stored |
|
|
| user 26 Typed B | 403 missing_permission, header content.create, nothing stored |
|
|
| user 27 | 403 missing_permission, header content.create, nothing stored |
|
|
|
|
Scenario Outline: Updating and deleting an article follow the scope of the article
|
|
Given the articles of the Background
|
|
When <caller> sends <call>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | call | result |
|
|
| user 21 Contributor | PATCH /v1/article/10 {"body": "x"} | 200 |
|
|
| user 21 Contributor | PATCH /v1/article/11 {"body": "x"} | 403 content.update, article 11 unchanged |
|
|
| user 21 Contributor | PATCH /v1/article/14 {"body": "x"} | 403 content.update |
|
|
| user 21 Contributor | PATCH /v1/article/999 {"body": "x"} | 404 Article not found |
|
|
| user 21 Contributor | PATCH /v1/article/11 {"nothing": 1} | 400 apperr.BodyErrorMessage, as before |
|
|
| user 21 Contributor | DELETE /v1/article/10 | 403 content.delete, before any lookup (also for id 999) |
|
|
| user 9 Writer | PATCH /v1/article/13 {"body": "x"} | 200 |
|
|
| user 9 Writer | DELETE /v1/article/11 | 403 content.delete |
|
|
| user 23 Editor | PATCH /v1/article/13 {"body": "x"} | 200 |
|
|
| user 23 Editor | DELETE /v1/article/13 | 403 content.delete (created by user 3) |
|
|
| user 5 Admin | DELETE /v1/article/13 | 204 |
|
|
| user 3 Owner | PATCH /v1/article/10 {"body": "x"} | 200 |
|
|
| user 24 Publisher own | PATCH /v1/article/17 {"body": "x"} | 403 content.update (publish does not imply update), before any lookup |
|
|
| user 27 | PATCH /v1/article/12 {"body": "x"} | 403 content.update |
|
|
And a refused call changes no field, records no audit event and creates no author or site side effect, including when the body also changes site_id, author_ids or the slug
|
|
And an allowed own update keeps created_by unchanged
|
|
|
|
Scenario: An own-scope user can delete only their own article
|
|
Given user 23 is the Editor with content.delete=own and article 18 (draft) was created by user 23
|
|
When user 23 sends DELETE /v1/article/18 and then DELETE /v1/article/12
|
|
Then the first answers 204 and the article is gone and one article.delete audit event is recorded
|
|
And the second answers 403 missing_permission with X-Missing-Permission "content.delete" and article 12 is still stored
|
|
|
|
Scenario Outline: Publish is its own permission
|
|
Given the articles of the Background
|
|
When <caller> sends <call>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | call | result |
|
|
| user 9 Writer | PATCH /v1/article/11/publish | 403 content.publish, article 11 is still a draft |
|
|
| user 9 Writer | PATCH /v1/article/12/unpublish | 403 content.publish, article 12 is still online |
|
|
| user 21 Contributor | PATCH /v1/article/10/publish | 403 content.publish (own article, but publish is none) |
|
|
| user 23 Editor | PATCH /v1/article/11/publish | 200, is_online true, one article.publish audit event |
|
|
| user 23 Editor | PATCH /v1/article/12/unpublish | 200, is_online false, one article.unpublish audit event |
|
|
| user 24 Publisher own | PATCH /v1/article/17/publish | 200, is_online true |
|
|
| user 24 Publisher own | PATCH /v1/article/17/unpublish | 200, is_online false |
|
|
| user 24 Publisher own | PATCH /v1/article/10/publish | 403 content.publish, article 10 unchanged |
|
|
| user 24 Publisher own | PATCH /v1/article/999/publish | 404 Article not found |
|
|
| user 5 Admin | PATCH /v1/article/10/publish | 200 |
|
|
| user 27 | PATCH /v1/article/12/publish | 403 content.publish |
|
|
|
|
Scenario: Saving a draft without the publish permission
|
|
Given user 9 (Writer: content.update=all, no content.publish) and draft article 11
|
|
When user 9 sends PATCH /v1/article/11 with {"body": "new text"}
|
|
Then the status is 200, the body holds the new text and is_online false
|
|
And the article is still a draft
|
|
And the server answers no copy for this: "Ask an editor to publish." is shown by the SPA, never produced by the API
|
|
|
|
Scenario: Update never changes the status
|
|
Given user 9 and draft article 11
|
|
When user 9 sends PATCH /v1/article/11 with {"body": "x", "is_online": true}
|
|
Then the status is 200 and the article is still a draft
|
|
And user 9 sends PATCH /v1/article/12 with {"body": "x"} on the online article and it stays online
|
|
|
|
Scenario: A new article is always a draft whatever the role
|
|
Given user 3 (Owner) sends POST /v1/article with {"title": "Owner draft", "is_online": true}
|
|
Then the status is 201 and is_online is false
|
|
|
|
Scenario: A refused publish leaves no trace
|
|
Given user 9 sends PATCH /v1/article/11/publish
|
|
Then the response is 403 missing_permission and no audit event is recorded and the article's updated_at is unchanged
|
|
|
|
Scenario Outline: Entries follow the read scope
|
|
When <caller> sends <call>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | call | result |
|
|
| user 21 Contributor | GET /v1/content-type/events/entry | 200, entries 1, 2, 3, 4, 6, 7, 8, total 7 |
|
|
| user 22 (read own) | GET /v1/content-type/events/entry | 200, entry 6 only, total 1 |
|
|
| user 22 (read own) | GET /v1/content-type/events/entry?page=1&size=5 | 200, entry 6, total 1, page 1, size 5 |
|
|
| user 22 (read own) | GET /v1/content-type/events/entry/6 | 200 |
|
|
| user 22 (read own) | GET /v1/content-type/events/entry/1 | 404 entry_not_found |
|
|
| user 22 (read own) | GET /v1/content-type/events/entry/3 | 404 entry_not_found (created_by 0) |
|
|
| user 27 | GET /v1/content-type/events/entry | 403 content.read, before any lookup |
|
|
| user 27 | GET /v1/content-type/events/entry/1 | 403 content.read |
|
|
| user 27 | GET /v1/content-type/nope/entry | 403 content.read |
|
|
| user 21 Contributor | GET /v1/content-type/nope/entry | 404 content_type_not_found |
|
|
| user 5 Admin | GET /v1/content-type/events/entry/2 | 200 |
|
|
And under scope own, a ?q= search, total, page and size are computed over the caller's own entries only
|
|
|
|
Scenario Outline: Entries follow the create, update and delete scope
|
|
When <caller> sends <call>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | call | result |
|
|
| user 21 Contributor | POST /v1/content-type/events/entry {"data": {...}} | 201, created_by 21 |
|
|
| user 27 | POST /v1/content-type/events/entry {"data": {...}} | 403 content.create, nothing stored |
|
|
| user 21 Contributor | PUT /v1/content-type/events/entry/1 {"data": {...}} | 200, created_by stays 21, updated_by 21 |
|
|
| user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update, entry 2 unchanged |
|
|
| user 21 Contributor | PUT /v1/content-type/events/entry/3 {"data": {...}} | 403 content.update |
|
|
| user 21 Contributor | PUT /v1/content-type/events/entry/999 {"data": {...}} | 404 entry_not_found |
|
|
| user 21 Contributor | PUT /v1/content-type/nope/entry/1 {"data": {...}} | 404 content_type_not_found |
|
|
| user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {"title": ""}} | 403 content.update (scope is checked before field validation) |
|
|
| user 21 Contributor | DELETE /v1/content-type/events/entry/1 | 403 content.delete, before any lookup, entry 1 stored |
|
|
| user 9 Writer | PUT /v1/content-type/events/entry/2 {"data": {...}} | 200 |
|
|
| user 9 Writer | DELETE /v1/content-type/events/entry/2 | 403 content.delete |
|
|
| user 23 Editor | DELETE /v1/content-type/events/entry/8 | 204 |
|
|
| user 23 Editor | DELETE /v1/content-type/events/entry/2 | 403 content.delete, entry 2 stored |
|
|
| user 5 Admin | DELETE /v1/content-type/events/entry/2 | 204 |
|
|
| user 3 Owner | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 |
|
|
And no entry route requires content.publish
|
|
|
|
Scenario Outline: Typed grants replace the wildcard for their type
|
|
When <caller> sends <call>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | call | result |
|
|
| user 25 Typed A | PATCH /v1/article/10 {"body": "x"} | 403 content.update (update:article=none replaces update=all), before any lookup |
|
|
| user 25 Typed A | PUT /v1/content-type/events/entry/4 {"data": {...}} | 200 (update:events=own, entry created by 25) |
|
|
| user 25 Typed A | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update (update:events=own replaces update=all) |
|
|
| user 25 Typed A | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 (wildcard update=all applies to venues) |
|
|
| user 26 Typed B | GET /v1/content-type/events/entry | 200 |
|
|
| user 26 Typed B | GET /v1/content-type/venues/entry | 403 content.read |
|
|
| user 26 Typed B | POST /v1/content-type/events/entry {"data": {...}} | 201 |
|
|
| user 26 Typed B | POST /v1/content-type/venues/entry {"data": {...}} | 403 content.create |
|
|
| user 26 Typed B | PUT /v1/content-type/events/entry/7 {"data": {...}} | 200 |
|
|
| user 26 Typed B | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update |
|
|
| user 26 Typed B | GET /v1/article/10 | 404 Article not found (no article read grant: public view) |
|
|
| user 26 Typed B | GET /v1/article/12 | 200 |
|
|
| user 26 Typed B | POST /v1/article {"title": "A new draft"} | 403 content.create |
|
|
And the header is always the base key, never "content.update:events"
|
|
|
|
Scenario: An unknown type key falls under the wildcard or is refused
|
|
Given user 26 (typed grants for events only)
|
|
When user 26 sends GET /v1/content-type/venues/entry and GET /v1/content-type/nope/entry
|
|
Then both answer 403 content.read, with no lookup of the content type
|
|
And user 21, whose wildcard grants apply, gets 404 content_type_not_found on GET /v1/content-type/nope/entry
|
|
|
|
Scenario: Creating a content type with a built-in key is refused
|
|
Given user 5 (Admin)
|
|
When user 5 sends POST /v1/content-type with a valid body whose key is "article"
|
|
Then the status is 409 and the body is apperr.ContentTypeKeyTakenErrorMessage and nothing is stored
|
|
And the same holds for the keys "author", "site", "media" and "user"
|
|
And content types that already exist are not touched
|
|
|
|
Scenario: Admin and Owner pass every content check
|
|
Given the Admin and the Owner
|
|
Then they read, create, update, delete and publish every article and entry whatever its created_by
|
|
And they list every article including drafts, with counts all 8, online 2, draft 6
|
|
|
|
Scenario: Writer is effective on content
|
|
Given user 9 on the Writer role
|
|
Then Writer reads and updates every article and entry, and creates articles and entries
|
|
And Writer gets 403 content.delete on DELETE of an article or an entry, and 403 content.publish on publish and unpublish
|
|
And editing an online article keeps it online
|
|
|
|
Scenario: A session follows the role live for content
|
|
Given user 21 gets 403 on PATCH /v1/article/11 {"body": "x"}
|
|
When the Contributor role's grant becomes content.update=all in the repository
|
|
Then the next PATCH /v1/article/11 with the same cookie answers 200, with no re-login
|
|
And when the grant is then removed, the next PATCH answers 403 content.update
|
|
|
|
Scenario: An API key follows its creator's role and owns what its creator owns
|
|
Given a valid API key created by user 21
|
|
When the key sends POST /v1/article {"title": "Key draft"}
|
|
Then the stored article has created_by 21
|
|
And the key's PATCH on that article answers 200 and on article 11 answers 403 content.update
|
|
And the key's PATCH /v1/article/11/publish answers 403 content.publish
|
|
And when user 21's role_id is changed to the Editor role, the same key's PATCH /v1/article/11/publish answers 200 with no new key
|
|
|
|
Scenario: A user whose role cannot be resolved is refused on gated content routes
|
|
Given user 13 has role_id 99
|
|
When user 13 sends POST /v1/article, PATCH /v1/article/10 and GET /v1/content-type/events/entry
|
|
Then every response is 403 missing_permission
|
|
And a role repository failure answers 500 "Internal server error"
|
|
|
|
Scenario: Verification and token checks still come first
|
|
Given user 12 is not verified and holds the Admin role
|
|
When user 12 sends PATCH /v1/article/10 with {"body": "x"}
|
|
Then the status is 403 apperr.VerificationRequiredErrorMessage
|
|
And a stale token_version gives 401 and no cookie gives 401 on every gated content route
|
|
|
|
Scenario Outline: Media routes keep their scope behaviour
|
|
When <caller> sends <call>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| caller | call | result |
|
|
| user 9 Writer | DELETE /v1/media/1 | 403 media.delete |
|
|
| role with media.delete=own | DELETE /v1/media/1 | 403 media.delete (images record no creator, own fails closed) |
|
|
| role with media.delete=own | POST /v1/media/merge | 403 media.delete |
|
|
| role with media.delete=own | POST /v1/media/purge | 403 media.delete |
|
|
| role with media.delete=all | DELETE /v1/media/1 | ok |
|
|
| user 9 Writer | GET /v1/media/1/usage | ok |
|
|
| user 9 Writer | POST /v1/media | ok |
|
|
And GET /v1/media/{id}/usage keeps listing every referencing article and entry to any media.view holder, whatever the content scopes (not filtered in this slice)
|
|
|
|
Scenario: authors.edit keeps its 2a behaviour on the shared helper
|
|
Given the authors.edit Outline of the enforcement feature
|
|
Then every row gives the same result as before, now evaluated through GrantSet.ScopeFor
|
|
|
|
Scenario: /auth/me lists the effective grants
|
|
Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all
|
|
When the client sends GET /v1/auth/me
|
|
Then access.permissions holds content.update at own and no settings.delete_site
|
|
And typed grants are not listed in this slice (superseded by roles_permissions_roles_api: typed entries, scope none included, are listed)
|
|
|
|
Scenario: Out of scope here
|
|
Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route
|
|
And typed grants are written by slice 3 and exposed to the SPA by a later slice (done by roles_permissions_roles_api: the API writes them and /auth/me lists them)
|
|
And rows of deleted content types in typed grants are cleaned up by slice 3 (done by roles_permissions_roles_api)
|