give each member their own immich account and key

Photos had no user provisioning at all: one registry row, the owner's, and
every other account resolving to nothing. Same shape as the Jellyfin gap, but
this one was never built rather than built and dropped.

photos_accounts records who on Officer is who on Immich, and carries that
person's own API key. getConfig falls back to it when the caller has no
registry of their own, and there is no path from there to the owner's key —
an Immich key with permissions:['all'] can read and delete the whole library,
so a member resolving to it would be administering everyone's photographs
rather than reading their own.

Matching is on email, which Immich enforces uniqueness on and Officer already
holds. That is exact where the Jellyfin side has to go through an uppercased
username index and cope with two Officer names colliding into one.

Two deliberate differences from the Jellyfin flow:

The admin's own account is reported as itself, not as "no Officer account".
That bug shipped there and was fixed in 591c0da; there was no reason to write
it twice, so reconcile takes the key's identity from /api/users/me and the
test pins it.

And the temporary password is HANDED OVER rather than discarded. Jellyfin
discards because it has no must-change-password flag, so anything handed over
stays valid forever. Immich has one, and Immich's own app is where phone
backup runs — discarding would leave a photo account that can never back a
phone up again. So: set a password, spend it on a key, raise
shouldChangePassword, return it once. POST /_config/:id/handover re-issues on
demand, because "setting up my phone" happens long after provisioning and more
than once. Verified against the running 3.1.0 image that an API key survives a
password change, so re-issuing never costs Officer its access.

Applied on alpha: conceiba and fedra linked, guipinto and diogo created, all
four keyed and each resolving to their own Immich identity. jrg.conceicao left
alone. Members still need the photos grant on the Member role before the
permission gate lets them through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:46:13 +00:00
co-authored by Claude Fable 5
parent 0b9e4f32db
commit a83da42a1f
11 changed files with 977 additions and 7 deletions
+69 -1
View File
@@ -3,12 +3,15 @@ import {
createPhotosAccount,
deletePhotosAccount,
getPhotosCredentials,
listPhotosAccountLinks,
listPhotosAccounts,
recordPhotosProbe,
setActivePhotosAccount,
updatePhotosAccount,
} from '../db/queries';
import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream';
import { backfillAccountKeys, runProvisioning } from './provision-run';
import { reissuePassword } from './provision';
// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server.
//
@@ -152,13 +155,68 @@ async function addAccount(req: Request, userId: number): Promise<Response> {
try {
const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate });
invalidateConfig(userId);
return Response.json({ account, user: result.user });
// ── Provision, if this connection can ──
//
// The trigger is registering an ADMIN connection, not installing the plugin: at install time there is
// no credential, so there is nothing to provision with. Awaited, so the response says what happened —
// and, importantly, carries the temporary passwords, which exist nowhere else.
const provisioning = await runProvisioning({ cfg: { base: url, key: apiKey }, configId: account.id });
return Response.json({ account, user: result.user, provisioning });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
throw err;
}
}
/**
* A fresh one-time password for one person, so they can set up Immich's own app.
*
* Returned here and nowhere else. Not logged, not stored, and not readable back — asking again issues a
* new one and invalidates the last, which is the correct behaviour for a credential whose whole life is
* the walk from this reply to the person it belongs to.
*/
async function handover(req: Request, userId: number, id: number): Promise<Response> {
const creds = await getPhotosCredentials(userId, id);
if (!creds) return bad('no such account', 404);
const body = ((await req.json().catch(() => null)) as { userId?: unknown } | null) ?? {};
const target = Number(body.userId);
if (!Number.isInteger(target) || target <= 0) return bad('userId is required');
const links = await listPhotosAccountLinks(id);
const account = links.find((l) => l.userId === target);
if (!account) return bad('that account is not provisioned on this instance', 404);
const temporaryPassword = await reissuePassword(
{ base: normalizeBase(creds.url), key: creds.apiKey },
{ immichUserId: account.immichUserId, email: account.immichEmail },
);
return Response.json({ email: account.immichEmail, temporaryPassword });
}
/**
* Re-run provisioning against an instance already registered, and mint whatever keys are missing.
*
* The repair action, and the one that carries hand-over passwords back. `addAccount` does this once, at
* connect; this is the same work without needing the owner to re-enter a credential.
*
* Owner-only WITHOUT a permission rule, and worth saying out loud: every `/_config` route resolves its
* account through `getPhotosCredentials(userId, id)`, scoped to the caller's own rows. A member holding
* `photos` at write reaches this handler and gets a 404, because they have no registry of their own — the
* key they read the library with lives on their link row, not here.
*/
async function reprovision(userId: number, id: number): Promise<Response> {
const creds = await getPhotosCredentials(userId, id);
if (!creds) return bad('no such account', 404);
const cfg = { base: normalizeBase(creds.url), key: creds.apiKey };
const provisioning = await runProvisioning({ cfg, configId: id });
const keys = await backfillAccountKeys({ cfg, configId: id });
return Response.json({ provisioning, keys });
}
/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
const body = await readBody(req);
@@ -213,6 +271,16 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
if (action === 'provision') {
if (req.method !== 'POST') return bad('method not allowed', 405);
return reprovision(userId, id);
}
if (action === 'handover') {
if (req.method !== 'POST') return bad('method not allowed', 405);
return handover(req, userId, id);
}
if (action === 'activate') {
if (req.method !== 'POST') return bad('method not allowed', 405);
const account = await setActivePhotosAccount(userId, id);