Phase two: archive origin facet, redaction-aware reads, share revocation on redaction

- group-files: origin filter facet; GET /files/:id returns 410 for unavailable
  rows after the ACL recheck; fix latent group_members.status filter and unbound
  workspace parameter that would 500 listFiles
- archive-bot: markRedacted revokes live group_file_shares rows in-transaction
- new live-DB integration suite for the archive index surface
This commit is contained in:
glass beadian 2026-10-11 21:44:23 +00:00
parent 7cea73dc0d
commit 7ea0b1e137
6 changed files with 375 additions and 22 deletions

View file

@ -30,7 +30,7 @@ import { isUuid, parseLimit } from "../http/validation.js";
import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js";
import { ocisConfigFromEnv, OcisWebdavClient, type FetchLike, type OcisDownload } from "./ocis-webdav.js";
import { hashShareToken, mintShareToken } from "./share-token.js";
import { MIME_CLASSES, parseRetentionInput, type MimeClass } from "./group-files.validation.js";
import { MIME_CLASSES, ORIGIN_KINDS, parseRetentionInput, type MimeClass, type OriginKind } from "./group-files.validation.js";
export const GROUP_FILES_DEPENDENCIES: unique symbol = Symbol("GROUP_FILES_DEPENDENCIES");
@ -55,6 +55,8 @@ export interface FileListFilters {
groupId: string | null;
senderMxid: string | null;
mimeClass: MimeClass | null;
/** SharedFileOrigin facet: matrix | bridge | upload | pad-export. */
origin: OriginKind | null;
name: string | null;
from: string | null;
to: string | null;
@ -215,13 +217,19 @@ export class GroupFilesService {
const limit = parseLimit(query.limit, 50, 100);
const cursor = typeof query.cursor === "string" && query.cursor !== "" ? decodeFileListCursor(query.cursor) : undefined;
return withTenant(workspaceId, async (client) => {
// values[1] is always the caller's id: the membership subquery needs it
// for non-Owner/Admin callers; Owner/Admin leave $2 bound but unused so
// every placeholder stays positional and no SQL text carries a value.
const values: unknown[] = [workspaceId, userId];
const conds: string[] = ["a.available = true", "(a.retention_mode <> 'until' OR a.retention_until > now())"];
// $1 is the explicit workspace scope; RLS (app.workspace_id) enforces the
// same tenant inside withTenant, so the two agree. Every value is bound
// positionally — no SQL text ever carries a value, and no parameter is
// left unused (Postgres cannot type an unused placeholder).
const values: unknown[] = [workspaceId];
const conds: string[] = [
"a.workspace_id = $1::uuid",
"a.available = true",
"(a.retention_mode <> 'until' OR a.retention_until > now())",
];
if (!WORKSPACE_WIDE_ROLES.includes(role)) {
conds.push("a.group_id IN (SELECT group_id FROM group_members WHERE user_id = $2 AND status = 'active')");
values.push(userId);
conds.push(`a.group_id IN (SELECT group_id FROM group_members WHERE user_id = $${values.length})`);
}
if (filters.groupId !== null) {
values.push(filters.groupId);
@ -235,6 +243,10 @@ export class GroupFilesService {
values.push(filters.mimeClass);
conds.push(`a.mime_class = $${values.length}`);
}
if (filters.origin !== null) {
values.push(filters.origin);
conds.push(`a.origin = $${values.length}`);
}
if (filters.name !== null) {
values.push(`%${filters.name}%`);
conds.push(`a.safe_name ILIKE $${values.length}`);
@ -269,10 +281,17 @@ export class GroupFilesService {
});
}
/**
* Single archived-media read. Unavailable rows never surface: the ACL
* recheck runs first (so a non-member learns nothing about the row), then a
* redacted (available = false) row answers Gone — the same exclusion the
* list applies ("only available rows visible through current ACLs").
*/
async getFile(workspaceId: string, userId: string, role: string, itemId: string): Promise<GroupFile> {
return withTenant(workspaceId, async (client) => {
const row = await this.requireRow(client, itemId);
await this.requireAccess(client, userId, role, row.group_id);
if (!row.available) throw new GoneException("file is revoked");
return rowToGroupFile(row, (resourceId) => this.webdav().libraryUrl(resourceId));
});
}
@ -496,7 +515,7 @@ export class GroupFilesService {
private async requireAccess(client: PoolClient, userId: string, role: string, groupId: string): Promise<void> {
if (WORKSPACE_WIDE_ROLES.includes(role)) return;
const result = await client.query(
"SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 AND status = 'active'",
"SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2",
[groupId, userId],
);
if ((result.rowCount ?? 0) === 0) throw new ForbiddenException("file belongs to a group you are not a member of");
@ -534,7 +553,7 @@ export class GroupFilesService {
): Promise<GroupFile> {
if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) {
const membership = await withTenant(workspaceId, (client) =>
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 AND status = 'active' LIMIT 1", [groupId, actor.userId]),
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", [groupId, actor.userId]),
);
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
}
@ -605,11 +624,15 @@ function parseFilters(query: Record<string, unknown>): FileListFilters {
if (rawClass !== null && !(MIME_CLASSES as readonly string[]).includes(rawClass)) {
throw new BadRequestException(`mimeClass must be one of: ${MIME_CLASSES.join(", ")}`);
}
const rawOrigin = typeof query.origin === "string" && query.origin !== "" ? query.origin : null;
if (rawOrigin !== null && !(ORIGIN_KINDS as readonly string[]).includes(rawOrigin)) {
throw new BadRequestException(`origin must be one of: ${ORIGIN_KINDS.join(", ")}`);
}
const name = typeof query.name === "string" && query.name.trim() !== "" ? query.name.trim().slice(0, 255) : null;
const from = parseBound(query.from, "from");
const to = parseBound(query.to, "to");
if (from !== null && to !== null && Date.parse(from) > Date.parse(to)) throw new BadRequestException("from must not be after to");
return { groupId, senderMxid, mimeClass: rawClass as MimeClass | null, name, from, to };
return { groupId, senderMxid, mimeClass: rawClass as MimeClass | null, origin: rawOrigin as OriginKind | null, name, from, to };
}
function optionalUuid(value: unknown): string | null {