Secure file handoff grants
This commit is contained in:
1 parent
ff6fcf879d
commit
eba83ef794
10 files changed
+437
-40
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||
import { RISK_LEVEL } from "../../shared/domain";
|
||||
import { FILE_SCAN_STATE, RISK_LEVEL } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
@@ -14,6 +14,14 @@ interface AttachmentCandidate {
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
interface FileAttachmentRow {
|
||||
id: string;
|
||||
sha256: string;
|
||||
scan_state: string;
|
||||
owner_type: string;
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
interface SpaceRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
@@ -93,7 +101,7 @@ export class ChatService {
|
||||
const slash = parseSlashInvocation(body);
|
||||
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
|
||||
if (slash) validateSlashInvocation(slash);
|
||||
const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? []);
|
||||
const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender);
|
||||
const mentions = this.resolveMentions(input.orgId, input.spaceId, body);
|
||||
|
||||
const id = createId("msg");
|
||||
@@ -127,6 +135,7 @@ export class ChatService {
|
||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null;
|
||||
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
||||
this.recordAttachments(id, attachments);
|
||||
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
|
||||
if (input.senderId) {
|
||||
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
|
||||
this.db.prepare(`
|
||||
@@ -244,6 +253,53 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private recordAttachmentGrants(
|
||||
input: SendMessageInput,
|
||||
messageId: string,
|
||||
threadId: string,
|
||||
attachments: AttachmentCandidate[],
|
||||
mentions: ResolvedMention[],
|
||||
now: string,
|
||||
): void {
|
||||
if (attachments.length === 0) return;
|
||||
const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, mentions);
|
||||
const statement = this.db.prepare(`
|
||||
insert or ignore into file_access_grants(
|
||||
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
||||
source_message_id, source_thread_id, access_level, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const attachment of attachments) {
|
||||
for (const recipient of recipients) {
|
||||
const result = statement.run(
|
||||
createId("fgr"),
|
||||
input.orgId,
|
||||
attachment.fileId,
|
||||
recipient.type,
|
||||
recipient.id,
|
||||
input.senderType,
|
||||
input.senderId,
|
||||
messageId,
|
||||
threadId,
|
||||
"read",
|
||||
now,
|
||||
);
|
||||
if (result.changes > 0) {
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "file_object",
|
||||
entityId: attachment.fileId,
|
||||
actorType: input.senderType,
|
||||
actorId: input.senderId,
|
||||
action: "access_granted",
|
||||
payload: { messageId, threadId, grantedToType: recipient.type, grantedToId: recipient.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireSpace(orgId: string, spaceId: string): SpaceRow {
|
||||
const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?")
|
||||
.get(spaceId, orgId) as SpaceRow | undefined;
|
||||
@@ -274,15 +330,68 @@ export class ChatService {
|
||||
if (!thread) throw new Error("Chat thread was not found in this space.");
|
||||
}
|
||||
|
||||
private requireAttachments(orgId: string, fileIds: string[]): AttachmentCandidate[] {
|
||||
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] {
|
||||
if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique.");
|
||||
return fileIds.map((fileId) => {
|
||||
const file = this.db.prepare("select sha256 from file_objects where id = ? and org_id = ?")
|
||||
.get(fileId, orgId) as { sha256: string } | undefined;
|
||||
const file = this.db.prepare(`
|
||||
select id, sha256, scan_state, owner_type, owner_id
|
||||
from file_objects
|
||||
where id = ? and org_id = ?
|
||||
`).get(fileId, orgId) as FileAttachmentRow | undefined;
|
||||
if (!file) throw new Error("Attachment file was not found.");
|
||||
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff.");
|
||||
if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required.");
|
||||
return { fileId, checksum: file.sha256 };
|
||||
});
|
||||
}
|
||||
|
||||
private senderCanReadFile(orgId: string, file: FileAttachmentRow, sender: PrincipalRef | null): boolean {
|
||||
if (!sender) return true;
|
||||
if (file.owner_type === sender.type && file.owner_id === sender.id) return true;
|
||||
const workspaceGroupId = `${orgId}:workspace`;
|
||||
const grant = this.db.prepare(`
|
||||
select id from file_access_grants
|
||||
where org_id = ?
|
||||
and file_id = ?
|
||||
and access_level in ('read', 'manage')
|
||||
and (
|
||||
(granted_to_type = ? and granted_to_id = ?)
|
||||
or (granted_to_type = 'org_group' and granted_to_id = ?)
|
||||
)
|
||||
limit 1
|
||||
`).get(orgId, file.id, sender.type, sender.id, workspaceGroupId) as { id: string } | undefined;
|
||||
return Boolean(grant);
|
||||
}
|
||||
|
||||
private attachmentGrantRecipients(orgId: string, spaceId: string, mentions: ResolvedMention[]): MentionTarget[] {
|
||||
const source = mentions.length > 0 ? mentions.map((mention) => mention.target) : this.spaceRecipients(spaceId);
|
||||
const seen = new Set<string>();
|
||||
const recipients: MentionTarget[] = [];
|
||||
for (const target of source) {
|
||||
const key = `${target.type}:${target.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
recipients.push(target);
|
||||
}
|
||||
if (recipients.length === 0) throw new Error("Attachment grant recipient is required.");
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private spaceRecipients(spaceId: string): MentionTarget[] {
|
||||
const rows = this.db.prepare(`
|
||||
select principal_type, principal_id
|
||||
from chat_memberships
|
||||
where space_id = ?
|
||||
order by principal_type asc, principal_id asc
|
||||
`).all(spaceId) as Array<{ principal_type: string; principal_id: string }>;
|
||||
return rows.map((row) => {
|
||||
if (row.principal_type === "employee" || row.principal_type === "agent") {
|
||||
return { type: row.principal_type, id: row.principal_id };
|
||||
}
|
||||
throw new Error("Chat membership principal type is unsupported.");
|
||||
});
|
||||
}
|
||||
|
||||
private requireMembership(spaceId: string, principal: PrincipalRef): void {
|
||||
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = ? and principal_id = ?")
|
||||
.get(spaceId, principal.type, principal.id) as { space_id: string } | undefined;
|
||||
|
||||
@@ -4,9 +4,16 @@ import { basename, extname, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { dialog } from "electron";
|
||||
import type { ImportFileInput } from "../../shared/commands";
|
||||
import { FILE_SCAN_STATE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface FileScanResult {
|
||||
state: typeof FILE_SCAN_STATE.APPROVED;
|
||||
labels: string[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
function fileKind(path: string): string {
|
||||
const ext = extname(path).toLowerCase().replace(".", "");
|
||||
if (!ext) return "file";
|
||||
@@ -43,41 +50,54 @@ export class FileService {
|
||||
const now = new Date().toISOString();
|
||||
const stats = statSync(sourcePath);
|
||||
if (!stats.isFile()) throw new Error("Selected path is not a file.");
|
||||
const hash = createHash("sha256").update(readFileSync(sourcePath)).digest("hex");
|
||||
const bytes = readFileSync(sourcePath);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex");
|
||||
const scan = scanFile(sourcePath, bytes);
|
||||
const orgDir = join(this.artifactRoot, orgId);
|
||||
mkdirSync(orgDir, { recursive: true });
|
||||
const target = join(orgDir, `${id}-${basename(sourcePath)}`);
|
||||
copyFileSync(sourcePath, target);
|
||||
this.db.prepare(`
|
||||
insert into file_objects(
|
||||
id, org_id, name, kind, size_bytes, sha256, storage_path,
|
||||
owner_type, owner_id, space_id, pinned, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
orgId,
|
||||
basename(sourcePath),
|
||||
fileKind(sourcePath),
|
||||
stats.size,
|
||||
hash,
|
||||
target,
|
||||
"system",
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "file_object",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "imported",
|
||||
payload: { sha256: hash, sizeBytes: stats.size },
|
||||
});
|
||||
this.db.exec("begin immediate");
|
||||
try {
|
||||
this.db.prepare(`
|
||||
insert into file_objects(
|
||||
id, org_id, name, kind, size_bytes, sha256, storage_path,
|
||||
owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
orgId,
|
||||
basename(sourcePath),
|
||||
fileKind(sourcePath),
|
||||
stats.size,
|
||||
hash,
|
||||
target,
|
||||
"system",
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
scan.state,
|
||||
JSON.stringify(scan.labels),
|
||||
scan.scannedAt,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.grantWorkspaceAccess(orgId, id, now);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "file_object",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "imported",
|
||||
payload: { sha256: hash, sizeBytes: stats.size, scanState: scan.state },
|
||||
});
|
||||
this.db.exec("commit");
|
||||
} catch (error) {
|
||||
this.db.exec("rollback");
|
||||
throw error;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -96,4 +116,45 @@ export class FileService {
|
||||
payload: { fileId },
|
||||
});
|
||||
}
|
||||
|
||||
private grantWorkspaceAccess(orgId: string, fileId: string, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into file_access_grants(
|
||||
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
||||
source_message_id, source_thread_id, access_level, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("fgr"),
|
||||
orgId,
|
||||
fileId,
|
||||
"org_group",
|
||||
`${orgId}:workspace`,
|
||||
"system",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"manage",
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scanFile(sourcePath: string, bytes: Buffer): FileScanResult {
|
||||
const sample = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1");
|
||||
if (sample.includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) {
|
||||
throw new Error("File scan blocked a known malware signature.");
|
||||
}
|
||||
return {
|
||||
state: FILE_SCAN_STATE.APPROVED,
|
||||
labels: scanLabels(sourcePath, bytes.length),
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function scanLabels(sourcePath: string, sizeBytes: number): string[] {
|
||||
const labels = new Set<string>([`kind:${fileKind(sourcePath)}`]);
|
||||
if (sizeBytes === 0) labels.add("empty");
|
||||
if (sizeBytes > 100 * 1024 * 1024) labels.add("large");
|
||||
return [...labels].sort();
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const LEGACY_TABLES = [
|
||||
"skill_runtime_snapshots",
|
||||
"skill_catalog_tags",
|
||||
"file_objects",
|
||||
"file_access_grants",
|
||||
"social_accounts",
|
||||
"social_account_grants",
|
||||
"browser_actions",
|
||||
@@ -136,6 +137,10 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "skill_catalog_tags", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" },
|
||||
{ table: "file_objects", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "file_objects", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
{ table: "file_access_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "file_access_grants", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||
{ table: "file_access_grants", column: "source_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_source_message_id" },
|
||||
{ table: "file_access_grants", column: "source_thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_source_thread_id" },
|
||||
{ table: "social_accounts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_accounts", column: "authorized_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_authorized_employee_id" },
|
||||
{ table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
|
||||
@@ -68,6 +68,7 @@ export class SnapshotService {
|
||||
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||
files,
|
||||
fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"),
|
||||
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
||||
providers,
|
||||
providerCatalog: PROVIDER_CATALOG,
|
||||
|
||||
Reference in new issue
Block a user