docs(plugins): add USER_PROVISIONING runbook
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 | `<base>/api` — e.g. `http://127.0.0.1:2283/api` |
|
||||||
|
| Auth | header `x-api-key: <ADMIN_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: <ADMIN_API_KEY>
|
||||||
|
```
|
||||||
|
→ `200`, JSON array. If the target email is present, stop — already provisioned.
|
||||||
|
|
||||||
|
### 2. Create
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/users
|
||||||
|
x-api-key: <ADMIN_API_KEY>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "person@example.com",
|
||||||
|
"password": "<generated>",
|
||||||
|
"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": "<generated>" }
|
||||||
|
```
|
||||||
|
→ `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: <key>` | `Authorization: MediaBrowser Token="<key>"` |
|
||||||
|
| 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=<name>` (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="<API_KEY>"
|
||||||
|
```
|
||||||
|
→ verified: exists, `401` without auth. Use it as the **check** step (rule 2) — match on `Name`.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /Users/New
|
||||||
|
Authorization: MediaBrowser Token="<API_KEY>"
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "Name": "Full Name", "Password": "<generated>" }
|
||||||
|
```
|
||||||
|
→ 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": "<generated>" }
|
||||||
|
```
|
||||||
|
→ 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.
|
||||||
Reference in New Issue
Block a user