feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
1 parent
094df63a7f
commit
80f7037d3a
124 files changed
+4644
-824
No files matched your search
+68
-5
@@ -33,7 +33,7 @@ for example `https://cms.example/v1/article/`.
|
||||
| --- | --- |
|
||||
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
||||
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
||||
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) |
|
||||
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
|
||||
| Settings | GET /v1/settings |
|
||||
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
||||
| Authors | GET /v1/author/ |
|
||||
@@ -74,7 +74,7 @@ never changes the response or the change itself.
|
||||
| --- | --- |
|
||||
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
||||
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
||||
| `authors` | `author.create`, `author.edit`, `author.delete` |
|
||||
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
||||
@@ -372,7 +372,8 @@ A role is `{ id, name, description, built_in, locked, version, grants, members,
|
||||
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
|
||||
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
|
||||
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
|
||||
users by their effective role (the legacy `owner` and `admin` strings count for the built-in role of that name).
|
||||
the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two
|
||||
statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody.
|
||||
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
|
||||
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
|
||||
|
||||
@@ -387,7 +388,7 @@ Refusals are plain text, one snake_case code per body:
|
||||
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
|
||||
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
|
||||
| 404 | `role_not_found` | Unknown role id. |
|
||||
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members; deleting or moving members out of a built-in role. |
|
||||
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. |
|
||||
|
||||
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
|
||||
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
|
||||
@@ -397,13 +398,75 @@ at the same version and records nothing. Members see an edit on their next reque
|
||||
|
||||
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
|
||||
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
|
||||
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` and is recorded only when
|
||||
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` (users and open invites together) and is recorded only when
|
||||
`moved` is above 0. A refusal records nothing.
|
||||
|
||||
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
|
||||
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
|
||||
creating one with the key of a deleted type clears any stale row for that key.
|
||||
|
||||
## Users, invites and ownership
|
||||
|
||||
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
|
||||
`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape,
|
||||
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
|
||||
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
|
||||
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
|
||||
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`.
|
||||
|
||||
| Route | Permission | Session only | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
|
||||
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item. |
|
||||
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
|
||||
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
|
||||
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
|
||||
| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. |
|
||||
|
||||
The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's
|
||||
current role and the new role on a role change, the role of an invite on create and renew. An API key may create and
|
||||
renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the
|
||||
Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves
|
||||
the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers
|
||||
200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided
|
||||
by the new grants. An unverified user may be given a role.
|
||||
|
||||
The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked
|
||||
role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write.
|
||||
On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered,
|
||||
email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404),
|
||||
already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission,
|
||||
body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite.
|
||||
|
||||
| Status | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
|
||||
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
|
||||
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
|
||||
| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. |
|
||||
| 403 | `own_role` | The target of a role change is the caller's own account. |
|
||||
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
|
||||
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
|
||||
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
|
||||
| 409 | `user_role_changed` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
|
||||
|
||||
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
|
||||
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
|
||||
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
|
||||
under two simultaneous transfers (one answers 200, the other 409).
|
||||
|
||||
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
|
||||
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
|
||||
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
|
||||
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
|
||||
`GET /v1/auth/me`; it is never promoted.
|
||||
|
||||
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
|
||||
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
|
||||
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
|
||||
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
|
||||
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
|
||||
|
||||
## Content permissions
|
||||
|
||||
Articles and the entries of custom content types are checked against the `content.*` permissions of the
|
||||
|
||||
Reference in new issue
Block a user