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>
244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import type { ProvisionableUser } from 'officerdb';
|
|
import { callUpstream, mintApiKey, type UpstreamTarget } from './upstream';
|
|
import { describePlan, normalize, planReconcile, type ImmichUser, type ReconcilePlan } from './reconcile';
|
|
|
|
// Carrying out the plan. `reconcile.ts` decides; this does, and does nothing else.
|
|
//
|
|
// ── Where this deliberately differs from the Jellyfin plugin ──
|
|
//
|
|
// Jellyfin's provisioning generates a password, spends it once on a sign-in, and discards it so that
|
|
// nobody ever learns it. That is right THERE for a reason it states: Jellyfin has no must-change-password
|
|
// flag, so a password handed to somebody stays valid indefinitely, and the deployment plan is that
|
|
// Jellyfin's own web UI is never exposed at all.
|
|
//
|
|
// Neither half of that holds for Immich:
|
|
//
|
|
// 1. **Immich HAS the flag.** `shouldChangePassword` is on both the create and the update DTO. A handed
|
|
// -over password can be made single-use in the only sense that matters — the account forces a change
|
|
// on first sign-in.
|
|
// 2. **Immich's own app is the point.** Background phone backup runs in Immich's mobile app, not in
|
|
// Officer, and it needs a password. An account whose password nobody knows can be browsed through
|
|
// Officer and can never back a phone up again. For a photo library that is most of the value.
|
|
//
|
|
// So this mints the API key AND reports the temporary password, once, in the response. The owner passes it
|
|
// on; the person signs into the Immich app and is immediately forced to set their own. Officer's key is
|
|
// unaffected by that change — `UserAdminService.update` hashes the new password and touches nothing else,
|
|
// so API keys and sessions both survive it (read from the running 3.1.0 image, not assumed).
|
|
//
|
|
// The password is returned and never logged. It is in exactly one place, the reply to the request that
|
|
// caused it, and it stops being useful the moment its owner signs in.
|
|
|
|
/** Long enough to be worthless to guess in the window before it is changed. */
|
|
const generatePassword = (): string => randomBytes(24).toString('base64url');
|
|
|
|
/**
|
|
* The storage quota given to an account Officer creates. `null` is Immich's "unlimited".
|
|
*
|
|
* Unlimited matches every account already on this instance, so a provisioned member is indistinguishable
|
|
* from one made by hand — a cap only on Officer-made accounts would be a rule to remember rather than a
|
|
* policy. Change this one constant to enforce one; Immich applies it server-side and the owner can still
|
|
* raise it per person from Immich's own admin screen.
|
|
*/
|
|
const QUOTA_BYTES: number | null = null;
|
|
|
|
export type ProvisionOutcome = {
|
|
linked: { userId: number; immichUserId: string; email: string }[];
|
|
created: { userId: number; immichUserId: string; email: string }[];
|
|
/**
|
|
* Per person: the key to store, and the temporary password to hand over. Keyed by Officer user id.
|
|
*
|
|
* Separate from `linked`/`created` because it is the half that must not be logged, and keeping it in its
|
|
* own structure makes that visible at every call site rather than depending on nobody dumping the outcome.
|
|
*/
|
|
credentials: { userId: number; email: string; apiKey: string; temporaryPassword: string }[];
|
|
/** Left alone, reported so the owner can see accounts Officer does not manage. */
|
|
untouched: ImmichUser[];
|
|
/** The account the API key belongs to — the owner's own. */
|
|
self: ImmichUser | null;
|
|
failed: { email: string; error: string }[];
|
|
/** One line per decision, for the log. Never contains a credential. */
|
|
log: string[];
|
|
};
|
|
|
|
/** Everything Immich knows about, as `GET /api/admin/users` reports it. */
|
|
export async function listImmichUsers(cfg: UpstreamTarget): Promise<ImmichUser[]> {
|
|
const res = await callUpstream(cfg, { path: '/api/admin/users' });
|
|
if (!res.ok) throw new Error(`GET /api/admin/users → ${res.status}`);
|
|
const body = (await res.json()) as { id: string; email: string; name?: string }[];
|
|
return body.map((u) => ({ id: u.id, email: u.email, name: u.name ?? '' }));
|
|
}
|
|
|
|
/**
|
|
* Who the configured API key authenticates as.
|
|
*
|
|
* Read rather than assumed, and the whole reason the owner's own Immich account does not end up reported
|
|
* as belonging to nobody.
|
|
*/
|
|
export async function whoAmI(cfg: UpstreamTarget): Promise<string | null> {
|
|
const res = await callUpstream(cfg, { path: '/api/users/me' });
|
|
if (!res.ok) return null;
|
|
const body = (await res.json()) as { id?: unknown };
|
|
return typeof body.id === 'string' ? body.id : null;
|
|
}
|
|
|
|
/** Admin-set a password on an account. Empty-bodied 200/204 either way; only the status matters. */
|
|
async function setPassword(cfg: UpstreamTarget, immichUserId: string, password: string): Promise<void> {
|
|
const res = await callUpstream(cfg, {
|
|
method: 'PUT',
|
|
path: `/api/admin/users/${immichUserId}`,
|
|
contentType: 'application/json',
|
|
// `shouldChangePassword: false` for THIS write: the sign-in that mints the key happens next, and the
|
|
// flag is raised afterwards. Setting it here would be asking the account to change a password in the
|
|
// same breath as using it.
|
|
body: JSON.stringify({ password, shouldChangePassword: false }),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`PUT /api/admin/users/${immichUserId} → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
}
|
|
}
|
|
|
|
/** Raise the must-change flag, once the key is safely in hand. */
|
|
async function requirePasswordChange(cfg: UpstreamTarget, immichUserId: string): Promise<void> {
|
|
const res = await callUpstream(cfg, {
|
|
method: 'PUT',
|
|
path: `/api/admin/users/${immichUserId}`,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ shouldChangePassword: true }),
|
|
});
|
|
// Not fatal. The key exists and the library is reachable; all that is lost is the prompt, and failing the
|
|
// whole provisioning over it would throw away a credential that already works.
|
|
if (!res.ok) {
|
|
console.error(`[photos:provision] could not set shouldChangePassword on ${immichUserId} → ${res.status}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Give one account a fresh password, spend it on an API key, then require it to be changed.
|
|
*
|
|
* Shared by the create and the adopt paths, which differ only in whether the account existed a moment ago.
|
|
* Both end in the same place: Officer holds a durable key, and one temporary password is on its way to a
|
|
* person who will be made to replace it.
|
|
*/
|
|
async function establishKey(
|
|
cfg: UpstreamTarget,
|
|
account: { immichUserId: string; email: string },
|
|
): Promise<{ apiKey: string; temporaryPassword: string }> {
|
|
const temporaryPassword = generatePassword();
|
|
await setPassword(cfg, account.immichUserId, temporaryPassword);
|
|
const apiKey = await mintApiKey(cfg.base, account.email, temporaryPassword);
|
|
await requirePasswordChange(cfg, account.immichUserId);
|
|
return { apiKey, temporaryPassword };
|
|
}
|
|
|
|
/**
|
|
* Issue a fresh temporary password for one account, without touching its API key.
|
|
*
|
|
* "This person is setting their phone up" — which is a thing that happens long after provisioning, and
|
|
* more than once. The alternative would be storing the password from the provisioning run so it could be
|
|
* read back later, and a stored password is exactly what this design exists to avoid: it would sit in
|
|
* Postgres indefinitely, still valid, for an account whose owner may already have chosen their own.
|
|
*
|
|
* Safe to call at any time. An Immich API key is independent of the password, so Officer's access is
|
|
* unaffected — the person simply gets a new way in, and is made to replace it on arrival.
|
|
*
|
|
* One call rather than two, unlike `establishKey`: nothing signs in with this password here, so the flag
|
|
* can go up in the same write that sets it.
|
|
*/
|
|
export async function reissuePassword(
|
|
cfg: UpstreamTarget,
|
|
account: { immichUserId: string; email: string },
|
|
): Promise<string> {
|
|
const temporaryPassword = generatePassword();
|
|
const res = await callUpstream(cfg, {
|
|
method: 'PUT',
|
|
path: `/api/admin/users/${account.immichUserId}`,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ password: temporaryPassword, shouldChangePassword: true }),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`PUT /api/admin/users/${account.immichUserId} → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
}
|
|
return temporaryPassword;
|
|
}
|
|
|
|
/** Create one account, then give it a key. */
|
|
async function createOne(cfg: UpstreamTarget, user: ProvisionableUser): Promise<{ immichUserId: string; email: string }> {
|
|
const res = await callUpstream(cfg, {
|
|
method: 'POST',
|
|
path: '/api/admin/users',
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
email: user.email,
|
|
// Set here and replaced moments later by `establishKey`. Immich requires one on create and this
|
|
// value never leaves the function, so there is nothing to hand over or to store.
|
|
password: generatePassword(),
|
|
name: user.name || user.username || user.email,
|
|
quotaSizeInBytes: QUOTA_BYTES,
|
|
shouldChangePassword: false,
|
|
// No email. Immich would try SMTP it may not have, and what it sends is a password — which is
|
|
// precisely the thing this flow keeps out of a mailbox.
|
|
notify: false,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`POST /api/admin/users → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
}
|
|
|
|
const created = (await res.json()) as { id?: string; email?: string };
|
|
if (!created.id) throw new Error('POST /api/admin/users returned no id');
|
|
return { immichUserId: created.id, email: created.email ?? user.email };
|
|
}
|
|
|
|
/**
|
|
* Reconcile Officer's accounts against Immich's, and carry out what is missing.
|
|
*
|
|
* Idempotent: run it twice and the second does nothing, because `existing` grows and the plan shrinks.
|
|
* Never deletes anything.
|
|
*/
|
|
export async function provision(params: {
|
|
cfg: UpstreamTarget;
|
|
users: ProvisionableUser[];
|
|
existing: { userId: number; immichUserId: string }[];
|
|
}): Promise<ProvisionOutcome> {
|
|
const { cfg, users, existing } = params;
|
|
|
|
const [immichUsers, adminImmichUserId] = await Promise.all([listImmichUsers(cfg), whoAmI(cfg)]);
|
|
const plan: ReconcilePlan = planReconcile({ users, immichUsers, existing, adminImmichUserId });
|
|
|
|
const outcome: ProvisionOutcome = {
|
|
linked: [],
|
|
created: [],
|
|
credentials: [],
|
|
untouched: plan.leave,
|
|
self: plan.self,
|
|
failed: [],
|
|
log: describePlan(plan),
|
|
};
|
|
|
|
// Adoptions first: recording a pairing touches nothing upstream, so it cannot fail halfway and leave
|
|
// Immich ahead of the mapping.
|
|
for (const { user, immich } of plan.link) {
|
|
outcome.linked.push({ userId: user.id, immichUserId: immich.id, email: immich.email });
|
|
}
|
|
|
|
for (const user of plan.create) {
|
|
try {
|
|
const made = await createOne(cfg, user);
|
|
outcome.created.push({ userId: user.id, immichUserId: made.immichUserId, email: made.email });
|
|
} catch (error) {
|
|
// One person failing must not stop the rest. A partially provisioned instance is recoverable by
|
|
// running again; a run that aborts on the first error leaves everyone after them unprovisioned.
|
|
outcome.failed.push({
|
|
email: user.email ?? String(user.id),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
return outcome;
|
|
}
|
|
|
|
export { establishKey, normalize };
|