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]
|
type IntegrationKey = (typeof INTEGRATIONS)[number]
|
||||||
|
|
||||||
// ── Danger-zone state ─────────────────────────────────────────────────────
|
// ── 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 dangerBusy = ref(false)
|
||||||
const dangerError = ref<string | null>(null)
|
const dangerError = ref<string | null>(null)
|
||||||
const reconcileBusy = ref(false)
|
const reconcileBusy = ref(false)
|
||||||
@@ -99,6 +99,11 @@ async function confirmDanger() {
|
|||||||
await $fetch(`/api/tenants/${slug.value}/resume`, { method: 'POST' })
|
await $fetch(`/api/tenants/${slug.value}/resume`, { method: 'POST' })
|
||||||
} else if (dangerAction.value === 'delete') {
|
} else if (dangerAction.value === 'delete') {
|
||||||
await $fetch(`/api/tenants/${slug.value}`, { method: '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()
|
await refreshTenant()
|
||||||
dangerAction.value = null
|
dangerAction.value = null
|
||||||
@@ -343,14 +348,27 @@ async function reconcile() {
|
|||||||
@click="dangerAction = 'delete'; dangerError = null"
|
@click="dangerAction = 'delete'; dangerError = null"
|
||||||
>Soft-delete tenant</UiButton>
|
>Soft-delete tenant</UiButton>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="dangerAction !== null"
|
:open="dangerAction !== null"
|
||||||
:eyebrow="`Tenant · ${tenant.slug}`"
|
:eyebrow="`Tenant · ${tenant.slug}`"
|
||||||
:title="dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
|
:title="dangerAction === 'purge' ? 'Purge this tenant permanently?' : dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
|
||||||
:confirm-label="dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
|
:confirm-label="dangerAction === 'purge' ? 'Purge forever' : dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
|
||||||
:tone="dangerAction === 'resume' ? 'primary' : 'danger'"
|
:tone="dangerAction === 'resume' ? 'primary' : 'danger'"
|
||||||
:busy="dangerBusy"
|
:busy="dangerBusy"
|
||||||
@close="dangerAction = null"
|
@close="dangerAction = null"
|
||||||
@@ -363,6 +381,11 @@ async function reconcile() {
|
|||||||
<p v-else-if="dangerAction === 'resume'">
|
<p v-else-if="dangerAction === 'resume'">
|
||||||
Lift the suspension on <strong>{{ tenant.name }}</strong>. Logins resume immediately.
|
Lift the suspension on <strong>{{ tenant.name }}</strong>. Logins resume immediately.
|
||||||
</p>
|
</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>
|
<p v-else>
|
||||||
Mark <strong>{{ tenant.name }}</strong> as deleted. Their data stays in Mongo until
|
Mark <strong>{{ tenant.name }}</strong> as deleted. Their data stays in Mongo until
|
||||||
the cleanup job runs; external resources stay untouched.
|
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))
|
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
|
// Manually re-run provisioning. Useful when an integration was down at create
|
||||||
// time, or when external state drifted (someone deleted the Authentik group
|
// time, or when external state drifted (someone deleted the Authentik group
|
||||||
// out of band). Idempotent — already-OK steps no-op.
|
// 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 { AuthModule } from '../auth/auth.module.js'
|
||||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||||
import { PricesModule } from '../prices/prices.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 { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||||
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
|
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
|
||||||
import { TenantSsoApp, TenantSsoAppSchema } from '../schemas/tenant-sso-app.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.
|
// lookup goes through PricesService for the soft-active filter.
|
||||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||||
{ name: TenantBranding.name, schema: TenantBrandingSchema },
|
{ 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 },
|
{ name: TenantSsoApp.name, schema: TenantSsoAppSchema },
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import { InjectModel } from '@nestjs/mongoose'
|
|||||||
import { Model, Types } from 'mongoose'
|
import { Model, Types } from 'mongoose'
|
||||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
import { AuthentikClient } from '../integrations/authentik.client.js'
|
import { AuthentikClient } from '../integrations/authentik.client.js'
|
||||||
|
import { StalwartClient } from '../integrations/stalwart.client.js'
|
||||||
import { StripeClient } from '../integrations/stripe.client.js'
|
import { StripeClient } from '../integrations/stripe.client.js'
|
||||||
import { PricesService } from '../prices/prices.service.js'
|
import { PricesService } from '../prices/prices.service.js'
|
||||||
import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.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 { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserDocument, roleForTenant } from '../schemas/user.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(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||||
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
@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 provisioning: ProvisioningService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly prices: PricesService,
|
private readonly prices: PricesService,
|
||||||
private readonly stripe: StripeClient,
|
private readonly stripe: StripeClient,
|
||||||
private readonly authentik: AuthentikClient,
|
private readonly authentik: AuthentikClient,
|
||||||
|
private readonly stalwart: StalwartClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
|
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(
|
async setStatus(
|
||||||
slug: string,
|
slug: string,
|
||||||
status: 'active' | 'suspended',
|
status: 'active' | 'suspended',
|
||||||
|
|||||||
Reference in New Issue
Block a user