Files
dockhand-upstream/src/lib/server/backups/route-guards.ts
T
2026-07-25 08:41:01 +02:00

119 lines
5.0 KiB
TypeScript

/**
* Shared authorization guard for snapshot-content API routes.
*
* The `backups:view` RBAC check alone is GLOBAL — on enterprise it does not
* distinguish which environment a snapshot belongs to, so a non-admin with
* backups:view in env A could otherwise read env-B snapshot content by supplying
* env-B's destination + snapshot id. This resolves the owning environment from
* the snapshot's own tag (server-side, not a caller-supplied `env` param) and
* enforces per-env access. Fails CLOSED when the env can't be resolved.
*/
import { resolveSnapshotEnvId, filterSnapshotsByAccessibleEnv } from './index';
import { getBackupConfig } from '../db';
import type { AuthorizationContext } from '../authorize';
import type { BackupConfig } from '../db';
// Local JSON response helper — identical output to SvelteKit's `json()` but with
// no `@sveltejs/kit` import, so this module (and its unit test) load in a plain
// `bun test` context without the Vite/SvelteKit resolution layer.
function json(data: unknown, init?: { status?: number }): Response {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
headers: { 'content-type': 'application/json' }
});
}
/**
* Shared RBAC check for backup routes: returns a 403 Response if the caller
* lacks `backups:<action>`, else null. Collapses the ~24 hand-copied
* `if (auth.authEnabled && !await auth.can('backups', ...)) return 403` blocks
* into one place.
*/
export async function requireBackups(
auth: AuthorizationContext,
action: 'view' | 'manage'
): Promise<Response | null> {
if (auth.authEnabled && !(await auth.can('backups', action))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
return null;
}
/**
* Load a backup config by id AND enforce per-environment access in ONE call, so
* a config-scoped route cannot forget the env gate. Returns either the config or
* a Response to return verbatim (404 not-found / 400 bad-id / 403 env-denied).
*
* This closes the gap where the configs/[id]/stop route had the `backups:manage`
* check but NOT the `canAccessEnvironment` gate its sibling run route has, so a
* user with global backups:manage and no role on the config's env could cancel
* an in-flight backup there. Routing every config-scoped handler through this
* guard makes the omission unrepresentable.
*/
export async function loadConfigGateEnv(
idParam: string,
auth: AuthorizationContext
): Promise<{ config: BackupConfig } | { response: Response }> {
const id = parseInt(idParam);
if (isNaN(id)) return { response: json({ error: 'Invalid ID' }, { status: 400 }) };
const config = await getBackupConfig(id);
if (!config) return { response: json({ error: 'Config not found' }, { status: 404 }) };
if (config.environmentId && auth.isEnterprise && !(await auth.canAccessEnvironment(config.environmentId))) {
return { response: json({ error: 'Access denied to this environment' }, { status: 403 }) };
}
return { config };
}
/**
* Returns a 403 Response if the caller may not access the snapshot's environment,
* otherwise null (access allowed). Free edition / admins have full env access so
* this is a no-op for them; only enterprise non-admins are gated.
*/
export async function guardSnapshotEnvAccess(
auth: AuthorizationContext,
destinationId: number,
snapshotId: string
): Promise<Response | null> {
// Only enterprise applies per-environment scoping; elsewhere backups:view already
// grants full access and there are no per-env boundaries to enforce.
if (!auth.isEnterprise) return null;
let resolution: { envId: number | null | undefined; resolved: boolean };
try {
resolution = await resolveSnapshotEnvId(destinationId, snapshotId);
} catch {
// Any failure resolving the snapshot's env → deny (fail closed).
return json({ error: 'Could not verify snapshot environment access' }, { status: 403 });
}
if (!resolution.resolved) {
// Unknown / unresolvable environment → deny.
return json({ error: 'Snapshot environment could not be resolved' }, { status: 403 });
}
// envId === null means an unscoped ('local') snapshot — no env gate to apply.
if (resolution.envId == null) return null;
if (!(await auth.canAccessEnvironment(resolution.envId))) {
return json({ error: 'Environment access denied' }, { status: 403 });
}
return null;
}
/**
* Drop snapshots whose owning environment the caller can't access, used by the
* destination-wide list branch which has no target/config filter. Resolves each
* snapshot's env from its own stable-id `dockhand:envid` tag (no restic round-trip
* per snapshot). Unscoped ('local') snapshots are kept; snapshots whose env can't
* be resolved are dropped (fail closed).
*/
export async function filterSnapshotsByEnvAccess<T extends { tags?: string[] }>(
auth: AuthorizationContext,
snapshots: T[]
): Promise<T[]> {
if (!auth.isEnterprise || snapshots.length === 0) return snapshots;
const tagged = snapshots.map((s) => ({ ...s, tags: s.tags ?? [] }));
return filterSnapshotsByAccessibleEnv(tagged, (envId) => auth.canAccessEnvironment(envId));
}