Add immutable social audit records

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:23:44 -04:00
1 parent eab282d8b1
commit a21d35a165
12 files changed
+254 -6

No files matched your search

@@ -0,0 +1,62 @@
export const socialAuditRecordsSchema = `
create table if not exists social_audit_records (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
social_account_id text references social_accounts(id) on delete set null,
source_type text not null,
source_id text,
event_type text not null,
actor_type text not null,
actor_id text,
policy_decision text not null,
evidence_file_id text references file_objects(id) on delete set null,
metadata_json text not null,
created_at text not null,
unique(source_type, source_id, event_type)
);
insert into social_audit_records(
id, org_id, social_account_id, source_type, source_id, event_type,
actor_type, actor_id, policy_decision, evidence_file_id, metadata_json, created_at
)
select
'saud_' || lower(hex(randomblob(16))),
org_id,
social_account_id,
'browser_action',
id,
action_type,
'system',
null,
policy_decision,
evidence_file_id,
json_object('source', 'browser_actions'),
created_at
from browser_actions
where not exists (
select 1 from social_audit_records
where social_audit_records.source_type = 'browser_action'
and social_audit_records.source_id = browser_actions.id
and social_audit_records.event_type = browser_actions.action_type
);
create index if not exists idx_social_audit_records_org_created
on social_audit_records(org_id, created_at);
create index if not exists idx_social_audit_records_account_created
on social_audit_records(social_account_id, created_at);
create trigger if not exists trg_social_audit_records_no_update
before update on social_audit_records
begin
select raise(abort, 'Social audit records are immutable.');
end;
create trigger if not exists trg_social_audit_records_no_delete
before delete on social_audit_records
begin
select raise(abort, 'Social audit records are immutable.');
end;
insert into schema_migrations(version, name, applied_at)
values (27, 'social_audit_records', datetime('now'));
`;
@@ -25,6 +25,7 @@ import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery";
import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance";
import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records";
interface Migration {
version: number;
@@ -59,6 +60,7 @@ const MIGRATIONS: Migration[] = [
{ version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema },
{ version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema },
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
{ version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -68,6 +68,7 @@ const LEGACY_TABLES = [
"browser_action_policies",
"social_approval_routes",
"browser_actions",
"social_audit_records",
"scheduled_posts",
"policy_rules",
"compliance_exports",
@@ -227,6 +228,9 @@ const RELATION_CHECKS: RelationCheck[] = [
{ table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" },
{ table: "social_audit_records", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "social_audit_records", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "social_audit_records", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" },
{ table: "scheduled_posts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "scheduled_posts", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
@@ -262,7 +262,7 @@ export class SdlcService {
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
{ key: "schema_version", passed: schemaVersion >= 26, value: schemaVersion },
{ key: "schema_version", passed: schemaVersion >= 27, value: schemaVersion },
];
}
@@ -122,6 +122,7 @@ export class SnapshotService {
.map((policy) => ({ ...policy, approvalRequired: toBool(policy.approvalRequired) })),
socialApprovalRoutes: list(this.db, "social_approval_routes", "created_at asc"),
browserActions: list(this.db, "browser_actions", "created_at desc"),
socialAuditRecords: list(this.db, "social_audit_records", "created_at desc"),
policyRules: list(this.db, "policy_rules", "created_at asc"),
migrationReports: list(this.db, "migration_reports", "created_at desc"),
rollbackSnapshots,
@@ -0,0 +1,52 @@
import type { DatabaseSync } from "node:sqlite";
import { createId } from "../../shared/ids";
type SocialAuditMetadata = Record<string, unknown>;
export interface RecordSocialAuditInput {
orgId: string;
socialAccountId: string | null;
sourceType: string;
sourceId: string | null;
eventType: string;
actorType: string;
actorId: string | null;
policyDecision: string;
evidenceFileId?: string | null;
metadata: SocialAuditMetadata;
createdAt?: string;
}
export class SocialAuditService {
constructor(private readonly db: DatabaseSync) {}
record(input: RecordSocialAuditInput): string {
const id = createId("saud");
this.db.prepare(`
insert into social_audit_records(
id, org_id, social_account_id, source_type, source_id, event_type,
actor_type, actor_id, policy_decision, evidence_file_id, metadata_json, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.socialAccountId,
requireText(input.sourceType, "Social audit source type is required."),
input.sourceId,
requireText(input.eventType, "Social audit event type is required."),
requireText(input.actorType, "Social audit actor type is required."),
input.actorId,
requireText(input.policyDecision, "Social audit policy decision is required."),
input.evidenceFileId ?? null,
JSON.stringify(input.metadata),
input.createdAt ?? new Date().toISOString(),
);
return id;
}
}
function requireText(value: string, message: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error(message);
return trimmed;
}
@@ -10,6 +10,7 @@ import { APPROVAL_STATE, RISK_LEVEL, ROUTE_TO_TYPE, SOCIAL_PLATFORM, type RiskLe
import { createId } from "../../shared/ids";
import { ApprovalService } from "./approval-service";
import { AuditService } from "./audit-service";
import { SocialAuditService } from "./social-audit-service";
import {
SOCIAL_BROWSER_ACTION,
SocialBrowserPolicyService,
@@ -75,17 +76,19 @@ interface GrantScope {
export class SocialService {
private readonly audit: AuditService;
private readonly approvals: ApprovalService;
private readonly socialAudit: SocialAuditService;
private readonly browserPolicy: SocialBrowserPolicyService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.approvals = new ApprovalService(db);
this.socialAudit = new SocialAuditService(db);
this.browserPolicy = new SocialBrowserPolicyService(db);
}
createScheduledPost(input: CreateScheduledPostInput): string {
this.requireAccount(input.orgId, input.socialAccountId);
this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT);
const policy = this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT);
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_BROWSER_ACTION.DRAFT);
const body = this.requireText(input.bodyMd, "Post body is required.");
const scheduledFor = this.requireSchedule(input.scheduledFor);
@@ -117,6 +120,18 @@ export class SocialService {
action: "drafted",
payload: { socialAccountId: input.socialAccountId, scheduledFor, state: "draft" },
});
this.socialAudit.record({
orgId: input.orgId,
socialAccountId: input.socialAccountId,
sourceType: "scheduled_post",
sourceId: id,
eventType: "draft_created",
actorType: "employee",
actorId: input.createdByEmployeeId,
policyDecision: "allowed_by_policy",
metadata: { scheduledFor, actionType: policy.action_type, riskLevel: policy.risk_level },
createdAt: now,
});
return id;
}
@@ -142,6 +157,18 @@ export class SocialService {
action: `state_${input.state}`,
payload: { state: input.state, approvalId },
});
this.socialAudit.record({
orgId: input.orgId,
socialAccountId: post.social_account_id,
sourceType: "scheduled_post",
sourceId: input.scheduledPostId,
eventType: `state_${input.state}`,
actorType: "employee",
actorId: input.employeeId,
policyDecision: policyDecisionForState(input.state, policy.approval_required === 1),
metadata: { state: input.state, approvalId, actionType: policy.action_type, riskLevel: policy.risk_level },
createdAt: now,
});
}
createAccount(input: CreateSocialAccountInput): string {
@@ -198,6 +225,18 @@ export class SocialService {
action: "created",
payload: { platform: input.platform, handle },
});
this.socialAudit.record({
orgId: input.orgId,
socialAccountId: id,
sourceType: "social_account",
sourceId: id,
eventType: "account_created",
actorType: "employee",
actorId: input.createdByEmployeeId,
policyDecision: "allowed_employee_authorization",
metadata: { platform: input.platform, handle, authorizedEmployeeId: input.authorizedEmployeeId ?? null },
createdAt: now,
});
return id;
}
@@ -225,10 +264,11 @@ export class SocialService {
win.loadURL(PLATFORM_LOGIN_URL[account.platform]);
const now = new Date().toISOString();
this.browserPolicy.markSessionUsed(container.id, now);
const browserActionId = createId("bact");
this.db.prepare(`
insert into browser_actions(id, org_id, social_account_id, action_type, policy_decision, evidence_file_id, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now);
`).run(browserActionId, account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now);
this.audit.record({
orgId: account.org_id,
entityType: "social_account",
@@ -238,6 +278,18 @@ export class SocialService {
action: "login_window_opened",
payload: { platform: account.platform },
});
this.socialAudit.record({
orgId: account.org_id,
socialAccountId: account.id,
sourceType: "browser_action",
sourceId: browserActionId,
eventType: "login_window_opened",
actorType: "employee",
actorId: input.employeeId,
policyDecision: "allowed_human_login",
metadata: { platform: account.platform, containerId: container.id },
createdAt: now,
});
}
private attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void {
@@ -406,3 +458,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isRiskLevel(value: unknown): value is RiskLevel {
return Object.values(RISK_LEVEL).includes(value as RiskLevel);
}
function policyDecisionForState(state: SetScheduledPostStateInput["state"], approvalRequired: boolean): string {
if (state === "scheduled" && approvalRequired) return "approval_requested";
if (state === "published" && approvalRequired) return "allowed_after_approval";
return "allowed_by_policy";
}
+3 -2
View File
@@ -10,7 +10,7 @@ import type { ProviderRequestRecord } from "./provider-request-domain";
import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain";
import type { RollbackSnapshotRecord } from "./rollback-domain";
import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain";
import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain";
import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain";
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
export type { AuditReplayRunRecord } from "./audit-domain";
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
@@ -24,7 +24,7 @@ export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord }
export type { RollbackSnapshotRecord } from "./rollback-domain";
export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain";
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain";
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain";
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const;
export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const;
@@ -449,6 +449,7 @@ export interface AppSnapshot {
browserActionPolicies: BrowserActionPolicyRecord[];
socialApprovalRoutes: SocialApprovalRouteRecord[];
browserActions: BrowserActionRecord[];
socialAuditRecords: SocialAuditRecord[];
policyRules: PolicyRuleRecord[];
migrationReports: MigrationReportRecord[];
rollbackSnapshots: RollbackSnapshotRecord[];
@@ -66,3 +66,18 @@ export interface BrowserActionRecord {
evidenceFileId: string | null;
createdAt: string;
}
export interface SocialAuditRecord {
id: string;
orgId: string;
socialAccountId: string | null;
sourceType: string;
sourceId: string | null;
eventType: string;
actorType: string;
actorId: string | null;
policyDecision: string;
evidenceFileId: string | null;
metadataJson: string;
createdAt: string;
}
@@ -23,7 +23,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]);
connection.close();
});
@@ -150,6 +150,7 @@ describe("migration service", () => {
create table browser_action_policies(id text primary key, org_id text, social_account_id text);
create table social_approval_routes(id text primary key, org_id text, social_account_id text, route_to_id text);
create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text);
create table social_audit_records(id text primary key, org_id text, social_account_id text, evidence_file_id text);
create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text);
create table policy_rules(id text primary key, org_id text);
create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text);
@@ -218,6 +219,7 @@ describe("migration service", () => {
insert into browser_action_policies(id, org_id, social_account_id) values ('browser-policy-1', 'org-1', 'social-1');
insert into social_approval_routes(id, org_id, social_account_id, route_to_id) values ('social-route-1', 'org-1', 'social-1', 'emp-1');
insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1');
insert into social_audit_records(id, org_id, social_account_id, evidence_file_id) values ('social-audit-1', 'org-1', 'social-1', 'file-1');
insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1');
insert into policy_rules(id, org_id) values ('policy-1', 'org-1');
insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1');
@@ -256,6 +258,7 @@ describe("migration service", () => {
expect(body.tableCounts.browser_session_containers).toBe(1);
expect(body.tableCounts.browser_action_policies).toBe(1);
expect(body.tableCounts.social_approval_routes).toBe(1);
expect(body.tableCounts.social_audit_records).toBe(1);
expect(body.tableCounts.compliance_exports).toBe(1);
expect(body.tableCounts.audit_replay_runs).toBe(1);
expect(body.tableCounts.rollback_snapshots).toBe(1);
@@ -278,6 +281,7 @@ describe("migration service", () => {
expect(body.idMaps.browser_session_containers).toEqual(["container-1"]);
expect(body.idMaps.browser_action_policies).toEqual(["browser-policy-1"]);
expect(body.idMaps.social_approval_routes).toEqual(["social-route-1"]);
expect(body.idMaps.social_audit_records).toEqual(["social-audit-1"]);
connection.close();
});
@@ -81,6 +81,14 @@ describe("social service", () => {
approvalKind: "social_publish",
routeToId: employeeId,
});
expect(state.socialAuditRecords[0]).toMatchObject({
socialAccountId: state.socialAccounts[0]?.id,
sourceType: "social_account",
eventType: "account_created",
actorType: "employee",
actorId: employeeId,
policyDecision: "allowed_employee_authorization",
});
expect(state.auditEvents.find((event) => event.entityType === "social_account")?.actorId).toBe(employeeId);
expect(grantScope.actions).toEqual(["login", "draft", "approve"]);
expect(grantScope.riskCeiling).toBe("medium");
@@ -153,6 +161,47 @@ describe("social service", () => {
expect(state.scheduledPosts[0]?.state).toBe("published");
expect(state.scheduledPosts[0]?.socialAccountId).toBe(socialAccountId);
expect(state.scheduledPosts[0]?.approvalId).toBe(state.approvals[0]?.id);
expect(state.socialAuditRecords.map((record) => record.eventType).sort()).toEqual([
"account_created",
"draft_created",
"state_published",
"state_scheduled",
]);
expect(state.socialAuditRecords.find((record) => record.eventType === "state_scheduled")).toMatchObject({
sourceId: postId,
policyDecision: "approval_requested",
});
expect(state.socialAuditRecords.find((record) => record.eventType === "state_published")).toMatchObject({
sourceId: postId,
policyDecision: "allowed_after_approval",
});
connection.close();
});
test("keeps social audit records immutable", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
let auditId = "";
connection.transaction(() => {
const orgId = createOrganization(organization);
const employeeId = firstEmployeeId(connection, orgId);
social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
createdByEmployeeId: employeeId,
authorizedEmployeeId: employeeId,
});
auditId = (connection.db.prepare("select id from social_audit_records limit 1").get() as { id: string }).id;
});
expect(() => connection.db.prepare("update social_audit_records set policy_decision = ? where id = ?")
.run("changed", auditId)).toThrow("Social audit records are immutable.");
expect(() => connection.db.prepare("delete from social_audit_records where id = ?")
.run(auditId)).toThrow("Social audit records are immutable.");
connection.close();
});