From fb4ff48617cec5ead8e057623082dcb586057f61 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Wed, 10 Jun 2026 21:07:08 +0200 Subject: [PATCH] feat(tenants): hard-delete (purge) for soft-deleted tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-delete kept the slug occupied forever — no way to remove a test tenant and reuse its name, and external resources lingered. DELETE /tenants/:slug/purge (platform-admin only, two-step: refuses anything not already soft-deleted) tears down the Stalwart service + customer domains (never the platform apex — the management admin account lives there) and the Authentik group, then removes domains/subscriptions/invoices/user links/the tenant doc. Audit trail is kept. Operator detail page shows a 'Purge permanently' card once a tenant is soft-deleted. --- apps/operator/pages/tenants/[slug].vue | 29 ++++++++- .../server/api/tenants/[slug]/purge.delete.ts | 6 ++ .../src/tenants/tenants.controller.ts | 13 ++++ .../src/tenants/tenants.module.ts | 5 ++ .../src/tenants/tenants.service.ts | 62 +++++++++++++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 apps/operator/server/api/tenants/[slug]/purge.delete.ts diff --git a/apps/operator/pages/tenants/[slug].vue b/apps/operator/pages/tenants/[slug].vue index 29c9ec9..8fc710f 100644 --- a/apps/operator/pages/tenants/[slug].vue +++ b/apps/operator/pages/tenants/[slug].vue @@ -82,7 +82,7 @@ const INTEGRATIONS = ['authentik', 'stalwart', 'ocis'] as const type IntegrationKey = (typeof INTEGRATIONS)[number] // ── Danger-zone state ───────────────────────────────────────────────────── -const dangerAction = ref<'suspend' | 'resume' | 'delete' | null>(null) +const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null) const dangerBusy = ref(false) const dangerError = ref(null) const reconcileBusy = ref(false) @@ -99,6 +99,11 @@ async function confirmDanger() { await $fetch(`/api/tenants/${slug.value}/resume`, { method: 'POST' }) } else if (dangerAction.value === 'delete') { await $fetch(`/api/tenants/${slug.value}`, { method: 'DELETE' }) + } else if (dangerAction.value === 'purge') { + await $fetch(`/api/tenants/${slug.value}/purge`, { method: 'DELETE' }) + // The tenant no longer exists — back to the list. + await navigateTo('/tenants') + return } await refreshTenant() dangerAction.value = null @@ -343,14 +348,27 @@ async function reconcile() { @click="dangerAction = 'delete'; dangerError = null" >Soft-delete tenant + + +

Purge permanently

+

+ Tears down external resources (Stalwart domains, Authentik group) and removes + every Mongo record except the audit trail. Frees the slug for reuse. + Irreversible. +

+ Purge tenant +
Lift the suspension on {{ tenant.name }}. Logins resume immediately.

+

+ Permanently erase {{ tenant.name }}: Stalwart domains and the + Authentik group are deleted, and all records except the audit trail are removed. + This cannot be undone. +

