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

@@ -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";
}