From 1cac61aa0093dea6745a83b61d5f14a6f09e8873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 18:48:20 +0000 Subject: [PATCH] docs(plugins): add USER_PROVISIONING runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract for provisioning a user into a plugin's upstream service, with two worked examples. Four rules: never hardcode the admin credential; check-then-create (neither API upserts); force a password change where the server supports it; verify by actually logging in, since a 201 only means the row was written. Immich (photos plugin) — verified end to end against a live 3.1.0 instance, including every error body: duplicate email 400 "Email is not available", missing field 400 validation, bad key 401. Includes a non-destructive probe recipe to re-verify after an upgrade. Jellyfin (jellyfin plugin) — route surface and auth model verified against 10.11.11; request bodies from working knowledge and marked as such. Differs in shape: MediaBrowser token auth, multi-call create, no forced-password-change flag, and accounts are per-instance (this deployment runs four independent servers). Records the decision to set a generated password rather than create blank-password accounts. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/USER_PROVISIONING.md | 240 +++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 plugins/USER_PROVISIONING.md diff --git a/plugins/USER_PROVISIONING.md b/plugins/USER_PROVISIONING.md new file mode 100644 index 00000000..f91d781e --- /dev/null +++ b/plugins/USER_PROVISIONING.md @@ -0,0 +1,240 @@ +# Provisioning a user in a plugin's upstream service + +> **Handing this to a fresh agent?** Everything below was verified against a live instance on +> 2026-08-19 — every status code, every error body. Trust it over the upstream's own docs, which +> lag. Re-verify with the probe sequence at the end if the upstream has been upgraded since. + +Officer creates the account; the upstream service owns it. This is the contract for doing that +safely, written from the **`photos` plugin** (Immich) as the first worked example. The shape +generalises: authenticate as an admin, check-then-create, verify the result, hand over a one-time +password. + +**This directory holds documentation, not plugins.** The plugin itself lives in +[`plugins/photos`](https://gitea.officer.dev/plugins/photos). + +--- + +## The four rules + +1. **Never hardcode the admin credential.** Read it from the environment or the platform's + credential store, and never log it. Docs and repos get shared; credentials must not travel + with them. +2. **Check before you create.** None of these APIs upsert. A second call with the same email is + an error, not a no-op — so provisioning must be re-runnable by listing first. +3. **Force a password change.** Generate a strong random password, mark it as + must-change, and treat it as a one-time handover value. It stops being a secret the moment + the user logs in. +4. **Verify by logging in.** A `201` means the row was written, not that the account works. Do a + real login before reporting success, so failures surface at provisioning time rather than when + the person first tries to use it. + +--- + +## Worked example — Immich (`photos` plugin) + +Verified against **Immich 3.1.0**. + +| | | +|---|---| +| API root | `/api` — e.g. `http://127.0.0.1:2283/api` | +| Auth | header `x-api-key: ` | +| Admin routes | `/api/admin/*` — reject non-admin keys with `401` | + +Immich stores API keys **SHA-256 hashed**, so a lost key can only be replaced, never recovered. +Mint one in the UI → Account Settings → API Keys. + +### 1. Check (idempotency guard) + +```http +GET /api/admin/users +x-api-key: +``` +→ `200`, JSON array. If the target email is present, stop — already provisioned. + +### 2. Create + +```http +POST /api/admin/users +x-api-key: +Content-Type: application/json + +{ + "email": "person@example.com", + "password": "", + "name": "Full Name", + "shouldChangePassword": true +} +``` + +**Required:** `email`, `password`, `name`. + +| Optional field | Type | Notes | +|---|---|---| +| `shouldChangePassword` | bool | `true` ⇒ forced reset at first login. Always send it. | +| `quotaSizeInBytes` | int | e.g. `1073741824` = 1 GiB. Omit for unlimited. | +| `storageLabel` | string | Folder name used by the storage template. | +| `notify` | bool | Emails the new user. Requires SMTP configured upstream. | + +→ **`201 Created`**, body carries the new `id`: + +```json +{ "id": "70ee4cd2-…", "email": "person@example.com", "name": "Full Name", + "isAdmin": false, "shouldChangePassword": true, "quotaSizeInBytes": null } +``` + +`isAdmin` cannot be set at creation — promote afterwards via the admin update endpoint. +Names are full Unicode (`Bruno Conceição`); send UTF-8, no escaping. + +### 3. Verify + +```http +POST /api/auth/login +Content-Type: application/json + +{ "email": "person@example.com", "password": "" } +``` +→ `200` with `{"name":…,"userEmail":…,"isAdmin":false,"shouldChangePassword":true}`. +No API key — this is the ordinary user login path, which is exactly why it proves the account works. + +### Errors (observed, not guessed) + +| Condition | Status | Body | +|---|---|---| +| Email already exists | `400` | `{"message":"Email is not available"}` | +| Missing `password` / `name` | `400` | `{"message":"Validation failed","errors":[{"path":["password"],…}]}` | +| Bad or missing API key | `401` | — | + +### Deprovisioning + +```http +DELETE /api/admin/users/{id} +Content-Type: application/json + +{ "force": true } +``` +→ `200` with `{"status":"removing","deletedAt":"…"}`. Soft-delete; `force` skips the grace period. + +--- + +## Worked example — Jellyfin (`jellyfin` plugin) + +Same four rules, **different shape**. Read this section as: the route surface and auth model are +**verified** against Jellyfin **10.11.11**; the request bodies are from working knowledge and +marked as such — confirm them with the probe recipe below before relying on them in code. + +### How it differs from Immich — read this first + +| | Immich | Jellyfin | +|---|---|---| +| Auth header | `x-api-key: ` | `Authorization: MediaBrowser Token=""` | +| Create + configure | one call | **two or three** — create, then policy, sometimes password | +| Force password change | `shouldChangePassword: true` | **no equivalent** — see below | +| Scope of an account | one server | **per instance** — see below | + +Two consequences that bite: + +1. **There is no "must change password" flag.** Rule 3 cannot be enforced by the server. + **Decision for this deployment (2026-08-20): set a generated password at creation and tell the + user to change it.** Do *not* use the blank-password alternative — Jellyfin permits it, but the + account is then open to anyone who can reach the port until the user sets one. + So for Jellyfin, rule 3 becomes: still generate a strong unique password, still treat it as a + handover value, but the change is advisory rather than forced — which means **never reuse a + generated password across users or instances**, since it may stay valid indefinitely. +2. **Users are per-instance, not global.** This deployment runs **four** independent Jellyfin + servers, each with its own user database and its own API keys: + + | Instance | Port | Server name | + |---|---|---| + | `jellyfin` | 9301 | Jelly | + | `jellyfin_music` | 9302 | Albums | + | `jellyfin_dj_sets` | 9303 | DJ_Sets | + | `jellyfin_diogo` | 9399 | JellyDiogo | + + Provisioning "a Jellyfin user" therefore means looping over whichever instances that person + should reach, with a **separate API key per instance**. There is no cross-server account. + +### Getting an API key + +Unlike Immich, Jellyfin has **no admin-token bootstrap from nothing** — creating a key requires +an already-authenticated session. Two routes: + +- **UI:** Dashboard → API Keys → `+`. Simplest, and the one to use for first setup. +- **API:** `POST /Auth/Keys?App=` (verified: route exists, `401` unauthenticated), using a + token obtained from `POST /Users/AuthenticateByName` with an admin username/password + (verified: route exists; returns `415` if you omit the JSON content type). + +`GET /Auth/Keys` lists them (verified: exists, `401` unauthenticated). Keys are stored in +`config/data/jellyfin.db`, table `ApiKeys`. **This deployment currently has zero API keys** — one +must be created before any of the flow below will work. + +### The flow + +```http +GET /Users +Authorization: MediaBrowser Token="" +``` +→ verified: exists, `401` without auth. Use it as the **check** step (rule 2) — match on `Name`. + +```http +POST /Users/New +Authorization: MediaBrowser Token="" +Content-Type: application/json + +{ "Name": "Full Name", "Password": "" } +``` +→ verified: route exists (`401` unauthenticated). Body shape from working knowledge. Returns the +created user object including its `Id` (a GUID), which the next calls need. + +Then, as needed: + +| Purpose | Endpoint | Notes | +|---|---|---| +| Library access, admin rights, parental rating | `POST /Users/{id}/Policy` | Full policy object; **send the whole thing**, it replaces rather than merges. This is where `IsAdministrator` and `EnabledFolders` live. | +| Set/replace password | `POST /Users/{id}/Password` | Needed if you created the user without one. | +| Delete | `DELETE /Users/{id}` | Hard delete — no soft-delete/grace period, unlike Immich. | + +**Verify (rule 4)** with a real login rather than trusting the create: + +```http +POST /Users/AuthenticateByName +Content-Type: application/json + +{ "Username": "Full Name", "Pw": "" } +``` +→ returns an `AccessToken` and the `User` object on success. Note the field is `Pw`, not +`Password`, and Jellyfin authenticates by **username**, not email — Jellyfin has no concept of an +email login. + +### A public endpoint worth knowing + +`GET /Users/Public` needs **no authentication at all** (verified: `200`). It lists users flagged +as visible on the login screen. On this deployment it returns `[]` on all four instances, so +nothing is currently exposed — but if you provision users, be aware that leaving them +login-screen-visible publishes their names to anyone who can reach the port. + +### Probe recipe (Jellyfin) + +Same spirit as the Immich one, but you need a key first, and there is no soft-delete — so probe on +a **non-production instance** or be prepared to hard-delete: + +1. `GET /Users` with the key → `200` and an array. Wrong key → `401`. +2. `POST /Users/New` with a throwaway name → expect the created object; keep the `Id`. +3. Repeat the identical call → observe the duplicate-name behaviour and **record the exact status + and body here**, since it is the guard rule 2 depends on. +4. `POST /Users/AuthenticateByName` with the throwaway credentials → expect an `AccessToken`. +5. `DELETE /Users/{id}` → then `GET /Users` to confirm it is gone. + +--- + +## Re-verifying after an upstream upgrade + +Cheap and non-destructive — create a probe user on an unroutable domain, exercise it, delete it: + +1. `POST /api/admin/users` with `probe-delete-me@example.invalid` → expect `201`, keep the `id`. +2. Repeat the identical call → expect the duplicate-email `400`. +3. `POST` with `password`/`name` omitted → expect the validation `400`. +4. `POST` with a bogus key → expect `401`. +5. `DELETE /api/admin/users/{id}` with `{"force":true}` → expect `200`, then `GET` to confirm it's gone. + +Use `.invalid` (RFC 2606 — guaranteed never to resolve) so a stray notification email can never +reach a real person.