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
@@ -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 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user