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:
1 parent
9887d8b311
commit
17cf3cbf97
16 files changed
+587
-107
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user