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

@@ -162,16 +162,34 @@ export async function authorizeMove(
body: Parameters<typeof requestKey>[0],
entityId: string | null,
): Promise<MoveAuthorization> {
const r = await authorizeMoveEntities(d, principal, body, [{ label: "", entityId }]);
return r.allowed ? r : { allowed: false, reason: r.reason };
}
export type MoveAuthorizationMulti =
| { allowed: true; via: "admin_session" | "grant" | "approval"; id?: string; grantIds?: string[] }
| { allowed: false; reason: string; unauthorized: { label: string; entityId: string | null; reason: string }[] };
/**
* 多源 Move 授权(验收 07 S-1):一次性审批覆盖整个请求;否则**每个源**所属作品都必须被某条持续授权覆盖,
* 任一源无法归属作品(无从核对 grant 范围)或作品不在任何 grant 范围内 → 整体拒绝,并列出未授权项。
* 同一源可以对应多个作品(目录里登记了多个作品的资产),每个都要覆盖。
*/
export async function authorizeMoveEntities(
d: AoDeps,
principal: Principal | undefined,
body: Parameters<typeof requestKey>[0],
sources: { label: string; entityId: string | null }[],
opts: { consume?: boolean } = {},
): Promise<MoveAuthorizationMulti> {
const consume = opts.consume !== false;
if (principal?.kind === "admin_session" || principal?.kind === "dev_admin")
return { allowed: true, via: "admin_session" };
const db = d.ctx.db;
const now = d.ports.now();
const entity = entityId
? await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "=", entityId).executeTakeFirst()
: undefined;
const reasons: string[] = [];
// 1) 一次性审批
// 1) 一次性审批(绑定整个请求)
const key = requestKey(body);
const approvals = await db
.selectFrom("approval as a")
@@ -190,6 +208,8 @@ export async function authorizeMove(
reasons.push(`审批 ${a.id} 绑定的计划内容已变化`);
continue;
}
// 预检(不消费):执行阶段会再调一次并原子消费
if (!consume) return { allowed: true, via: "approval", id: a.id };
const used = await db
.updateTable("approval")
.set({ status: "consumed" })
@@ -199,45 +219,94 @@ export async function authorizeMove(
if (Number(used.numUpdatedRows ?? 0n) === 1) return { allowed: true, via: "approval", id: a.id };
}
// 2) 持续授权
if (!entity) {
reasons.push("无法确定目标作品,持续授权无从核对范围");
} else {
const grants = await db
.selectFrom("automation_grant as g")
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
.select(["g.id", "g.scope", "g.policy_revision_id", "p.status as policy_status"])
.where("g.status", "=", "active")
.where("g.action", "in", GRANT_ACTIONS)
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
.orderBy("g.created_at", "desc")
.execute();
for (const g of grants) {
if (g.policy_revision_id && g.policy_status !== "published") {
reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
continue;
// 2) 持续授权:逐个作品核对
const grants = await db
.selectFrom("automation_grant as g")
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
.select(["g.id", "g.scope", "g.policy_revision_id", "p.status as policy_status"])
.where("g.status", "=", "active")
.where("g.action", "in", GRANT_ACTIONS)
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
.orderBy("g.created_at", "desc")
.execute();
const ids = [...new Set(sources.map((s) => s.entityId).filter((x): x is string => !!x))];
const kinds = new Map(
ids.length
? (await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", ids).execute()).map((e) => [
e.id,
e.kind,
])
: [],
);
const coverCache = new Map<string, { ok: true; id: string } | { ok: false; reasons: string[] }>();
const cover = (entityId: string) => {
const hit = coverCache.get(entityId);
if (hit) return hit;
const kind = kinds.get(entityId);
const why: string[] = [];
let res: { ok: true; id: string } | { ok: false; reasons: string[] } | null = null;
if (!kind) why.push("作品不存在");
else
for (const g of grants) {
if (g.policy_revision_id && g.policy_status !== "published") {
why.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
continue;
}
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
if (scope.capabilities?.length && !scope.capabilities.includes("organize.execute")) {
why.push(`授权 ${g.id} 不含 organize.execute`);
continue;
}
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(entityId)) {
why.push(`授权 ${g.id} 的对象范围不含目标作品`);
continue;
}
if (scope.mediaKinds?.length && !kindCovered(scope.mediaKinds, kind)) {
why.push(`授权 ${g.id} 的媒体类型范围不含 ${kind}`);
continue;
}
res = { ok: true, id: g.id };
break;
}
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
if (scope.capabilities?.length && !scope.capabilities.includes("organize.execute")) {
reasons.push(`授权 ${g.id} 不含 organize.execute`);
continue;
}
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(entity.id)) {
reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
continue;
}
if (scope.mediaKinds?.length && !kindCovered(scope.mediaKinds, entity.kind)) {
reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${entity.kind}`);
continue;
}
return { allowed: true, via: "grant", id: g.id };
const out = res ?? { ok: false as const, reasons: why };
coverCache.set(entityId, out);
return out;
};
const unauthorized: { label: string; entityId: string | null; reason: string }[] = [];
const used = new Set<string>();
for (const s of sources) {
if (!s.entityId) {
unauthorized.push({
label: s.label,
entityId: null,
reason: "无法确定所属作品,持续授权无从核对范围(只接受一次性审批)",
});
continue;
}
const c = cover(s.entityId);
if (c.ok) used.add(c.id);
else
unauthorized.push({
label: s.label,
entityId: s.entityId,
reason: c.reasons.length ? c.reasons.join(";") : "没有覆盖该作品的持续授权",
});
}
if (sources.length > 0 && unauthorized.length === 0) {
const grantIds = [...used];
return { allowed: true, via: "grant", id: grantIds[0]!, grantIds };
}
const single = sources.length === 1 && !sources[0]!.label;
const detail = single
? [...reasons, ...unauthorized.map((u) => u.reason)]
: [...reasons, ...unauthorized.map((u) => `${u.label}(${u.entityId ?? "未归属作品"}):${u.reason}`)];
return {
allowed: false,
unauthorized,
reason:
reasons.length > 0
? `Move 需要授权:${reasons.join(";")}`
detail.length > 0 && (reasons.length > 0 || grants.length > 0 || !single || unauthorized[0]?.entityId === null)
? `Move 需要授权:${detail.join(";")}`
: "Move 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批",
};
}