feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
+92
-3
@@ -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/ (`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`) |
|
||||
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /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`, `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `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` |
|
||||
@@ -406,6 +406,95 @@ Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `
|
||||
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.
|
||||
|
||||
## Changing the email address
|
||||
|
||||
Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid
|
||||
cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403
|
||||
`You must verify your account to access this resource.`
|
||||
|
||||
| Route | Body | Success |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "<new>" }` |
|
||||
| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "<new>" }` |
|
||||
| `DELETE /v1/auth/email/change` | none | 204, no body |
|
||||
| `POST /v1/auth/email/confirm` | `{ "code": "<raw code>" }` | 200 `{ "email": "<new>" }` |
|
||||
|
||||
`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and
|
||||
the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an
|
||||
expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to
|
||||
the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The
|
||||
link works once.
|
||||
|
||||
Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one,
|
||||
whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403
|
||||
`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409
|
||||
`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body
|
||||
that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text
|
||||
(`new_email` must be a full address, `current_password` and `code` are required).
|
||||
|
||||
A request for an address that another user already holds answers exactly like a success, stores the pending change and
|
||||
still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account
|
||||
exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and
|
||||
`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a
|
||||
`Retry-After` header. Cancel and confirm are not limited.
|
||||
|
||||
Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and
|
||||
`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing
|
||||
pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed
|
||||
`user.request_email_change` with the reason `Wrong password.`
|
||||
|
||||
## Exporting your own data
|
||||
|
||||
`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401,
|
||||
an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission
|
||||
gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`,
|
||||
`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip"`;
|
||||
the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is
|
||||
built in memory first, so a failure is a plain text 500 and never a half file.
|
||||
|
||||
| File | Content |
|
||||
| --- | --- |
|
||||
| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` |
|
||||
| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out |
|
||||
| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out |
|
||||
| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile |
|
||||
|
||||
The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key
|
||||
hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later."
|
||||
with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records
|
||||
one `user.export_data` audit event (category `users`, not flagged `security`).
|
||||
|
||||
The archive is built in memory. At most 2 exports are built at the same time across all users (the constant
|
||||
`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is
|
||||
cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used.
|
||||
|
||||
## Deleting your own account
|
||||
|
||||
`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401,
|
||||
an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`,
|
||||
which stays the admin action and is unchanged.
|
||||
|
||||
Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly
|
||||
`DELETE` (case-sensitive, no spaces). The checks run in this order:
|
||||
|
||||
| Answer | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` |
|
||||
| 403 | `wrong_password` | the password is not the account's |
|
||||
| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. |
|
||||
| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed |
|
||||
| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check |
|
||||
| 500 | "Internal server error" | the removal failed; everything is rolled back |
|
||||
| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted |
|
||||
|
||||
The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article
|
||||
credits it, otherwise kept as "Deleted user" with the slug `deleted-user-<author id>`, no bio, avatar, website or links and
|
||||
no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and
|
||||
one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send
|
||||
is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`,
|
||||
flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed
|
||||
`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`.
|
||||
|
||||
## Users, invites and ownership
|
||||
|
||||
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
|
||||
@@ -413,7 +502,7 @@ A user has exactly one stored role, `role_id`, and nothing else: there is no `ro
|
||||
`{ 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`.
|
||||
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `pending_email` (string or `null`), `author` and `access`.
|
||||
|
||||
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
|
||||
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
|
||||
|
||||
Reference in new issue
Block a user