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',