fix(files,security): S-1 per-source Move authorization, S-2 session-only annotation, S-5 restore guards

S-1 (/files/move, /files/organize action=move):
- organize-guard: authorizeMoveEntities checks every source's media entities
  (declared entity plus entities of registered assets under the source);
  one-time approval still covers the whole request; any source that cannot
  be attributed to an entity only passes with an approval; denial lists every
  unauthorized item; optional non-consuming pre-check
- transfer: entities per source (directory = registered assets beneath it)
- organize: all move groups pre-checked before any group executes; any
  denial -> whole request 403 approval_required
S-2:
- api.ts: GET /files/download and POST /files/trash/purge marked
  auth:"session"; files routes attach requireAdminSession from the registry
  annotation; handler-side purge check removed, download keeps a same-rule
  defense check; permanent delete uses isAdminSession
- DEV key rule written down: equals an admin-scope key for session-only
  endpoints, still admin for Move/delete approval bypass (as authorizeMove)
- api-registry.test: session list updated; new reverse assertion that no
  non-session endpoint answers 403 to an admin-scope bearer
S-5 (trash restore):
- restore-to requires a writable managed library root (isWritableTarget)
- in-place restore rebuilds missing parents level by level without
  following symlinks (mkdirWithin), realpath-checked per level
- cross-volume restore capped by FILES_TRASH_CROSS_MAX_BYTES; cross-volume
  copies (trash in / restore out) go through fsproxy
Tests: multi-entity move 403 + listing, unattributed source, organize foreign
asset 403, download/purge 403 for DEV and admin-scope keys, restore target
not writable, symlinked parent, rebuild parents, cross-volume cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 00:45:56 +08:00
1 parent 9887d8b311
commit 17cf3cbf97
16 files changed
+587 -107

No files matched your search

+32 -4
View File
@@ -2,7 +2,14 @@ import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { capabilitySpec, DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import {
CSRF_HEADER,
capabilitySpec,
DEFAULT_SETTINGS,
GlobalSettings,
SESSION_COOKIE,
type SettingsKey,
} from "@umt/contracts";
import { type DB, migrateToLatest } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { Kysely, PostgresDialect, sql } from "kysely";
@@ -10,7 +17,7 @@ import pg from "pg";
import { ConfigSchema } from "../../src/config.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { hashApiKey } from "../../src/http/auth.ts";
import { deriveCsrfToken, hashApiKey, hashSessionToken } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
@@ -25,9 +32,14 @@ export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:
export const DEV_KEY = "dev-admin-fm-test-key";
export const READ_KEY = "fm-organize-read-key";
export const EXEC_KEY = "fm-organize-exec-key";
export const ADMIN_KEY = "fm-admin-scope-key";
/** 传给 api()/raw() 的 key:改用管理员控制台会话 cookie(+CSRF),而不是 Bearer */
export const SESSION = "__admin_session__";
const SESSION_TOKEN = "fm-test-admin-session-token-0123456789";
const KEYS = [
{ key: READ_KEY, scopes: ["organize.read"] },
{ key: EXEC_KEY, scopes: ["organize.execute"] },
{ key: ADMIN_KEY, scopes: ["admin"] },
];
export async function pgAvailable() {
@@ -188,8 +200,24 @@ export async function createFmHarness(
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
},
async touch() {},
async findSession(hash) {
if (hash !== hashSessionToken(SESSION_TOKEN)) return null;
const now = new Date();
return {
id: "00000000-0000-4000-8000-00000000a001",
adminUserId: "00000000-0000-4000-8000-00000000a002",
username: "admin",
expiresAt: new Date(now.getTime() + 3600_000),
revokedAt: null,
lastSeenAt: now,
};
},
},
});
const authHeaders = (key: string): Record<string, string> =>
key === SESSION
? { cookie: `${SESSION_COOKIE}=${SESSION_TOKEN}`, [CSRF_HEADER]: deriveCsrfToken(SESSION_TOKEN) }
: { authorization: `Bearer ${key}` };
const root = await db
.insertInto("library_root")
@@ -219,7 +247,7 @@ export async function createFmHarness(
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...authHeaders(key),
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
@@ -227,7 +255,7 @@ export async function createFmHarness(
return { status: res.status, body: await res.json() };
},
raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } }));
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) }));
},
async close() {
await db.destroy();