From 3cf3b2d018ed0a25ac5082e5524c74d77a483832 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Mon, 20 Apr 2026 18:35:10 +0100 Subject: [PATCH 1/5] feat(admin): add platform-admin endpoint to permanently purge an organization Customer-success-facing regulatory erasure. DELETE /v1/admin/organizations/:id cancels Stripe subscriptions, deletes the Stripe customer, sweeps S3 objects under the org prefix, triggers vector-store deletions, cascades the DB row, then verifies zero leftover rows. Guarded by PlatformAdminGuard, slug confirmation, 2/min throttle, and a durable audit log written to the acting admin's other membership org (fails closed when absent). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin-organizations.controller.spec.ts | 53 ++++- .../admin-organizations.controller.ts | 32 ++- .../admin-organizations.module.ts | 10 +- .../dto/purge-organization.dto.ts | 12 + .../purge-organization-external.service.ts | 186 +++++++++++++++ .../purge-organization-snapshot.service.ts | 197 ++++++++++++++++ .../purge-organization.service.spec.ts | 213 +++++++++++++++++ .../purge-organization.service.ts | 223 ++++++++++++++++++ .../purge-organization.types.ts | 28 +++ .../[adminOrgId]/components/AdminOrgTabs.tsx | 82 +++++++ 10 files changed, 1033 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/admin-organizations/dto/purge-organization.dto.ts create mode 100644 apps/api/src/admin-organizations/purge-organization-external.service.ts create mode 100644 apps/api/src/admin-organizations/purge-organization-snapshot.service.ts create mode 100644 apps/api/src/admin-organizations/purge-organization.service.spec.ts create mode 100644 apps/api/src/admin-organizations/purge-organization.service.ts create mode 100644 apps/api/src/admin-organizations/purge-organization.types.ts diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts b/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts index 2dc9c367e6..2a2af65407 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts @@ -14,7 +14,28 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: {} }, })); -jest.mock('@db', () => ({ db: {} })); +jest.mock('@db', () => ({ + db: {}, + AuditLogEntityType: { + organization: 'organization', + people: 'people', + control: 'control', + policy: 'policy', + task: 'task', + vendor: 'vendor', + risk: 'risk', + finding: 'finding', + framework: 'framework', + integration: 'integration', + trust: 'trust', + }, + CommentEntityType: { + task: 'task', + vendor: 'vendor', + risk: 'risk', + policy: 'policy', + }, +})); describe('AdminOrganizationsController', () => { let controller: AdminOrganizationsController; @@ -28,12 +49,20 @@ describe('AdminOrganizationsController', () => { revokeInvitation: jest.fn(), getAuditLogs: jest.fn(), }; + const mockPurgeService = { + purgeOrganization: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AdminOrganizationsController], providers: [ { provide: AdminOrganizationsService, useValue: mockService }, + { + provide: require('./purge-organization.service') + .PurgeOrganizationService, + useValue: mockPurgeService, + }, ], }).compile(); @@ -160,6 +189,28 @@ describe('AdminOrganizationsController', () => { }); }); + describe('purge', () => { + it('should call purge service with confirm, id, and acting user', async () => { + mockPurgeService.purgeOrganization.mockResolvedValue({ + success: true, + organizationId: 'org_1', + }); + + const result = await controller.purge( + 'org_1', + { userId: 'usr_admin' } as { userId: string }, + { confirm: 'acme' }, + ); + + expect(mockPurgeService.purgeOrganization).toHaveBeenCalledWith({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'usr_admin', + }); + expect(result).toEqual({ success: true, organizationId: 'org_1' }); + }); + }); + describe('revokeInvitation', () => { it('should call service with org id and invitation id', async () => { mockService.revokeInvitation.mockResolvedValue({ success: true }); diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index 7233022631..49f7e31b3a 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -17,8 +17,10 @@ import { ApiExcludeController, ApiOperation, ApiQuery, ApiTags } from '@nestjs/s import { Throttle } from '@nestjs/throttler'; import { PlatformAdminGuard } from '../auth/platform-admin.guard'; import { AdminOrganizationsService } from './admin-organizations.service'; +import { PurgeOrganizationService } from './purge-organization.service'; import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; import { InviteMemberDto } from './dto/invite-member.dto'; +import { PurgeOrganizationDto } from './dto/purge-organization.dto'; @ApiExcludeController() @ApiTags('Admin - Organizations') @@ -27,7 +29,10 @@ import { InviteMemberDto } from './dto/invite-member.dto'; @UseInterceptors(AdminAuditLogInterceptor) @Throttle({ default: { ttl: 60000, limit: 30 } }) export class AdminOrganizationsController { - constructor(private readonly service: AdminOrganizationsService) {} + constructor( + private readonly service: AdminOrganizationsService, + private readonly purgeService: PurgeOrganizationService, + ) {} @Get() @ApiOperation({ summary: 'List all organizations (platform admin)' }) @@ -159,6 +164,31 @@ export class AdminOrganizationsController { return this.service.listInvitations(id); } + @Delete(':id') + @ApiOperation({ + summary: + 'Permanently delete organization and all associated data (platform admin)', + }) + @Throttle({ default: { ttl: 60000, limit: 2 } }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async purge( + @Param('id') id: string, + @Req() req: { userId: string }, + @Body() body: PurgeOrganizationDto, + ) { + return this.purgeService.purgeOrganization({ + organizationId: id, + confirm: body.confirm, + adminUserId: req.userId, + }); + } + @Delete(':id/invitations/:invId') @ApiOperation({ summary: 'Revoke invitation (platform admin)' }) @Throttle({ default: { ttl: 60000, limit: 10 } }) diff --git a/apps/api/src/admin-organizations/admin-organizations.module.ts b/apps/api/src/admin-organizations/admin-organizations.module.ts index 752159622e..1f63faa775 100644 --- a/apps/api/src/admin-organizations/admin-organizations.module.ts +++ b/apps/api/src/admin-organizations/admin-organizations.module.ts @@ -9,6 +9,9 @@ import { CommentsModule } from '../comments/comments.module'; import { AttachmentsModule } from '../attachments/attachments.module'; import { AdminOrganizationsController } from './admin-organizations.controller'; import { AdminOrganizationsService } from './admin-organizations.service'; +import { PurgeOrganizationService } from './purge-organization.service'; +import { PurgeOrganizationSnapshotService } from './purge-organization-snapshot.service'; +import { PurgeOrganizationExternalService } from './purge-organization-external.service'; import { AdminFindingsController } from './admin-findings.controller'; import { AdminPoliciesController } from './admin-policies.controller'; import { AdminTasksController } from './admin-tasks.controller'; @@ -36,6 +39,11 @@ import { AdminEvidenceController } from './admin-evidence.controller'; AdminContextController, AdminEvidenceController, ], - providers: [AdminOrganizationsService], + providers: [ + AdminOrganizationsService, + PurgeOrganizationService, + PurgeOrganizationSnapshotService, + PurgeOrganizationExternalService, + ], }) export class AdminOrganizationsModule {} diff --git a/apps/api/src/admin-organizations/dto/purge-organization.dto.ts b/apps/api/src/admin-organizations/dto/purge-organization.dto.ts new file mode 100644 index 0000000000..7bf9396039 --- /dev/null +++ b/apps/api/src/admin-organizations/dto/purge-organization.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class PurgeOrganizationDto { + @ApiProperty({ + description: + 'The target organization slug. Must match exactly to confirm deletion.', + example: 'acme-corp', + }) + @IsString() + confirm: string; +} diff --git a/apps/api/src/admin-organizations/purge-organization-external.service.ts b/apps/api/src/admin-organizations/purge-organization-external.service.ts new file mode 100644 index 0000000000..0c15b006af --- /dev/null +++ b/apps/api/src/admin-organizations/purge-organization-external.service.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + DeleteObjectsCommand, + ListObjectsV2Command, + type ObjectIdentifier, +} from '@aws-sdk/client-s3'; +import { tasks } from '@trigger.dev/sdk'; +import type Stripe from 'stripe'; +import { StripeService } from '../stripe/stripe.service'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3'; +import type { deleteKnowledgeBaseDocumentTask } from '../trigger/vector-store/delete-knowledge-base-document'; +import type { deleteAllManualAnswersOrchestratorTask } from '../trigger/vector-store/delete-all-manual-answers-orchestrator'; +import type { + PurgeExternalCleanupResult, + PurgeSnapshot, +} from './purge-organization.types'; + +@Injectable() +export class PurgeOrganizationExternalService { + private readonly logger = new Logger(PurgeOrganizationExternalService.name); + + constructor(private readonly stripeService: StripeService) {} + + async cleanupStripe( + stripe: PurgeSnapshot['stripe'], + ): Promise { + const result = { customerDeleted: false, subscriptionCanceled: false }; + + if (!this.stripeService.isConfigured()) { + this.logger.warn('Stripe not configured — skipping Stripe cleanup'); + return result; + } + + const client = this.stripeService.getClient(); + + if (stripe.subscriptionId) { + try { + await client.subscriptions.cancel(stripe.subscriptionId); + result.subscriptionCanceled = true; + } catch (err) { + if (this.isStripeMissingResource(err)) { + this.logger.log( + `Stripe subscription ${stripe.subscriptionId} already gone`, + ); + } else { + throw err; + } + } + } + + if (stripe.customerId) { + try { + await client.customers.del(stripe.customerId); + result.customerDeleted = true; + } catch (err) { + if (this.isStripeMissingResource(err)) { + this.logger.log( + `Stripe customer ${stripe.customerId} already gone`, + ); + } else { + throw err; + } + } + } + + return result; + } + + private isStripeMissingResource(err: unknown): boolean { + const e = err as Partial | undefined; + return !!e && e.code === 'resource_missing'; + } + + async cleanupVectorStore( + snapshot: PurgeSnapshot, + ): Promise { + const result = { + knowledgeBaseTasksTriggered: 0, + manualAnswerOrchestratorTriggered: false, + }; + + for (const documentId of snapshot.knowledgeBaseDocumentIds) { + try { + await tasks.trigger( + 'delete-knowledge-base-document-from-vector', + { documentId, organizationId: snapshot.organization.id }, + ); + result.knowledgeBaseTasksTriggered += 1; + } catch (err) { + this.logger.error( + `Failed to trigger KB vector delete for ${documentId}`, + err instanceof Error ? err.message : err, + ); + } + } + + if (snapshot.manualAnswerIds.length > 0) { + try { + await tasks.trigger( + 'delete-all-manual-answers-orchestrator', + { + organizationId: snapshot.organization.id, + manualAnswerIds: snapshot.manualAnswerIds, + }, + ); + result.manualAnswerOrchestratorTriggered = true; + } catch (err) { + this.logger.error( + `Failed to trigger manual answer orchestrator for ${snapshot.organization.id}`, + err instanceof Error ? err.message : err, + ); + } + } + + return result; + } + + async cleanupS3( + organizationId: string, + snapshot: PurgeSnapshot, + ): Promise { + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + this.logger.warn( + 'S3 client or ORG assets bucket not configured — skipping S3 cleanup', + ); + return { objectsDeleted: 0 }; + } + + const keys = new Set(snapshot.s3KeysFromSchema); + + let continuationToken: string | undefined; + do { + const listed = await s3Client.send( + new ListObjectsV2Command({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Prefix: `${organizationId}/`, + ContinuationToken: continuationToken, + }), + ); + for (const obj of listed.Contents ?? []) { + if (obj.Key) keys.add(obj.Key); + } + continuationToken = listed.IsTruncated + ? listed.NextContinuationToken + : undefined; + } while (continuationToken); + + if (keys.size === 0) return { objectsDeleted: 0 }; + + const allKeys = [...keys]; + let deleted = 0; + + for (let i = 0; i < allKeys.length; i += 1000) { + const batch = allKeys + .slice(i, i + 1000) + .map((Key) => ({ Key })); + const res = await s3Client.send( + new DeleteObjectsCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Delete: { Objects: batch, Quiet: true }, + }), + ); + deleted += batch.length - (res.Errors?.length ?? 0); + if (res.Errors && res.Errors.length > 0) { + this.logger.error( + `S3 reported ${res.Errors.length} delete errors during purge of ${organizationId}`, + JSON.stringify(res.Errors.slice(0, 5)), + ); + } + } + + return { objectsDeleted: deleted }; + } + + async verifyS3Clean(organizationId: string): Promise { + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) return true; + const remaining = await s3Client.send( + new ListObjectsV2Command({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Prefix: `${organizationId}/`, + MaxKeys: 1, + }), + ); + return (remaining.Contents ?? []).length === 0; + } +} diff --git a/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts b/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts new file mode 100644 index 0000000000..87f9778b55 --- /dev/null +++ b/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { extractS3KeyFromUrl } from '../app/s3'; +import type { PurgeSnapshot } from './purge-organization.types'; + +@Injectable() +export class PurgeOrganizationSnapshotService { + private readonly logger = new Logger(PurgeOrganizationSnapshotService.name); + + async build(organizationId: string): Promise { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { id: true, name: true, slug: true, logo: true }, + }); + + if (!org) { + throw new NotFoundException(`Organization ${organizationId} not found`); + } + + const [ + billing, + pentest, + trustResources, + trustNdas, + trustDocs, + orgChart, + questionnaires, + kbDocs, + scriptVersions, + attachments, + manualAnswers, + integrations, + counts, + ] = await Promise.all([ + db.organizationBilling.findUnique({ + where: { organizationId }, + select: { stripeCustomerId: true }, + }), + db.pentestSubscription.findUnique({ + where: { organizationId }, + select: { stripeSubscriptionId: true }, + }), + db.trustResource.findMany({ + where: { organizationId }, + select: { s3Key: true }, + }), + db.trustNDAAgreement.findMany({ + where: { organizationId }, + select: { pdfTemplateKey: true, pdfSignedKey: true }, + }), + db.trustDocument.findMany({ + where: { organizationId }, + select: { s3Key: true }, + }), + db.organizationChart.findUnique({ + where: { organizationId }, + select: { uploadedImageUrl: true }, + }), + db.questionnaire.findMany({ + where: { organizationId }, + select: { s3Key: true }, + }), + db.knowledgeBaseDocument.findMany({ + where: { organizationId }, + select: { id: true, s3Key: true }, + }), + db.evidenceAutomationVersion.findMany({ + where: { evidenceAutomation: { task: { organizationId } } }, + select: { scriptKey: true }, + }), + db.attachment.findMany({ + where: { organizationId }, + select: { url: true }, + }), + db.securityQuestionnaireManualAnswer.findMany({ + where: { organizationId }, + select: { id: true }, + }), + db.integrationConnection.findMany({ + where: { organizationId }, + select: { id: true, providerId: true }, + }), + this.countOrgRows(organizationId), + ]); + + const s3KeysFromSchema: string[] = []; + + if (org.logo) s3KeysFromSchema.push(org.logo); + for (const r of trustResources) if (r.s3Key) s3KeysFromSchema.push(r.s3Key); + for (const n of trustNdas) { + if (n.pdfTemplateKey) s3KeysFromSchema.push(n.pdfTemplateKey); + if (n.pdfSignedKey) s3KeysFromSchema.push(n.pdfSignedKey); + } + for (const d of trustDocs) if (d.s3Key) s3KeysFromSchema.push(d.s3Key); + if (orgChart?.uploadedImageUrl) { + s3KeysFromSchema.push(orgChart.uploadedImageUrl); + } + for (const q of questionnaires) if (q.s3Key) s3KeysFromSchema.push(q.s3Key); + for (const k of kbDocs) if (k.s3Key) s3KeysFromSchema.push(k.s3Key); + for (const v of scriptVersions) { + if (v.scriptKey) s3KeysFromSchema.push(v.scriptKey); + } + for (const a of attachments) { + if (!a.url) continue; + try { + s3KeysFromSchema.push(extractS3KeyFromUrl(a.url)); + } catch (err) { + this.logger.warn( + `Skipping attachment with unparseable URL during purge of ${organizationId}`, + err instanceof Error ? err.message : 'unknown', + ); + } + } + + return { + organization: { id: org.id, name: org.name, slug: org.slug }, + counts, + stripe: { + customerId: billing?.stripeCustomerId ?? null, + subscriptionId: pentest?.stripeSubscriptionId ?? null, + }, + s3KeysFromSchema: [...new Set(s3KeysFromSchema)], + knowledgeBaseDocumentIds: kbDocs.map((d) => d.id), + manualAnswerIds: manualAnswers.map((m) => m.id), + integrations: integrations.map((i) => ({ + id: i.id, + provider: i.providerId, + })), + }; + } + + private async countOrgRows( + organizationId: string, + ): Promise> { + const where = { organizationId }; + const [ + members, + apiKeys, + auditLogs, + controls, + policies, + tasks, + vendors, + risks, + findings, + evidenceSubmissions, + devices, + integrations, + knowledgeBaseDocs, + questionnaires, + frameworkInstances, + trustResources, + attachments, + secrets, + ] = await Promise.all([ + db.member.count({ where }), + db.apiKey.count({ where }), + db.auditLog.count({ where }), + db.control.count({ where }), + db.policy.count({ where }), + db.task.count({ where }), + db.vendor.count({ where }), + db.risk.count({ where }), + db.finding.count({ where }), + db.evidenceSubmission.count({ where }), + db.device.count({ where }), + db.integrationConnection.count({ where }), + db.knowledgeBaseDocument.count({ where }), + db.questionnaire.count({ where }), + db.frameworkInstance.count({ where }), + db.trustResource.count({ where }), + db.attachment.count({ where }), + db.secret.count({ where }), + ]); + + return { + members, + apiKeys, + auditLogs, + controls, + policies, + tasks, + vendors, + risks, + findings, + evidenceSubmissions, + devices, + integrations, + knowledgeBaseDocs, + questionnaires, + frameworkInstances, + trustResources, + attachments, + secrets, + }; + } +} diff --git a/apps/api/src/admin-organizations/purge-organization.service.spec.ts b/apps/api/src/admin-organizations/purge-organization.service.spec.ts new file mode 100644 index 0000000000..85464f1598 --- /dev/null +++ b/apps/api/src/admin-organizations/purge-organization.service.spec.ts @@ -0,0 +1,213 @@ +import { + BadRequestException, + InternalServerErrorException, + UnprocessableEntityException, +} from '@nestjs/common'; + +jest.mock('@db', () => ({ + AuditLogEntityType: { organization: 'organization' }, + Prisma: {}, + db: { + organization: { delete: jest.fn() }, + member: { findFirst: jest.fn() }, + apiKey: { count: jest.fn() }, + control: { count: jest.fn() }, + policy: { count: jest.fn() }, + task: { count: jest.fn() }, + auditLog: { count: jest.fn(), create: jest.fn() }, + device: { count: jest.fn() }, + integrationConnection: { count: jest.fn() }, + vendor: { count: jest.fn() }, + risk: { count: jest.fn() }, + }, +})); + +import { db } from '@db'; +import { PurgeOrganizationService } from './purge-organization.service'; +import type { PurgeOrganizationSnapshotService } from './purge-organization-snapshot.service'; +import type { PurgeOrganizationExternalService } from './purge-organization-external.service'; +import type { PurgeSnapshot } from './purge-organization.types'; + +const mockDb = db as unknown as Record>; + +const sampleSnapshot: PurgeSnapshot = { + organization: { id: 'org_1', name: 'Acme', slug: 'acme' }, + counts: { policies: 5 }, + stripe: { customerId: 'cus_1', subscriptionId: 'sub_1' }, + s3KeysFromSchema: ['org_1/logo/a.png'], + knowledgeBaseDocumentIds: ['kbd_1'], + manualAnswerIds: ['ma_1'], + integrations: [{ id: 'icn_1', provider: 'google' }], +}; + +describe('PurgeOrganizationService', () => { + let service: PurgeOrganizationService; + let snapshotService: jest.Mocked; + let externalService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + snapshotService = { + build: jest.fn().mockResolvedValue(sampleSnapshot), + } as unknown as jest.Mocked; + externalService = { + cleanupStripe: jest + .fn() + .mockResolvedValue({ customerDeleted: true, subscriptionCanceled: true }), + cleanupVectorStore: jest.fn().mockResolvedValue({ + knowledgeBaseTasksTriggered: 1, + manualAnswerOrchestratorTriggered: true, + }), + cleanupS3: jest.fn().mockResolvedValue({ objectsDeleted: 3 }), + verifyS3Clean: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; + + for (const model of [ + 'apiKey', + 'control', + 'policy', + 'task', + 'auditLog', + 'device', + 'integrationConnection', + 'vendor', + 'risk', + ]) { + mockDb[model === 'member' ? 'member' : model].count = + jest.fn().mockResolvedValue(0); + } + mockDb.member.count = jest.fn().mockResolvedValue(0); + mockDb.member.findFirst = jest + .fn() + .mockResolvedValue({ organizationId: 'org_admin_home' }); + mockDb.organization.delete = jest.fn().mockResolvedValue({ id: 'org_1' }); + mockDb.auditLog.create = jest.fn().mockResolvedValue({}); + + service = new PurgeOrganizationService(snapshotService, externalService); + }); + + it('throws BadRequestException when confirm does not match slug', async () => { + await expect( + service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'wrong', + adminUserId: 'u1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(externalService.cleanupStripe).not.toHaveBeenCalled(); + expect(mockDb.organization.delete).not.toHaveBeenCalled(); + }); + + it('orchestrates external cleanup then DB delete then verification', async () => { + const order: string[] = []; + externalService.cleanupStripe.mockImplementation(async () => { + order.push('stripe'); + return { customerDeleted: true, subscriptionCanceled: true }; + }); + externalService.cleanupVectorStore.mockImplementation(async () => { + order.push('vector'); + return { + knowledgeBaseTasksTriggered: 1, + manualAnswerOrchestratorTriggered: true, + }; + }); + externalService.cleanupS3.mockImplementation(async () => { + order.push('s3'); + return { objectsDeleted: 3 }; + }); + mockDb.organization.delete.mockImplementation(async () => { + order.push('db-delete'); + return { id: 'org_1' }; + }); + externalService.verifyS3Clean.mockImplementation(async () => { + order.push('verify-s3'); + return true; + }); + + const result = await service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }); + + expect(order).toEqual([ + 'stripe', + 'vector', + 's3', + 'db-delete', + 'verify-s3', + ]); + expect(result.success).toBe(true); + expect(result.deletedCounts).toEqual({ policies: 5 }); + expect(result.externalCleanup.stripe.customerDeleted).toBe(true); + expect(result.externalCleanup.s3.objectsDeleted).toBe(3); + }); + + it('throws when verification finds leftover rows', async () => { + mockDb.policy.count.mockResolvedValue(5); + await expect( + service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }), + ).rejects.toThrow(/verification failed/); + }); + + it('throws when S3 verification reports leftover objects', async () => { + externalService.verifyS3Clean.mockResolvedValue(false); + await expect( + service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }), + ).rejects.toThrow(/S3 objects remain/); + }); + + it('writes persistent audit log to admin member org', async () => { + await service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }); + + const calls = mockDb.auditLog.create.mock.calls; + expect(calls.length).toBeGreaterThanOrEqual(2); + for (const [arg] of calls) { + expect(arg.data.organizationId).toBe('org_admin_home'); + expect(arg.data.entityId).toBe('org_1'); + } + }); + + it('wraps raw Stripe/S3/vector errors as InternalServerErrorException', async () => { + const raw = new Error('STRIPE req_abc123 customer cus_secret leaked'); + externalService.cleanupStripe.mockRejectedValue(raw); + + const promise = service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }); + + await expect(promise).rejects.toBeInstanceOf(InternalServerErrorException); + await expect(promise).rejects.not.toThrow(/cus_secret/); + expect(mockDb.organization.delete).not.toHaveBeenCalled(); + }); + + it('fails closed when admin has no other membership for audit trail', async () => { + mockDb.member.findFirst.mockResolvedValue(null); + + await expect( + service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }), + ).rejects.toBeInstanceOf(UnprocessableEntityException); + + expect(externalService.cleanupStripe).not.toHaveBeenCalled(); + expect(mockDb.organization.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/admin-organizations/purge-organization.service.ts b/apps/api/src/admin-organizations/purge-organization.service.ts new file mode 100644 index 0000000000..9096306a79 --- /dev/null +++ b/apps/api/src/admin-organizations/purge-organization.service.ts @@ -0,0 +1,223 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + UnprocessableEntityException, +} from '@nestjs/common'; +import { AuditLogEntityType, db, Prisma } from '@db'; +import { PurgeOrganizationSnapshotService } from './purge-organization-snapshot.service'; +import { PurgeOrganizationExternalService } from './purge-organization-external.service'; +import type { + PurgeExternalCleanupResult, + PurgeResult, + PurgeSnapshot, +} from './purge-organization.types'; + +@Injectable() +export class PurgeOrganizationService { + private readonly logger = new Logger(PurgeOrganizationService.name); + + constructor( + private readonly snapshotService: PurgeOrganizationSnapshotService, + private readonly externalService: PurgeOrganizationExternalService, + ) {} + + async purgeOrganization(params: { + organizationId: string; + confirm: string; + adminUserId: string; + }): Promise { + const { organizationId, confirm, adminUserId } = params; + + const snapshot = await this.snapshotService.build(organizationId); + + if (confirm !== snapshot.organization.slug) { + throw new BadRequestException( + `Confirmation does not match organization slug. Expected '${snapshot.organization.slug}'.`, + ); + } + + // Fail closed if we cannot write a durable audit trail. The target org's + // audit log will be cascade-deleted, so we require the acting admin to + // have another active membership to hold the record. + const loggingOrgId = await this.findAdminMembershipOrgId( + adminUserId, + organizationId, + ); + if (!loggingOrgId) { + throw new UnprocessableEntityException( + 'Cannot purge organization: platform admin has no other active ' + + 'organization membership to record the audit trail against. Add the ' + + 'admin as a member of another organization before retrying.', + ); + } + + await this.writeAdminAuditLog({ + loggingOrgId, + adminUserId, + snapshot, + status: 'initiated', + }); + + const stripeResult = await this.runExternalStep( + 'stripe', + organizationId, + () => this.externalService.cleanupStripe(snapshot.stripe), + ); + const vectorResult = await this.runExternalStep( + 'vector-store', + organizationId, + () => this.externalService.cleanupVectorStore(snapshot), + ); + const s3Result = await this.runExternalStep('s3', organizationId, () => + this.externalService.cleanupS3(organizationId, snapshot), + ); + + await db.organization.delete({ where: { id: organizationId } }); + + await this.verifyDeletion(organizationId); + + const externalCleanup: PurgeExternalCleanupResult = { + stripe: stripeResult, + s3: s3Result, + vectorStore: vectorResult, + }; + + await this.writeAdminAuditLog({ + loggingOrgId, + adminUserId, + snapshot, + status: 'completed', + externalCleanup, + }); + + return { + success: true, + organizationId, + deletedCounts: snapshot.counts, + externalCleanup, + }; + } + + private async runExternalStep( + step: string, + organizationId: string, + fn: () => Promise, + ): Promise { + try { + return await fn(); + } catch (err) { + this.logger.error( + `Purge external step '${step}' failed for org ${organizationId}`, + err instanceof Error ? err.stack : err, + ); + throw new InternalServerErrorException( + `External cleanup step '${step}' failed. See server logs.`, + ); + } + } + + private async verifyDeletion(organizationId: string): Promise { + const where = { organizationId }; + const checks: Array<[string, Promise]> = [ + ['apiKey', db.apiKey.count({ where })], + ['member', db.member.count({ where })], + ['control', db.control.count({ where })], + ['policy', db.policy.count({ where })], + ['task', db.task.count({ where })], + ['auditLog', db.auditLog.count({ where })], + ['device', db.device.count({ where })], + ['integrationConnection', db.integrationConnection.count({ where })], + ['vendor', db.vendor.count({ where })], + ['risk', db.risk.count({ where })], + ]; + + const results = await Promise.all( + checks.map(async ([name, p]) => [name, await p] as const), + ); + const leftovers = results.filter(([, count]) => count > 0); + if (leftovers.length > 0) { + const summary = leftovers + .map(([name, count]) => `${name}=${count}`) + .join(', '); + throw new Error( + `Organization ${organizationId} purge verification failed — leftover rows: ${summary}`, + ); + } + + const s3Clean = await this.externalService.verifyS3Clean(organizationId); + if (!s3Clean) { + throw new Error( + `Organization ${organizationId} purge verification failed — S3 objects remain under prefix`, + ); + } + } + + private async writeAdminAuditLog(params: { + loggingOrgId: string; + adminUserId: string; + snapshot: PurgeSnapshot; + status: 'initiated' | 'completed'; + externalCleanup?: PurgeExternalCleanupResult; + }): Promise { + const description = + params.status === 'initiated' + ? `Initiated purge of organization '${params.snapshot.organization.name}'` + : `Completed purge of organization '${params.snapshot.organization.name}'`; + + const data: Record = { + action: description, + resource: 'admin', + permission: 'platform-admin', + targetOrganization: params.snapshot.organization, + counts: params.snapshot.counts, + stripe: params.snapshot.stripe, + integrations: params.snapshot.integrations, + s3KeyCount: params.snapshot.s3KeysFromSchema.length, + knowledgeBaseDocumentIds: params.snapshot.knowledgeBaseDocumentIds, + manualAnswerIdCount: params.snapshot.manualAnswerIds.length, + }; + if (params.externalCleanup) { + data.externalCleanup = params.externalCleanup as unknown as Record< + string, + unknown + >; + } + + try { + await db.auditLog.create({ + data: { + organizationId: params.loggingOrgId, + userId: params.adminUserId, + memberId: null, + entityType: AuditLogEntityType.organization, + entityId: params.snapshot.organization.id, + description, + data: data as Prisma.InputJsonValue, + }, + }); + } catch (err) { + this.logger.error( + 'Failed to write platform-admin purge audit log', + err instanceof Error ? err.message : err, + ); + } + } + + private async findAdminMembershipOrgId( + adminUserId: string, + excludeOrgId: string, + ): Promise { + const member = await db.member.findFirst({ + where: { + userId: adminUserId, + organizationId: { not: excludeOrgId }, + deactivated: false, + }, + select: { organizationId: true }, + orderBy: { createdAt: 'asc' }, + }); + return member?.organizationId ?? null; + } +} diff --git a/apps/api/src/admin-organizations/purge-organization.types.ts b/apps/api/src/admin-organizations/purge-organization.types.ts new file mode 100644 index 0000000000..394272088a --- /dev/null +++ b/apps/api/src/admin-organizations/purge-organization.types.ts @@ -0,0 +1,28 @@ +export interface PurgeSnapshot { + organization: { id: string; name: string; slug: string }; + counts: Record; + stripe: { + customerId: string | null; + subscriptionId: string | null; + }; + s3KeysFromSchema: string[]; + knowledgeBaseDocumentIds: string[]; + manualAnswerIds: string[]; + integrations: { id: string; provider: string }[]; +} + +export interface PurgeExternalCleanupResult { + stripe: { customerDeleted: boolean; subscriptionCanceled: boolean }; + s3: { objectsDeleted: number }; + vectorStore: { + knowledgeBaseTasksTriggered: number; + manualAnswerOrchestratorTriggered: boolean; + }; +} + +export interface PurgeResult { + success: true; + organizationId: string; + deletedCounts: Record; + externalCleanup: PurgeExternalCleanupResult; +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index 6ab5aaa79f..03d8f9a439 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -1,6 +1,8 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; import { api } from '@/lib/api-client'; import { AlertDialog, @@ -63,11 +65,15 @@ export function AdminOrgTabs({ org: AdminOrgDetail; currentOrgId: string; }) { + const router = useRouter(); const [activeTab, setActiveTab] = useState('overview'); const [toggling, setToggling] = useState(false); const [hasAccess, setHasAccess] = useState(org.hasAccess); const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false); const [confirmValue, setConfirmValue] = useState(''); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteConfirmValue, setDeleteConfirmValue] = useState(''); + const [deleting, setDeleting] = useState(false); const handleToggleAccess = async () => { if (hasAccess) { @@ -93,6 +99,27 @@ export function AdminOrgTabs({ setToggling(false); }; + const handleConfirmDelete = async () => { + setDeleting(true); + const res = await api.delete( + `/v1/admin/organizations/${org.id}`, + undefined, + { confirm: org.slug }, + ); + setDeleting(false); + if (res.error) { + toast.error( + typeof res.error === 'string' + ? res.error + : 'Failed to delete organization', + ); + return; + } + setDeleteDialogOpen(false); + toast.success(`Organization '${org.name}' permanently deleted`); + router.push(`/${currentOrgId}/admin/organizations`); + }; + return ( { if (v) setActiveTab(v); }}> {hasAccess ? 'Deactivate' : 'Activate'} + } tabs={ @@ -220,6 +257,51 @@ export function AdminOrgTabs({ + + { + setDeleteDialogOpen(open); + if (!open) setDeleteConfirmValue(''); + }} + > + + + Permanently delete organization + + This cannot be undone. All policies, members, tasks, devices, + evidence, integrations, Stripe billing, S3 files, and vector + embeddings for {org.name} will be permanently + removed. + + +
+ + setDeleteConfirmValue(e.target.value)} + placeholder={org.slug} + autoComplete="off" + /> +
+ + setDeleteConfirmValue('')}> + Cancel + + + {deleting ? 'Deleting…' : 'Delete Permanently'} + + +
+
); } From 3d179d9cfc4b62ddbb10d24cdce7ef0fd53b7db1 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Wed, 22 Apr 2026 12:00:16 +0100 Subject: [PATCH 2/5] fix(admin-organizations): harden org purge against silent failures - Stop swallowing vector-store trigger errors so purge aborts if cleanup cannot be scheduled - Require durable audit-log write to succeed; remove try/catch that let purge proceed without a trail - Skip AdminAuditLogInterceptor on DELETE :id so it does not try to write an audit log against the just-deleted org - Capture S3 keys per-bucket and purge across all configured buckets so org-chart/attachment objects in APP_AWS_BUCKET_NAME are removed Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin-audit-log.interceptor.spec.ts | 4 +- .../admin-audit-log.interceptor.ts | 12 ++ .../admin-organizations.controller.ts | 2 + .../purge-organization-external.service.ts | 171 +++++++++++------- .../purge-organization-snapshot.service.ts | 49 +++-- .../purge-organization.service.spec.ts | 8 +- .../purge-organization.service.ts | 36 ++-- .../purge-organization.types.ts | 10 +- .../skip-admin-audit-log.decorator.ts | 6 + 9 files changed, 187 insertions(+), 111 deletions(-) create mode 100644 apps/api/src/admin-organizations/skip-admin-audit-log.decorator.ts diff --git a/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts b/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts index 3050c80b64..a8b310b2e7 100644 --- a/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts +++ b/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts @@ -82,7 +82,9 @@ describe('AdminAuditLogInterceptor', () => { let interceptor: AdminAuditLogInterceptor; beforeEach(() => { - interceptor = new AdminAuditLogInterceptor(); + interceptor = new AdminAuditLogInterceptor({ + get: jest.fn().mockReturnValue(false), + } as never); jest.clearAllMocks(); mockPolicyFind.mockResolvedValue(null); mockTaskFind.mockResolvedValue(null); diff --git a/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts b/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts index d8caeafb49..eac9368b96 100644 --- a/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts +++ b/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts @@ -6,8 +6,10 @@ import { NestInterceptor, } from '@nestjs/common'; import { AuditLogEntityType, db, Prisma } from '@db'; +import { Reflector } from '@nestjs/core'; import { Observable, tap } from 'rxjs'; import { MUTATION_METHODS, SENSITIVE_KEYS } from '../audit/audit-log.constants'; +import { SKIP_ADMIN_AUDIT_LOG_KEY } from './skip-admin-audit-log.decorator'; const SEGMENT_TO_RESOURCE: Record< string, @@ -41,7 +43,17 @@ interface ParsedPath { export class AdminAuditLogInterceptor implements NestInterceptor { private readonly logger = new Logger(AdminAuditLogInterceptor.name); + constructor(private readonly reflector: Reflector) {} + intercept(context: ExecutionContext, next: CallHandler): Observable { + const skip = this.reflector.get( + SKIP_ADMIN_AUDIT_LOG_KEY, + context.getHandler(), + ); + if (skip) { + return next.handle(); + } + const request = context.switchToHttp().getRequest(); const method: string = request.method; diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index 49f7e31b3a..172990faa2 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -19,6 +19,7 @@ import { PlatformAdminGuard } from '../auth/platform-admin.guard'; import { AdminOrganizationsService } from './admin-organizations.service'; import { PurgeOrganizationService } from './purge-organization.service'; import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { SkipAdminAuditLog } from './skip-admin-audit-log.decorator'; import { InviteMemberDto } from './dto/invite-member.dto'; import { PurgeOrganizationDto } from './dto/purge-organization.dto'; @@ -165,6 +166,7 @@ export class AdminOrganizationsController { } @Delete(':id') + @SkipAdminAuditLog() @ApiOperation({ summary: 'Permanently delete organization and all associated data (platform admin)', diff --git a/apps/api/src/admin-organizations/purge-organization-external.service.ts b/apps/api/src/admin-organizations/purge-organization-external.service.ts index 0c15b006af..96a863e5c9 100644 --- a/apps/api/src/admin-organizations/purge-organization-external.service.ts +++ b/apps/api/src/admin-organizations/purge-organization-external.service.ts @@ -7,14 +7,35 @@ import { import { tasks } from '@trigger.dev/sdk'; import type Stripe from 'stripe'; import { StripeService } from '../stripe/stripe.service'; -import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3'; +import { + APP_AWS_KNOWLEDGE_BASE_BUCKET, + APP_AWS_ORG_ASSETS_BUCKET, + APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET, + BUCKET_NAME, + s3Client, +} from '../app/s3'; import type { deleteKnowledgeBaseDocumentTask } from '../trigger/vector-store/delete-knowledge-base-document'; import type { deleteAllManualAnswersOrchestratorTask } from '../trigger/vector-store/delete-all-manual-answers-orchestrator'; import type { PurgeExternalCleanupResult, + PurgeS3BucketRef, PurgeSnapshot, } from './purge-organization.types'; +const BUCKET_ENV: Record = { + orgAssets: APP_AWS_ORG_ASSETS_BUCKET, + default: BUCKET_NAME, + knowledgeBase: APP_AWS_KNOWLEDGE_BASE_BUCKET, + questionnaire: APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET, +}; + +const BUCKET_REFS: PurgeS3BucketRef[] = [ + 'orgAssets', + 'default', + 'knowledgeBase', + 'questionnaire', +]; + @Injectable() export class PurgeOrganizationExternalService { private readonly logger = new Logger(PurgeOrganizationExternalService.name); @@ -80,36 +101,22 @@ export class PurgeOrganizationExternalService { }; for (const documentId of snapshot.knowledgeBaseDocumentIds) { - try { - await tasks.trigger( - 'delete-knowledge-base-document-from-vector', - { documentId, organizationId: snapshot.organization.id }, - ); - result.knowledgeBaseTasksTriggered += 1; - } catch (err) { - this.logger.error( - `Failed to trigger KB vector delete for ${documentId}`, - err instanceof Error ? err.message : err, - ); - } + await tasks.trigger( + 'delete-knowledge-base-document-from-vector', + { documentId, organizationId: snapshot.organization.id }, + ); + result.knowledgeBaseTasksTriggered += 1; } if (snapshot.manualAnswerIds.length > 0) { - try { - await tasks.trigger( - 'delete-all-manual-answers-orchestrator', - { - organizationId: snapshot.organization.id, - manualAnswerIds: snapshot.manualAnswerIds, - }, - ); - result.manualAnswerOrchestratorTriggered = true; - } catch (err) { - this.logger.error( - `Failed to trigger manual answer orchestrator for ${snapshot.organization.id}`, - err instanceof Error ? err.message : err, - ); - } + await tasks.trigger( + 'delete-all-manual-answers-orchestrator', + { + organizationId: snapshot.organization.id, + manualAnswerIds: snapshot.manualAnswerIds, + }, + ); + result.manualAnswerOrchestratorTriggered = true; } return result; @@ -119,68 +126,94 @@ export class PurgeOrganizationExternalService { organizationId: string, snapshot: PurgeSnapshot, ): Promise { - if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { - this.logger.warn( - 'S3 client or ORG assets bucket not configured — skipping S3 cleanup', - ); + if (!s3Client) { + this.logger.warn('S3 client not configured — skipping S3 cleanup'); return { objectsDeleted: 0 }; } - const keys = new Set(snapshot.s3KeysFromSchema); + let totalDeleted = 0; + for (const ref of BUCKET_REFS) { + const bucket = BUCKET_ENV[ref]; + if (!bucket) continue; + + const keys = new Set(snapshot.s3KeysByBucket[ref] ?? []); + + let continuationToken: string | undefined; + do { + const listed = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: `${organizationId}/`, + ContinuationToken: continuationToken, + }), + ); + for (const obj of listed.Contents ?? []) { + if (obj.Key) keys.add(obj.Key); + } + continuationToken = listed.IsTruncated + ? listed.NextContinuationToken + : undefined; + } while (continuationToken); - let continuationToken: string | undefined; - do { - const listed = await s3Client.send( - new ListObjectsV2Command({ - Bucket: APP_AWS_ORG_ASSETS_BUCKET, - Prefix: `${organizationId}/`, - ContinuationToken: continuationToken, - }), + if (keys.size === 0) continue; + + totalDeleted += await this.deleteBatched( + bucket, + [...keys], + organizationId, ); - for (const obj of listed.Contents ?? []) { - if (obj.Key) keys.add(obj.Key); - } - continuationToken = listed.IsTruncated - ? listed.NextContinuationToken - : undefined; - } while (continuationToken); + } - if (keys.size === 0) return { objectsDeleted: 0 }; + return { objectsDeleted: totalDeleted }; + } - const allKeys = [...keys]; + private async deleteBatched( + bucket: string, + keys: string[], + organizationId: string, + ): Promise { + if (!s3Client) return 0; let deleted = 0; - - for (let i = 0; i < allKeys.length; i += 1000) { - const batch = allKeys + for (let i = 0; i < keys.length; i += 1000) { + const batch = keys .slice(i, i + 1000) .map((Key) => ({ Key })); const res = await s3Client.send( new DeleteObjectsCommand({ - Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Bucket: bucket, Delete: { Objects: batch, Quiet: true }, }), ); - deleted += batch.length - (res.Errors?.length ?? 0); - if (res.Errors && res.Errors.length > 0) { + const errorCount = res.Errors?.length ?? 0; + deleted += batch.length - errorCount; + if (errorCount > 0) { this.logger.error( - `S3 reported ${res.Errors.length} delete errors during purge of ${organizationId}`, - JSON.stringify(res.Errors.slice(0, 5)), + `S3 reported ${errorCount} delete errors in bucket ${bucket} ` + + `during purge of ${organizationId}`, + JSON.stringify(res.Errors!.slice(0, 5)), + ); + throw new Error( + `S3 delete reported ${errorCount} errors in bucket ${bucket}`, ); } } - - return { objectsDeleted: deleted }; + return deleted; } async verifyS3Clean(organizationId: string): Promise { - if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) return true; - const remaining = await s3Client.send( - new ListObjectsV2Command({ - Bucket: APP_AWS_ORG_ASSETS_BUCKET, - Prefix: `${organizationId}/`, - MaxKeys: 1, - }), - ); - return (remaining.Contents ?? []).length === 0; + if (!s3Client) return true; + for (const ref of BUCKET_REFS) { + const bucket = BUCKET_ENV[ref]; + if (!bucket) continue; + const remaining = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: `${organizationId}/`, + MaxKeys: 1, + }), + ); + if ((remaining.Contents ?? []).length > 0) return false; + } + return true; } } diff --git a/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts b/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts index 87f9778b55..34e304c702 100644 --- a/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts +++ b/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts @@ -1,7 +1,11 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { db } from '@db'; import { extractS3KeyFromUrl } from '../app/s3'; -import type { PurgeSnapshot } from './purge-organization.types'; +import type { + PurgeS3BucketRef, + PurgeS3KeysByBucket, + PurgeSnapshot, +} from './purge-organization.types'; @Injectable() export class PurgeOrganizationSnapshotService { @@ -83,27 +87,31 @@ export class PurgeOrganizationSnapshotService { this.countOrgRows(organizationId), ]); - const s3KeysFromSchema: string[] = []; + const keysByBucket: Record> = { + orgAssets: new Set(), + default: new Set(), + knowledgeBase: new Set(), + questionnaire: new Set(), + }; + const add = (bucket: PurgeS3BucketRef, key: string | null | undefined) => { + if (key) keysByBucket[bucket].add(key); + }; - if (org.logo) s3KeysFromSchema.push(org.logo); - for (const r of trustResources) if (r.s3Key) s3KeysFromSchema.push(r.s3Key); + add('orgAssets', org.logo); + for (const r of trustResources) add('orgAssets', r.s3Key); + for (const d of trustDocs) add('orgAssets', d.s3Key); for (const n of trustNdas) { - if (n.pdfTemplateKey) s3KeysFromSchema.push(n.pdfTemplateKey); - if (n.pdfSignedKey) s3KeysFromSchema.push(n.pdfSignedKey); - } - for (const d of trustDocs) if (d.s3Key) s3KeysFromSchema.push(d.s3Key); - if (orgChart?.uploadedImageUrl) { - s3KeysFromSchema.push(orgChart.uploadedImageUrl); - } - for (const q of questionnaires) if (q.s3Key) s3KeysFromSchema.push(q.s3Key); - for (const k of kbDocs) if (k.s3Key) s3KeysFromSchema.push(k.s3Key); - for (const v of scriptVersions) { - if (v.scriptKey) s3KeysFromSchema.push(v.scriptKey); + add('default', n.pdfTemplateKey); + add('default', n.pdfSignedKey); } + add('default', orgChart?.uploadedImageUrl ?? null); + for (const v of scriptVersions) add('default', v.scriptKey); + for (const q of questionnaires) add('questionnaire', q.s3Key); + for (const k of kbDocs) add('knowledgeBase', k.s3Key); for (const a of attachments) { if (!a.url) continue; try { - s3KeysFromSchema.push(extractS3KeyFromUrl(a.url)); + add('default', extractS3KeyFromUrl(a.url)); } catch (err) { this.logger.warn( `Skipping attachment with unparseable URL during purge of ${organizationId}`, @@ -112,6 +120,13 @@ export class PurgeOrganizationSnapshotService { } } + const s3KeysByBucket: PurgeS3KeysByBucket = {}; + for (const [bucket, set] of Object.entries(keysByBucket) as Array< + [PurgeS3BucketRef, Set] + >) { + if (set.size > 0) s3KeysByBucket[bucket] = [...set]; + } + return { organization: { id: org.id, name: org.name, slug: org.slug }, counts, @@ -119,7 +134,7 @@ export class PurgeOrganizationSnapshotService { customerId: billing?.stripeCustomerId ?? null, subscriptionId: pentest?.stripeSubscriptionId ?? null, }, - s3KeysFromSchema: [...new Set(s3KeysFromSchema)], + s3KeysByBucket, knowledgeBaseDocumentIds: kbDocs.map((d) => d.id), manualAnswerIds: manualAnswers.map((m) => m.id), integrations: integrations.map((i) => ({ diff --git a/apps/api/src/admin-organizations/purge-organization.service.spec.ts b/apps/api/src/admin-organizations/purge-organization.service.spec.ts index 85464f1598..a054a89d73 100644 --- a/apps/api/src/admin-organizations/purge-organization.service.spec.ts +++ b/apps/api/src/admin-organizations/purge-organization.service.spec.ts @@ -24,9 +24,9 @@ jest.mock('@db', () => ({ import { db } from '@db'; import { PurgeOrganizationService } from './purge-organization.service'; -import type { PurgeOrganizationSnapshotService } from './purge-organization-snapshot.service'; -import type { PurgeOrganizationExternalService } from './purge-organization-external.service'; -import type { PurgeSnapshot } from './purge-organization.types'; +import { PurgeOrganizationSnapshotService } from './purge-organization-snapshot.service'; +import { PurgeOrganizationExternalService } from './purge-organization-external.service'; +import { PurgeSnapshot } from './purge-organization.types'; const mockDb = db as unknown as Record>; @@ -34,7 +34,7 @@ const sampleSnapshot: PurgeSnapshot = { organization: { id: 'org_1', name: 'Acme', slug: 'acme' }, counts: { policies: 5 }, stripe: { customerId: 'cus_1', subscriptionId: 'sub_1' }, - s3KeysFromSchema: ['org_1/logo/a.png'], + s3KeysByBucket: { orgAssets: ['org_1/logo/a.png'] }, knowledgeBaseDocumentIds: ['kbd_1'], manualAnswerIds: ['ma_1'], integrations: [{ id: 'icn_1', provider: 'google' }], diff --git a/apps/api/src/admin-organizations/purge-organization.service.ts b/apps/api/src/admin-organizations/purge-organization.service.ts index 9096306a79..39c5baa64e 100644 --- a/apps/api/src/admin-organizations/purge-organization.service.ts +++ b/apps/api/src/admin-organizations/purge-organization.service.ts @@ -174,7 +174,12 @@ export class PurgeOrganizationService { counts: params.snapshot.counts, stripe: params.snapshot.stripe, integrations: params.snapshot.integrations, - s3KeyCount: params.snapshot.s3KeysFromSchema.length, + s3KeyCountByBucket: Object.fromEntries( + Object.entries(params.snapshot.s3KeysByBucket).map(([k, v]) => [ + k, + v.length, + ]), + ), knowledgeBaseDocumentIds: params.snapshot.knowledgeBaseDocumentIds, manualAnswerIdCount: params.snapshot.manualAnswerIds.length, }; @@ -185,24 +190,17 @@ export class PurgeOrganizationService { >; } - try { - await db.auditLog.create({ - data: { - organizationId: params.loggingOrgId, - userId: params.adminUserId, - memberId: null, - entityType: AuditLogEntityType.organization, - entityId: params.snapshot.organization.id, - description, - data: data as Prisma.InputJsonValue, - }, - }); - } catch (err) { - this.logger.error( - 'Failed to write platform-admin purge audit log', - err instanceof Error ? err.message : err, - ); - } + await db.auditLog.create({ + data: { + organizationId: params.loggingOrgId, + userId: params.adminUserId, + memberId: null, + entityType: AuditLogEntityType.organization, + entityId: params.snapshot.organization.id, + description, + data: data as Prisma.InputJsonValue, + }, + }); } private async findAdminMembershipOrgId( diff --git a/apps/api/src/admin-organizations/purge-organization.types.ts b/apps/api/src/admin-organizations/purge-organization.types.ts index 394272088a..1bde5f28eb 100644 --- a/apps/api/src/admin-organizations/purge-organization.types.ts +++ b/apps/api/src/admin-organizations/purge-organization.types.ts @@ -1,3 +1,11 @@ +export type PurgeS3BucketRef = + | 'orgAssets' + | 'default' + | 'knowledgeBase' + | 'questionnaire'; + +export type PurgeS3KeysByBucket = Partial>; + export interface PurgeSnapshot { organization: { id: string; name: string; slug: string }; counts: Record; @@ -5,7 +13,7 @@ export interface PurgeSnapshot { customerId: string | null; subscriptionId: string | null; }; - s3KeysFromSchema: string[]; + s3KeysByBucket: PurgeS3KeysByBucket; knowledgeBaseDocumentIds: string[]; manualAnswerIds: string[]; integrations: { id: string; provider: string }[]; diff --git a/apps/api/src/admin-organizations/skip-admin-audit-log.decorator.ts b/apps/api/src/admin-organizations/skip-admin-audit-log.decorator.ts new file mode 100644 index 0000000000..365c35b095 --- /dev/null +++ b/apps/api/src/admin-organizations/skip-admin-audit-log.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_ADMIN_AUDIT_LOG_KEY = 'skipAdminAuditLog'; + +export const SkipAdminAuditLog = () => + SetMetadata(SKIP_ADMIN_AUDIT_LOG_KEY, true); From 463bcab941106054b7a103a021f19a60cdf87959 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Wed, 22 Apr 2026 12:27:50 +0100 Subject: [PATCH 3/5] fix(admin-organizations): make completion audit best-effort; verify non-prefix S3 keys - The completion audit log write no longer fails the request: the org is already deleted, so a failure there would falsely tell the caller the purge failed. The initiated audit record is the durable trail. - verifyS3Clean now HEADs any schema-referenced key that does not live under the `${orgId}/` prefix, so legacy/non-prefixed objects cannot survive a "clean" verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../purge-organization-external.service.ts | 35 ++++++++++++++- .../purge-organization.service.spec.ts | 45 +++++++++++++++++++ .../purge-organization.service.ts | 37 ++++++++++----- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/apps/api/src/admin-organizations/purge-organization-external.service.ts b/apps/api/src/admin-organizations/purge-organization-external.service.ts index 96a863e5c9..0fb16330f7 100644 --- a/apps/api/src/admin-organizations/purge-organization-external.service.ts +++ b/apps/api/src/admin-organizations/purge-organization-external.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { DeleteObjectsCommand, + HeadObjectCommand, ListObjectsV2Command, type ObjectIdentifier, } from '@aws-sdk/client-s3'; @@ -200,11 +201,15 @@ export class PurgeOrganizationExternalService { return deleted; } - async verifyS3Clean(organizationId: string): Promise { + async verifyS3Clean( + organizationId: string, + snapshot: PurgeSnapshot, + ): Promise { if (!s3Client) return true; for (const ref of BUCKET_REFS) { const bucket = BUCKET_ENV[ref]; if (!bucket) continue; + const remaining = await s3Client.send( new ListObjectsV2Command({ Bucket: bucket, @@ -213,7 +218,35 @@ export class PurgeOrganizationExternalService { }), ); if ((remaining.Contents ?? []).length > 0) return false; + + // Schema-referenced keys may not live under the `${orgId}/` prefix + // (legacy uploads, cross-org-shared paths, etc.), so verify each + // captured key is actually gone. + const keys = snapshot.s3KeysByBucket[ref] ?? []; + for (const key of keys) { + if (key.startsWith(`${organizationId}/`)) continue; + try { + await s3Client.send( + new HeadObjectCommand({ Bucket: bucket, Key: key }), + ); + return false; + } catch (err) { + if (!this.isS3NotFound(err)) throw err; + } + } } return true; } + + private isS3NotFound(err: unknown): boolean { + const e = err as + | { name?: string; $metadata?: { httpStatusCode?: number } } + | undefined; + return ( + !!e && + (e.name === 'NotFound' || + e.name === 'NoSuchKey' || + e.$metadata?.httpStatusCode === 404) + ); + } } diff --git a/apps/api/src/admin-organizations/purge-organization.service.spec.ts b/apps/api/src/admin-organizations/purge-organization.service.spec.ts index a054a89d73..6620b8aac4 100644 --- a/apps/api/src/admin-organizations/purge-organization.service.spec.ts +++ b/apps/api/src/admin-organizations/purge-organization.service.spec.ts @@ -196,6 +196,51 @@ describe('PurgeOrganizationService', () => { expect(mockDb.organization.delete).not.toHaveBeenCalled(); }); + it('succeeds even if completion audit log write fails', async () => { + mockDb.auditLog.create + .mockResolvedValueOnce({}) // initiated + .mockRejectedValueOnce(new Error('db unavailable')); // completed + + const result = await service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }); + + expect(result.success).toBe(true); + expect(mockDb.organization.delete).toHaveBeenCalled(); + }); + + it('fails closed if the initiated audit log write fails, before deletion', async () => { + mockDb.auditLog.create.mockRejectedValueOnce(new Error('db unavailable')); + + await expect( + service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }), + ).rejects.toThrow(/db unavailable/); + + expect(externalService.cleanupStripe).not.toHaveBeenCalled(); + expect(mockDb.organization.delete).not.toHaveBeenCalled(); + }); + + it('passes snapshot to verifyS3Clean so non-prefix keys can be verified', async () => { + await service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }); + + expect(externalService.verifyS3Clean).toHaveBeenCalledWith( + 'org_1', + expect.objectContaining({ + s3KeysByBucket: expect.any(Object), + }), + ); + }); + it('fails closed when admin has no other membership for audit trail', async () => { mockDb.member.findFirst.mockResolvedValue(null); diff --git a/apps/api/src/admin-organizations/purge-organization.service.ts b/apps/api/src/admin-organizations/purge-organization.service.ts index 39c5baa64e..04332271c0 100644 --- a/apps/api/src/admin-organizations/purge-organization.service.ts +++ b/apps/api/src/admin-organizations/purge-organization.service.ts @@ -76,7 +76,7 @@ export class PurgeOrganizationService { await db.organization.delete({ where: { id: organizationId } }); - await this.verifyDeletion(organizationId); + await this.verifyDeletion(organizationId, snapshot); const externalCleanup: PurgeExternalCleanupResult = { stripe: stripeResult, @@ -84,13 +84,24 @@ export class PurgeOrganizationService { vectorStore: vectorResult, }; - await this.writeAdminAuditLog({ - loggingOrgId, - adminUserId, - snapshot, - status: 'completed', - externalCleanup, - }); + // Completion audit is best-effort: the purge has already succeeded, so + // failing the request here would lie to the caller about deletion state. + // The "initiated" record written earlier is the durable trail. + try { + await this.writeAdminAuditLog({ + loggingOrgId, + adminUserId, + snapshot, + status: 'completed', + externalCleanup, + }); + } catch (err) { + this.logger.error( + `Failed to write completion audit log for purge of ${organizationId}; ` + + `deletion succeeded, initiated audit record is the record of truth`, + err instanceof Error ? err.stack : err, + ); + } return { success: true, @@ -118,7 +129,10 @@ export class PurgeOrganizationService { } } - private async verifyDeletion(organizationId: string): Promise { + private async verifyDeletion( + organizationId: string, + snapshot: PurgeSnapshot, + ): Promise { const where = { organizationId }; const checks: Array<[string, Promise]> = [ ['apiKey', db.apiKey.count({ where })], @@ -146,7 +160,10 @@ export class PurgeOrganizationService { ); } - const s3Clean = await this.externalService.verifyS3Clean(organizationId); + const s3Clean = await this.externalService.verifyS3Clean( + organizationId, + snapshot, + ); if (!s3Clean) { throw new Error( `Organization ${organizationId} purge verification failed — S3 objects remain under prefix`, From fae4aeb1525e82840cf134a80ad0d502503fd4b2 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Wed, 22 Apr 2026 13:18:39 +0100 Subject: [PATCH 4/5] fix(admin-organizations): harden purge per security review - DTO: require non-empty confirm string (defense in depth around slug match) - findAdminMembershipOrgId: require logging membership to be >=1h old, so an admin cannot self-invite into an arbitrary org immediately before a purge to satisfy the audit-trail requirement - Emit a "failed" audit record when external cleanup throws, pairing the "initiated" record so the audit trail is never left open-ended - Move S3-clean verification before the DB delete so the purge can fail safely while the org still exists - Post-delete DB verification no longer throws on leftover rows (the org is already gone); return a partial-success result instead and log loudly Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dto/purge-organization.dto.ts | 4 +- .../purge-organization.service.spec.ts | 36 ++++- .../purge-organization.service.ts | 144 +++++++++++++----- .../purge-organization.types.ts | 7 + 4 files changed, 145 insertions(+), 46 deletions(-) diff --git a/apps/api/src/admin-organizations/dto/purge-organization.dto.ts b/apps/api/src/admin-organizations/dto/purge-organization.dto.ts index 7bf9396039..6427b5a03c 100644 --- a/apps/api/src/admin-organizations/dto/purge-organization.dto.ts +++ b/apps/api/src/admin-organizations/dto/purge-organization.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; export class PurgeOrganizationDto { @ApiProperty({ @@ -8,5 +8,7 @@ export class PurgeOrganizationDto { example: 'acme-corp', }) @IsString() + @IsNotEmpty() + @MinLength(1) confirm: string; } diff --git a/apps/api/src/admin-organizations/purge-organization.service.spec.ts b/apps/api/src/admin-organizations/purge-organization.service.spec.ts index 6620b8aac4..c6930cabe6 100644 --- a/apps/api/src/admin-organizations/purge-organization.service.spec.ts +++ b/apps/api/src/admin-organizations/purge-organization.service.spec.ts @@ -135,8 +135,8 @@ describe('PurgeOrganizationService', () => { 'stripe', 'vector', 's3', - 'db-delete', 'verify-s3', + 'db-delete', ]); expect(result.success).toBe(true); expect(result.deletedCounts).toEqual({ policies: 5 }); @@ -144,26 +144,50 @@ describe('PurgeOrganizationService', () => { expect(result.externalCleanup.s3.objectsDeleted).toBe(3); }); - it('throws when verification finds leftover rows', async () => { + it('returns partial-success when post-delete verification finds leftover rows', async () => { mockDb.policy.count.mockResolvedValue(5); + + const result = await service.purgeOrganization({ + organizationId: 'org_1', + confirm: 'acme', + adminUserId: 'u1', + }); + + expect(result.success).toBe(true); + expect(result.verification.verified).toBe(false); + expect(result.verification.leftoverRows).toEqual({ policy: 5 }); + expect(mockDb.organization.delete).toHaveBeenCalled(); + }); + + it('throws pre-delete when S3 verification reports leftover objects', async () => { + externalService.verifyS3Clean.mockResolvedValue(false); await expect( service.purgeOrganization({ organizationId: 'org_1', confirm: 'acme', adminUserId: 'u1', }), - ).rejects.toThrow(/verification failed/); + ).rejects.toThrow(/S3 objects remain/); + + // Org is NOT deleted if S3 cleanup is incomplete + expect(mockDb.organization.delete).not.toHaveBeenCalled(); }); - it('throws when S3 verification reports leftover objects', async () => { - externalService.verifyS3Clean.mockResolvedValue(false); + it('writes a failed audit log when external cleanup throws', async () => { + externalService.cleanupStripe.mockRejectedValue(new Error('stripe down')); + await expect( service.purgeOrganization({ organizationId: 'org_1', confirm: 'acme', adminUserId: 'u1', }), - ).rejects.toThrow(/S3 objects remain/); + ).rejects.toBeInstanceOf(InternalServerErrorException); + + const calls = mockDb.auditLog.create.mock.calls; + const statuses = calls.map(([arg]) => arg.data.data.status); + expect(statuses).toContain('initiated'); + expect(statuses).toContain('failed'); }); it('writes persistent audit log to admin member org', async () => { diff --git a/apps/api/src/admin-organizations/purge-organization.service.ts b/apps/api/src/admin-organizations/purge-organization.service.ts index 04332271c0..9f25275f39 100644 --- a/apps/api/src/admin-organizations/purge-organization.service.ts +++ b/apps/api/src/admin-organizations/purge-organization.service.ts @@ -12,6 +12,7 @@ import type { PurgeExternalCleanupResult, PurgeResult, PurgeSnapshot, + PurgeVerificationResult, } from './purge-organization.types'; @Injectable() @@ -60,23 +61,55 @@ export class PurgeOrganizationService { status: 'initiated', }); - const stripeResult = await this.runExternalStep( - 'stripe', - organizationId, - () => this.externalService.cleanupStripe(snapshot.stripe), - ); - const vectorResult = await this.runExternalStep( - 'vector-store', - organizationId, - () => this.externalService.cleanupVectorStore(snapshot), - ); - const s3Result = await this.runExternalStep('s3', organizationId, () => - this.externalService.cleanupS3(organizationId, snapshot), - ); + let stripeResult: PurgeExternalCleanupResult['stripe']; + let vectorResult: PurgeExternalCleanupResult['vectorStore']; + let s3Result: PurgeExternalCleanupResult['s3']; + try { + stripeResult = await this.runExternalStep('stripe', organizationId, () => + this.externalService.cleanupStripe(snapshot.stripe), + ); + vectorResult = await this.runExternalStep( + 'vector-store', + organizationId, + () => this.externalService.cleanupVectorStore(snapshot), + ); + s3Result = await this.runExternalStep('s3', organizationId, () => + this.externalService.cleanupS3(organizationId, snapshot), + ); - await db.organization.delete({ where: { id: organizationId } }); + // Verify S3 is clean *before* deleting the DB row. After the DB delete + // we can no longer fail safely — the org is gone regardless. + const s3Clean = await this.externalService.verifyS3Clean( + organizationId, + snapshot, + ); + if (!s3Clean) { + throw new Error( + `Organization ${organizationId} purge verification failed — S3 objects remain`, + ); + } - await this.verifyDeletion(organizationId, snapshot); + await db.organization.delete({ where: { id: organizationId } }); + } catch (err) { + // Pair the "initiated" record with a "failed" record so the audit trail + // is not left open-ended. Best-effort: we do not want a secondary write + // failure to mask the underlying purge error. + try { + await this.writeAdminAuditLog({ + loggingOrgId, + adminUserId, + snapshot, + status: 'failed', + failureReason: err instanceof Error ? err.message : String(err), + }); + } catch (logErr) { + this.logger.error( + `Failed to write failure audit log for purge of ${organizationId}`, + logErr instanceof Error ? logErr.stack : logErr, + ); + } + throw err; + } const externalCleanup: PurgeExternalCleanupResult = { stripe: stripeResult, @@ -84,6 +117,20 @@ export class PurgeOrganizationService { vectorStore: vectorResult, }; + // Post-delete verification of cascade completeness. We do not throw on + // leftovers here because the org is already gone — throwing would lie + // to the caller about deletion state. Surface it in the response and + // log loudly so operators can investigate. + const verification = await this.verifyDeletion(organizationId); + if (!verification.verified) { + this.logger.error( + `Organization ${organizationId} deleted but cascade left rows: ` + + Object.entries(verification.leftoverRows) + .map(([k, v]) => `${k}=${v}`) + .join(', '), + ); + } + // Completion audit is best-effort: the purge has already succeeded, so // failing the request here would lie to the caller about deletion state. // The "initiated" record written earlier is the durable trail. @@ -94,6 +141,7 @@ export class PurgeOrganizationService { snapshot, status: 'completed', externalCleanup, + verification, }); } catch (err) { this.logger.error( @@ -108,6 +156,7 @@ export class PurgeOrganizationService { organizationId, deletedCounts: snapshot.counts, externalCleanup, + verification, }; } @@ -131,8 +180,7 @@ export class PurgeOrganizationService { private async verifyDeletion( organizationId: string, - snapshot: PurgeSnapshot, - ): Promise { + ): Promise { const where = { organizationId }; const checks: Array<[string, Promise]> = [ ['apiKey', db.apiKey.count({ where })], @@ -150,41 +198,41 @@ export class PurgeOrganizationService { const results = await Promise.all( checks.map(async ([name, p]) => [name, await p] as const), ); - const leftovers = results.filter(([, count]) => count > 0); - if (leftovers.length > 0) { - const summary = leftovers - .map(([name, count]) => `${name}=${count}`) - .join(', '); - throw new Error( - `Organization ${organizationId} purge verification failed — leftover rows: ${summary}`, - ); + const leftoverRows: Record = {}; + for (const [name, count] of results) { + if (count > 0) leftoverRows[name] = count; } - const s3Clean = await this.externalService.verifyS3Clean( - organizationId, - snapshot, - ); - if (!s3Clean) { - throw new Error( - `Organization ${organizationId} purge verification failed — S3 objects remain under prefix`, - ); - } + // S3 was already verified pre-delete, but we've kept the field for + // compatibility with a potential future async verification pass. + const s3Clean = true; + + return { + verified: Object.keys(leftoverRows).length === 0 && s3Clean, + leftoverRows, + s3Clean, + }; } private async writeAdminAuditLog(params: { loggingOrgId: string; adminUserId: string; snapshot: PurgeSnapshot; - status: 'initiated' | 'completed'; + status: 'initiated' | 'completed' | 'failed'; externalCleanup?: PurgeExternalCleanupResult; + failureReason?: string; + verification?: PurgeVerificationResult; }): Promise { - const description = - params.status === 'initiated' - ? `Initiated purge of organization '${params.snapshot.organization.name}'` - : `Completed purge of organization '${params.snapshot.organization.name}'`; + const descriptions: Record = { + initiated: `Initiated purge of organization '${params.snapshot.organization.name}'`, + completed: `Completed purge of organization '${params.snapshot.organization.name}'`, + failed: `Failed purge of organization '${params.snapshot.organization.name}'`, + }; + const description = descriptions[params.status]; const data: Record = { action: description, + status: params.status, resource: 'admin', permission: 'platform-admin', targetOrganization: params.snapshot.organization, @@ -206,6 +254,15 @@ export class PurgeOrganizationService { unknown >; } + if (params.failureReason) { + data.failureReason = params.failureReason; + } + if (params.verification) { + data.verification = params.verification as unknown as Record< + string, + unknown + >; + } await db.auditLog.create({ data: { @@ -220,15 +277,24 @@ export class PurgeOrganizationService { }); } + // Membership must predate this request by at least this much, to prevent an + // admin from self-inviting into an arbitrary org immediately before a purge + // to satisfy the audit-trail requirement. + private static readonly MIN_LOGGING_MEMBERSHIP_AGE_MS = 60 * 60 * 1000; + private async findAdminMembershipOrgId( adminUserId: string, excludeOrgId: string, ): Promise { + const cutoff = new Date( + Date.now() - PurgeOrganizationService.MIN_LOGGING_MEMBERSHIP_AGE_MS, + ); const member = await db.member.findFirst({ where: { userId: adminUserId, organizationId: { not: excludeOrgId }, deactivated: false, + createdAt: { lt: cutoff }, }, select: { organizationId: true }, orderBy: { createdAt: 'asc' }, diff --git a/apps/api/src/admin-organizations/purge-organization.types.ts b/apps/api/src/admin-organizations/purge-organization.types.ts index 1bde5f28eb..68da879c8e 100644 --- a/apps/api/src/admin-organizations/purge-organization.types.ts +++ b/apps/api/src/admin-organizations/purge-organization.types.ts @@ -28,9 +28,16 @@ export interface PurgeExternalCleanupResult { }; } +export interface PurgeVerificationResult { + verified: boolean; + leftoverRows: Record; + s3Clean: boolean; +} + export interface PurgeResult { success: true; organizationId: string; deletedCounts: Record; externalCleanup: PurgeExternalCleanupResult; + verification: PurgeVerificationResult; } From 987ad1ef5648023417779debb1a63ec3dd12f237 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:59:21 -0400 Subject: [PATCH 5/5] fix: ff timeline in framework tab [dev] [Marfuen] mariano/feature-flag-compliance-timeline --- .../[frameworkInstanceId]/components/FrameworkTimeline.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkTimeline.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkTimeline.tsx index ffb0cfafa0..87ffd9a63b 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkTimeline.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkTimeline.tsx @@ -7,6 +7,7 @@ import { CircleDash, Time, } from '@trycompai/design-system/icons'; +import { useFeatureFlag } from '@trycompai/analytics'; import { useState } from 'react'; import { useTimelines, @@ -46,8 +47,11 @@ function getTimeRemaining(endDate: string | null): string | null { export function FrameworkTimeline({ frameworkInstanceId, }: FrameworkTimelineProps) { + const isTimelineEnabled = useFeatureFlag('is-timeline-enabled'); const { timelines } = useTimelines(); + if (!isTimelineEnabled) return null; + const timeline = timelines.find( (t) => t.frameworkInstanceId === frameworkInstanceId, );