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

@@ -2,11 +2,11 @@ import { createRoute } from "@hono/zod-openapi";
import { Api, apiResponse, type EndpointDef } from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
import { errorBody } from "../../http/errors.ts";
import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts";
import type { ModuleApp } from "../types.ts";
import { FilesError, type FmDeps, fsErrorToFiles, isAdmin } from "./common.ts";
import { FilesError, type FmDeps, fsErrorToFiles } from "./common.ts";
import { downloadFile } from "./download.ts";
import { identify } from "./identify.ts";
import { idempotent } from "./ledger.ts";
@@ -31,7 +31,8 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler, raw = false)
path: def.path,
tags: ["files"],
summary: def.summary,
middleware: [requireScope(def.scope)] as const,
// auth:"session" 的端点(download / trash purge)由中间件统一只放行管理员控制台会话,与注册表标注同源
middleware: [requireScope(def.scope), ...(def.auth === "session" ? [requireAdminSession()] : [])] as const,
request: {
...(def.query ? { query: def.query as z.ZodObject } : {}),
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
@@ -97,11 +98,10 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.trashRestore, async (c, { body }) =>
idem(d, c, "trash_restore", body, () => restoreTrash(d, c.get("principal"), body)),
);
mount(app, F.trashPurge, async (c, { body }) => {
// 授权矩阵:清空回收站仅 admin 会话,API Key(含 admin scope)一律拒绝
if (!isAdmin(c.get("principal"))) throw new FilesError("forbidden", "清空回收站只允许控制台管理员会话");
return idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body));
});
// 授权矩阵:清空回收站仅 admin 会话(注册表 auth:"session",中间件执行),API Key 与 DEV key 一律拒绝
mount(app, F.trashPurge, async (c, { body }) =>
idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body)),
);
mount(app, F.identify, async (c, { body }) => identify(d, c.get("principal"), body));
mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body)));
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));