Add immutable social audit records
This commit is contained in:
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";
|
||||
}
|
||||
Reference in new issue
Block a user