Files
platform/plugins/USER_PROVISIONING.md
pastilhasandClaude Opus 5 1cac61aa00 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>
2026-08-20 18:49:55 +00:00

9.8 KiB

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.


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)

GET /api/admin/users
x-api-key: <ADMIN_API_KEY>

→ 200, JSON array. If the target email is present, stop — already provisioned.

2. Create

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:

{ "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

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

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

GET /Users
Authorization: MediaBrowser Token="<API_KEY>"

→ verified: exists, 401 without auth. Use it as the check step (rule 2) — match on Name.

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:

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.