feat(tenants): hard-delete (purge) for soft-deleted tenants
ci / tc_portal (push) Has been skipped
ci / build_operator (push) Successful in 30s
ci / test_platform_api (push) Successful in 33s
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 24s
ci / tc_operator (push) Successful in 24s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Successful in 40s

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.
This commit is contained in:
Ronni Baslund
2026-06-10 21:07:08 +02:00
parent 25d932d3c1
commit fb4ff48617
5 changed files with 112 additions and 3 deletions
+26 -3
View File
@@ -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<string | null>(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</UiButton>
</Card>
<Card v-if="tenant.status === 'deleted'">
<h2 class="danger">Purge permanently</h2>
<p>
Tears down external resources (Stalwart domains, Authentik group) and removes
every Mongo record except the audit trail. Frees the slug for reuse.
Irreversible.
</p>
<UiButton
variant="danger"
@click="dangerAction = 'purge'; dangerError = null"
>Purge tenant</UiButton>
</Card>
</div>
</div>
<ConfirmDialog
:open="dangerAction !== null"
:eyebrow="`Tenant · ${tenant.slug}`"
:title="dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
:confirm-label="dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
:title="dangerAction === 'purge' ? 'Purge this tenant permanently?' : dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
:confirm-label="dangerAction === 'purge' ? 'Purge forever' : dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
:tone="dangerAction === 'resume' ? 'primary' : 'danger'"
:busy="dangerBusy"
@close="dangerAction = null"
@@ -363,6 +381,11 @@ async function reconcile() {
<p v-else-if="dangerAction === 'resume'">
Lift the suspension on <strong>{{ tenant.name }}</strong>. Logins resume immediately.
</p>
<p v-else-if="dangerAction === 'purge'">
Permanently erase <strong>{{ tenant.name }}</strong>: Stalwart domains and the
Authentik group are deleted, and all records except the audit trail are removed.
This cannot be undone.
</p>
<p v-else>
Mark <strong>{{ tenant.name }}</strong> as deleted. Their data stays in Mongo until
the cleanup job runs; external resources stay untouched.
@@ -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' })
})
@@ -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<typeof clientIp>[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.
@@ -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,
@@ -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<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
@InjectModel(Invoice.name) private readonly invoiceModel: Model<InvoiceDocument>,
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<UserDocument[]> {
@@ -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<void> {
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',