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:
+69
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user