ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag defaulted to ON, so none of it ran on a real install — what comes out is documented defence in depth that was already switched off. The file said so itself: "Both flags and their call sites come out once the tailnet is the perimeter." Origin was never authentication here in any case. An app's `officer://<hex>` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt, originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which existed only to pin them. CORS now echoes whatever Origin it is given, which is what every install already did. What SURVIVES is the reason this needed care. origin-validation.ts held two unrelated things, and the second was the global authorization gate — a valid non-owner token reaches only what its role grants, deliberately NOT under the flag because it is account-based rather than origin-based. Its own comment called it "the airtight half". Deleting the file wholesale would have deleted authorization. So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with the name matching what it does: nothing in it reads an Origin header any more. hono.ts mounts it in the same position, ahead of every router. origin-middleware.ts stays and is untouched — it extracts the Origin for six auth handlers that log it, and for passkeys. Extraction, not validation. Also updates every claim that rested on the old model: CLAUDE.md's security section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five messages in machine-setup's Tailscale section which told the owner to set ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to follow, and the honest version is different: with no tailnet the token is the whole lock, so put a proxy in front and restrict who can reach it. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes four variables now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
7.9 KiB
TypeScript
148 lines
7.9 KiB
TypeScript
import type { MiddlewareHandler } from 'hono';
|
|
import { createRouter } from '../../create-router';
|
|
import * as errors from '@@/custom-errors';
|
|
import { isSuperAdmin } from '../../super-admin';
|
|
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
|
import type { UserRole } from 'officerdb';
|
|
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
|
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
|
import { capabilityAvailability } from '../../app-store/availability';
|
|
|
|
// Two audiences, deliberately split.
|
|
//
|
|
// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
|
|
// app registry and the route guards all read it, so it is the frontend's whole view of the permission
|
|
// model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the
|
|
// 403 from the capability gate is the lock.
|
|
//
|
|
// Everything else here is owner-only and edits the policy itself.
|
|
|
|
const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
|
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Capability management is owner-only');
|
|
return next();
|
|
};
|
|
|
|
/** What the caller may reach. Mounted under /api/user, which is a `core` capability, so nobody is 403'd. */
|
|
export const selfCapabilitiesRouter = createRouter();
|
|
|
|
selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
|
const userId = ctx.get('user').id as number;
|
|
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
|
|
|
// What EXISTS on this server, which is a different question from what this account may use. A
|
|
// capability the owner holds unconditionally still means nothing if its sidecar was never installed,
|
|
// and the owner is as subject to that as a member — see app-store/availability.ts.
|
|
const { unavailable, manifests } = await capabilityAvailability();
|
|
|
|
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
|
|
// remember to special-case. One shape for both audiences means one code path in the UI.
|
|
const held = isOwner
|
|
? CAPABILITIES.map((c) => ({ key: c.key, level: 'write' as const }))
|
|
: [...grants].map(([key, level]) => ({ key, level }));
|
|
|
|
const heldKeys = new Set(held.map((h) => h.key));
|
|
// Held AND present. Two subtractions rather than one because they mean different things to the UI: a
|
|
// capability withheld is "not yours", one whose sidecar is absent is "not here yet, install it".
|
|
const usable = held.filter(({ key }) => !unavailable.has(key));
|
|
|
|
return ctx.json({
|
|
isOwner,
|
|
capabilities: held,
|
|
/** Capabilities the account holds whose sidecar is not installed or is disabled. */
|
|
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
|
|
/**
|
|
* Dock tiles and routes belonging to installed sidecars the account may reach.
|
|
*
|
|
* Filtered by capability here rather than in the client: a member must not be handed the manifest
|
|
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
|
|
* privacy that lasts until someone opens the network tab.
|
|
*/
|
|
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)),
|
|
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
|
|
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
|
|
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
|
|
// this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in
|
|
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
|
|
// Routes of capabilities this account does not hold, PLUS those whose sidecar is not installed. The
|
|
// guard treats both the same — there is nothing to show — while `unavailable` above lets the UI
|
|
// explain the second case as something the owner can fix by installing it.
|
|
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key) || unavailable.has(c.key)).flatMap(
|
|
(c) => c.routes ?? [],
|
|
),
|
|
});
|
|
});
|
|
|
|
/** Policy administration. Owner-only, mounted under /api/users. */
|
|
export const capabilityAdminRouter = createRouter();
|
|
|
|
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
|
// Only what this server can actually do RIGHT NOW.
|
|
//
|
|
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
|
|
// fourteen app capabilities on a fresh install where none of their sidecars existed. Offering to grant
|
|
// Photos on a machine with no Immich is not a permission decision, it is a menu of things that would
|
|
// 403 for a different reason than the owner thinks.
|
|
//
|
|
// Fail open on a degraded read: `capabilityAvailability` returns an empty `unavailable` set when it
|
|
// cannot see install state, so the list falls back to everything rather than to nothing. An owner whose
|
|
// Permissions screen emptied itself because one query failed would reasonably conclude the feature broke.
|
|
const { unavailable } = await capabilityAvailability();
|
|
|
|
const describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
|
|
key: c.key,
|
|
label: c.label,
|
|
description: c.description,
|
|
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
|
routes: c.routes ?? [],
|
|
hasPersonalWrites: !!c.personal?.length,
|
|
});
|
|
|
|
return ctx.json({
|
|
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
|
|
// shows a checkbox it will refuse to honour is worse than one that never offered it.
|
|
capabilities: GRANTABLE_CAPABILITIES.filter((c) => !unavailable.has(c.key)).map(describe),
|
|
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
|
// database refuses a row for that role.
|
|
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
|
grants: await getAllRoleGrants(),
|
|
});
|
|
});
|
|
|
|
capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
|
|
const role = ctx.req.param('role') as UserRole;
|
|
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
|
if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants');
|
|
|
|
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
|
const raw = body?.grants;
|
|
if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ capability, level }] }');
|
|
|
|
const grants: { capability: string; level: 'read' | 'write' }[] = [];
|
|
for (const entry of raw) {
|
|
const { capability, level } = (entry ?? {}) as { capability?: unknown; level?: unknown };
|
|
if (typeof capability !== 'string') throw errors.BAD_REQUEST('Each grant needs a capability key');
|
|
if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${capability}'`);
|
|
|
|
// The registry is the authority on what a capability key means, which is why the column has no CHECK.
|
|
// This is where that authority is applied — rejecting a name nothing defines, and refusing to store a
|
|
// grant the resolver would drop on read anyway.
|
|
const known = CAPABILITY_BY_KEY.get(capability);
|
|
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
|
|
if (known.kind !== 'app' && known.kind !== 'confined') {
|
|
throw errors.BAD_REQUEST(
|
|
known.kind === 'execution'
|
|
? `${known.label} runs as the server owner and can never be granted`
|
|
: `${known.label} is not grantable`,
|
|
);
|
|
}
|
|
grants.push({ capability, level });
|
|
}
|
|
|
|
await replaceRoleGrants(role, grants);
|
|
// The cache's entire invalidation contract, discharged here. Adding a second writer means adding a
|
|
// second call to this — see the note on grantCache in capabilities/authorize.ts.
|
|
invalidateRoleGrants(role);
|
|
|
|
return ctx.json({ role, grants });
|
|
});
|