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:
parent
7cea73dc0d
commit
7ea0b1e137
6 changed files with 375 additions and 22 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue