Make organization deletion cascade through credentials and immutable audit logs
This commit is contained in:
1 parent
e14a4bfce8
commit
c6e29d7fbe
5 files changed
+154
-1
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user