Mark {{ tenant.name }} as deleted. Their data stays in Mongo until the cleanup job runs; external resources stay untouched. diff --git a/apps/operator/server/api/tenants/[slug]/purge.delete.ts b/apps/operator/server/api/tenants/[slug]/purge.delete.ts new file mode 100644 index 0000000..356c806 --- /dev/null +++ b/apps/operator/server/api/tenants/[slug]/purge.delete.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/tenants/${slug}/purge`, { method: 'DELETE' }) +}) diff --git a/services/platform-api/src/tenants/tenants.controller.ts b/services/platform-api/src/tenants/tenants.controller.ts index df9ec6a..99443cc 100644 --- a/services/platform-api/src/tenants/tenants.controller.ts +++ b/services/platform-api/src/tenants/tenants.controller.ts @@ -292,6 +292,19 @@ export class TenantsController { await this.tenants.softDelete(slug, auditActor(user, req)) } + // Hard delete. Only valid on an already soft-deleted tenant (two-step on + // purpose) — tears down external resources and removes every Mongo trace + // except the audit log, freeing the slug for reuse. + @Delete(':slug/purge') + @HttpCode(204) + async purge(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0]) { + const user = await this.actor.resolve(jwt) + if (!user.platformAdmin) { + throw new ForbiddenException('Only platform admins can purge tenants') + } + await this.tenants.purge(slug, auditActor(user, req)) + } + // Manually re-run provisioning. Useful when an integration was down at create // time, or when external state drifted (someone deleted the Authentik group // out of band). Idempotent — already-OK steps no-op. diff --git a/services/platform-api/src/tenants/tenants.module.ts b/services/platform-api/src/tenants/tenants.module.ts index 182b3db..a5295bf 100644 --- a/services/platform-api/src/tenants/tenants.module.ts +++ b/services/platform-api/src/tenants/tenants.module.ts @@ -4,6 +4,8 @@ import { AuditModule } from '../audit/audit.module.js' import { AuthModule } from '../auth/auth.module.js' import { IntegrationsModule } from '../integrations/integrations.module.js' import { PricesModule } from '../prices/prices.module.js' +import { Domain, DomainSchema } from '../schemas/domain.schema.js' +import { Invoice, InvoiceSchema } from '../schemas/invoice.schema.js' import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js' import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js' import { TenantSsoApp, TenantSsoAppSchema } from '../schemas/tenant-sso-app.schema.js' @@ -26,6 +28,9 @@ import { TenantsService } from './tenants.service.js' // lookup goes through PricesService for the soft-active filter. { name: Subscription.name, schema: SubscriptionSchema }, { name: TenantBranding.name, schema: TenantBrandingSchema }, + // Domain + Invoice are only touched by purge() — the hard-delete cascade. + { name: Domain.name, schema: DomainSchema }, + { name: Invoice.name, schema: InvoiceSchema }, { name: TenantSsoApp.name, schema: TenantSsoAppSchema }, ]), AuthModule, diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts index f65be0d..e297b82 100644 --- a/services/platform-api/src/tenants/tenants.service.ts +++ b/services/platform-api/src/tenants/tenants.service.ts @@ -9,9 +9,12 @@ import { InjectModel } from '@nestjs/mongoose' import { Model, Types } from 'mongoose' import { AuditService, type AuditActor } from '../audit/audit.service.js' import { AuthentikClient } from '../integrations/authentik.client.js' +import { StalwartClient } from '../integrations/stalwart.client.js' import { StripeClient } from '../integrations/stripe.client.js' import { PricesService } from '../prices/prices.service.js' import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.js' +import { Domain, DomainDocument } from '../schemas/domain.schema.js' +import { Invoice, InvoiceDocument } from '../schemas/invoice.schema.js' import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { User, UserDocument, roleForTenant } from '../schemas/user.schema.js' @@ -30,11 +33,14 @@ export class TenantsService { @InjectModel(Tenant.name) private readonly tenantModel: Model, @InjectModel(User.name) private readonly userModel: Model, @InjectModel(Subscription.name) private readonly subModel: Model, + @InjectModel(Domain.name) private readonly domainModel: Model, + @InjectModel(Invoice.name) private readonly invoiceModel: Model, private readonly provisioning: ProvisioningService, private readonly audit: AuditService, private readonly prices: PricesService, private readonly stripe: StripeClient, private readonly authentik: AuthentikClient, + private readonly stalwart: StalwartClient, ) {} async listUsersForTenant(slug: string): Promise { @@ -379,6 +385,62 @@ export class TenantsService { ) } + // Permanently remove a soft-deleted tenant: best-effort external teardown + // (Stalwart service + customer domains, Authentik group), then the Mongo + // cascade (domains, subscriptions, invoices, user links, the tenant doc). + // Two-step by design — purge refuses tenants that aren't already + // soft-deleted, so a stray click can never erase a live customer. + async purge(slug: string, actor?: AuditActor): Promise { + const tenant = await this.findOneBySlug(slug) + if (tenant.status !== 'deleted') { + throw new ConflictException(`Tenant "${slug}" must be soft-deleted before it can be purged`) + } + + // External teardown is best-effort: a dead integration must not leave the + // tenant stuck half-purged — leftovers are visible in the integrations' + // own admin UIs and are idempotent to remove later. + const warn = (what: string) => (err: unknown) => + this.logger.warn(`purge(${slug}): ${what} teardown failed: ${(err as Error).message}`) + if (tenant.stalwartDomain) { + await this.stalwart.deleteDomain(tenant.stalwartDomain).catch(warn('stalwart service domain')) + } + const platformBase = (process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local').toLowerCase() + const domains = await this.domainModel.find({ tenantId: tenant._id }).exec() + for (const d of domains) { + // Never drop the platform apex from Stalwart — the management admin + // account lives in that domain. + if (d.stalwartProvisioned && d.domain !== platformBase) { + await this.stalwart.deleteDomain(d.domain).catch(warn(`stalwart domain ${d.domain}`)) + } + } + if (tenant.authentikGroupId) { + await this.authentik.deleteGroup(tenant.authentikGroupId).catch(warn('authentik group')) + } + + await this.domainModel.deleteMany({ tenantId: tenant._id }).exec() + await this.subModel.deleteMany({ tenantId: tenant._id }).exec() + await this.invoiceModel.deleteMany({ tenantId: tenant._id }).exec() + await this.userModel + .updateMany( + { tenantIds: tenant._id }, + { $pull: { tenantIds: tenant._id }, $unset: { [`tenantRoles.${String(tenant._id)}`]: '' } }, + ) + .exec() + await this.tenantModel.deleteOne({ _id: tenant._id }).exec() + + void this.audit.record( + { + action: 'tenant.purged', + resourceType: 'tenant', + resourceId: String(tenant._id), + resourceName: tenant.name, + tenantSlug: tenant.slug, + metadata: { stalwartDomain: tenant.stalwartDomain, customerDomains: domains.map((d) => d.domain) }, + }, + actor, + ) + } + async setStatus( slug: string, status: 'active' | 'suspended',