From c6e29d7fbeea89e5f0c567e90c1dd62c33a3b909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 30 May 2026 00:13:24 -0400 Subject: [PATCH] Make organization deletion cascade through credentials and immutable audit logs --- .../038-provider-auth-profile-cascade.ts | 46 ++++++++++++ .../039-audit-immutability-org-teardown.ts | 28 +++++++ AgentCompany-New/src/main/database/schema.ts | 4 + .../tests/integration/org-deletion.test.ts | 75 +++++++++++++++++++ .../tests/unit/domain-services.test.ts | 2 +- 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 AgentCompany-New/src/main/database/migrations/038-provider-auth-profile-cascade.ts create mode 100644 AgentCompany-New/src/main/database/migrations/039-audit-immutability-org-teardown.ts create mode 100644 AgentCompany-New/tests/integration/org-deletion.test.ts diff --git a/AgentCompany-New/src/main/database/migrations/038-provider-auth-profile-cascade.ts b/AgentCompany-New/src/main/database/migrations/038-provider-auth-profile-cascade.ts new file mode 100644 index 00000000..820ef04e --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/038-provider-auth-profile-cascade.ts @@ -0,0 +1,46 @@ +// Deleting an organization cascades into its `credentials`, but +// `provider_auth_profiles.credential_id` referenced `credentials(id)` with +// `on delete restrict`, which blocked the cascade and made the whole DELETE +// throw — so deleting an organization silently failed. This rebuilds the +// (leaf) table with `on delete cascade`, preserving every existing row and +// index. Revocation only deactivates a profile (active = 0) and never deletes +// a credential row, so cascade has no effect on normal operation. +export const providerAuthProfileCascadeSchema = ` + create table provider_auth_profiles_rebuild ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + provider_id text not null references providers(id) on delete cascade, + credential_id text not null references credentials(id) on delete cascade, + provider_key text not null, + auth_type text not null, + profile_kind text not null, + display_hint text not null, + source_type text not null, + source_ref text, + capability_json text not null, + active integer not null check(active in (0, 1)), + created_at text not null + ); + + insert into provider_auth_profiles_rebuild( + id, org_id, provider_id, credential_id, provider_key, auth_type, + profile_kind, display_hint, source_type, source_ref, capability_json, active, created_at + ) + select + id, org_id, provider_id, credential_id, provider_key, auth_type, + profile_kind, display_hint, source_type, source_ref, capability_json, active, created_at + from provider_auth_profiles; + + drop table provider_auth_profiles; + alter table provider_auth_profiles_rebuild rename to provider_auth_profiles; + + create index if not exists idx_provider_auth_profiles_org_provider + on provider_auth_profiles(org_id, provider_id, created_at); + create index if not exists idx_provider_auth_profiles_credential + on provider_auth_profiles(credential_id); + create unique index if not exists idx_provider_auth_profiles_active + on provider_auth_profiles(provider_id) where active = 1; + + insert into schema_migrations(version, name, applied_at) + values (38, 'provider_auth_profile_cascade', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/migrations/039-audit-immutability-org-teardown.ts b/AgentCompany-New/src/main/database/migrations/039-audit-immutability-org-teardown.ts new file mode 100644 index 00000000..52deb2c5 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/039-audit-immutability-org-teardown.ts @@ -0,0 +1,28 @@ +// The audit_events and social_audit_records immutability triggers raised ABORT +// on EVERY delete, including the legitimate cascade when an organization is torn +// down — so deleting an organization always rolled back. These rebuilt triggers +// still block tampering deletes of individual records (the owning org exists), +// but allow the cascade once the organization row itself is gone. Direct +// deletes of audit rows whose org still exists remain forbidden. +export const auditImmutabilityOrgTeardownSchema = ` + drop trigger if exists trg_audit_events_no_delete; + create trigger trg_audit_events_no_delete + before delete on audit_events + for each row + when exists (select 1 from organizations where id = old.org_id) + begin + select raise(abort, 'Audit events are immutable.'); + end; + + drop trigger if exists trg_social_audit_records_no_delete; + create trigger trg_social_audit_records_no_delete + before delete on social_audit_records + for each row + when exists (select 1 from organizations where id = old.org_id) + begin + select raise(abort, 'Social audit records are immutable.'); + end; + + insert into schema_migrations(version, name, applied_at) + values (39, 'audit_immutability_org_teardown', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 3c0c9c99..2e4bd89a 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -36,6 +36,8 @@ import { providerAuthSessionsSchema } from "./migrations/034-provider-auth-sessi import { taskRunIsolationSchema } from "./migrations/035-task-run-isolation"; import { additionalConnectorsSchema } from "./migrations/036-additional-connectors"; import { customProvidersSchema } from "./migrations/037-custom-providers"; +import { providerAuthProfileCascadeSchema } from "./migrations/038-provider-auth-profile-cascade"; +import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immutability-org-teardown"; interface Migration { version: number; @@ -81,6 +83,8 @@ const MIGRATIONS: Migration[] = [ { version: 35, name: "task_run_isolation", sql: taskRunIsolationSchema }, { version: 36, name: "additional_connectors", sql: additionalConnectorsSchema }, { version: 37, name: "custom_providers", sql: customProvidersSchema }, + { version: 38, name: "provider_auth_profile_cascade", sql: providerAuthProfileCascadeSchema }, + { version: 39, name: "audit_immutability_org_teardown", sql: auditImmutabilityOrgTeardownSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/tests/integration/org-deletion.test.ts b/AgentCompany-New/tests/integration/org-deletion.test.ts new file mode 100644 index 00000000..e069b6f6 --- /dev/null +++ b/AgentCompany-New/tests/integration/org-deletion.test.ts @@ -0,0 +1,75 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OnboardingService } from "../../src/main/services/onboarding-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { createId } from "../../src/shared/ids"; + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +function countFor(db: DatabaseConnection["db"], table: string, orgId: string): number { + return (db.prepare(`select count(*) as count from ${table} where org_id = ?`).get(orgId) as { count: number }).count; +} + +describe("organization deletion", () => { + test("deleting an organization cascades through credentials, audit events, and social audit records", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-del-${randomUUID()}.db`)); + const providers = new ProviderService(connection.db, providerAdapters, testVault); + const organization = new OrganizationService(connection.db); + const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (i) => providers.saveCredential(i) }); + const snapshot = new SnapshotService(connection.db); + + let orgId = ""; + let survivorId = ""; + connection.transaction(() => { + const summary = onboarding.bootstrap({ + companyName: "OrbitOps AI", companyDescription: "AI-run operations company.", employeeName: "Yang", + workspacePath: "", executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false, + goalTitle: "Stand up a local service business.", goalDescription: "Win the first paying customers.", + providerKey: "openai", providerSecret: "sk-test-key", providerModel: "gpt-5.4", + }); + orgId = summary.orgId; + survivorId = organization.createOrganization({ + organizationName: "Keepers Inc", employeeName: "Dana", mission: "Stay.", goal: "Persist.", + }); + organization.setActiveOrganization(orgId); + // A social audit record is immutable (no-delete trigger); it must still cascade on org teardown. + connection.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 (?, ?, null, 'post', ?, 'created', 'system', null, 'allow', null, '{}', datetime('now')) + `).run(createId("sar"), orgId, createId("src")); + }); + + // Precondition: every blocking row type is present for the org. + expect(countFor(connection.db, "audit_events", orgId)).toBeGreaterThan(0); + expect(countFor(connection.db, "provider_auth_profiles", orgId)).toBeGreaterThan(0); + expect(countFor(connection.db, "social_audit_records", orgId)).toBe(1); + expect(countFor(connection.db, "agents", orgId)).toBeGreaterThan(0); + + // The delete must succeed (previously the FK restrict + immutability triggers rolled it back). + expect(() => connection.transaction(() => organization.deleteOrganization(orgId))).not.toThrow(); + + const state = snapshot.load(); + expect(state.organizations.map((o) => o.id)).toEqual([survivorId]); + expect(state.activeOrgId).toBe(survivorId); + for (const table of ["audit_events", "provider_auth_profiles", "social_audit_records", "agents", "providers", "credentials", "tasks", "approvals"]) { + expect(countFor(connection.db, table, orgId)).toBe(0); + } + + // The immutability trigger still blocks tampering with a living org's audit log. + const survivorAudit = connection.db.prepare("select id from audit_events where org_id = ? limit 1").get(survivorId) as { id: string }; + expect(() => connection.db.prepare("delete from audit_events where id = ?").run(survivorAudit.id)).toThrow(/immutable/); + + connection.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 06733111..ccd1c0fb 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -24,7 +24,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, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37]); + 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, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39]); connection.close(); });