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
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user