Files
dezky/services/platform-api/src/tenants/provisioning.service.ts
T
Ronni Baslund 47eb9502f8 feat(platform): real email domains, mailboxes & member lifecycle
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.

Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
  email domains via x:Domain at the internal http://stalwart:8080 listener;
  DKIM auto-generated; the records to publish are read from the domain's
  dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
  tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
  resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
  domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
  attention.

Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
  tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
  account permissions, original password preserved), force-logout (terminate
  sessions, filtered client-side so it can never end other users' sessions),
  reset password (new one-time password on SSO + mailbox), and remove (tear down
  mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
  users). Self-suspend / self-force-logout are blocked.

Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
2026-06-01 21:19:42 +02:00

122 lines
4.8 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import {
IntegrationState,
Tenant,
TenantDocument,
} from '../schemas/tenant.schema.js'
// Orchestrates provisioning across Authentik / Stalwart / OCIS. Each step is
// independent — one failure doesn't roll back the others — and the per-step
// status is recorded on the tenant document so the operation is idempotent
// when retried.
@Injectable()
export class ProvisioningService {
private readonly logger = new Logger(ProvisioningService.name)
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly authentik: AuthentikClient,
private readonly stalwart: StalwartClient,
private readonly ocis: OcisClient,
) {}
// Runs all integrations and writes back per-step state. Returns the refreshed
// tenant doc so the controller can return it to the caller.
async reconcile(tenant: TenantDocument): Promise<TenantDocument> {
this.logger.log(`Reconciling tenant "${tenant.slug}"`)
await this.runStep(tenant, 'authentik', async () => {
const group = await this.authentik.ensureGroup(tenant.slug, { tenantId: tenant.id })
tenant.authentikGroupId = String(group.pk)
})
// Stalwart provisioning is real when STALWART_PROVISIONING_ENABLED is on;
// otherwise we record the honest 'skipped' state. ensureDomain is idempotent
// and auto-generates the domain's DKIM keys.
await this.runStep(tenant, 'stalwart', async () => {
const domain = this.domainFor(tenant.slug)
if (!this.stalwart.configured) return 'skipped'
await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`)
tenant.stalwartDomain = domain
// falls through to 'ok' — a real upstream call succeeded
})
await this.runStep(tenant, 'ocis', async () => {
const space = await this.ocis.ensureSpace(tenant.slug)
tenant.ocisSpaceId = space.id
return 'skipped'
})
// If every required integration is either 'ok' or 'skipped' (not 'error' /
// 'pending'), activate the tenant. Skipped steps don't block activation —
// they just won't have their resources wired up yet.
const keys = ['authentik', 'stalwart', 'ocis'] as const
const allSettled = keys.every((k) => {
const s = tenant.provisioningStatus[k]
return s === 'ok' || s === 'skipped'
})
if (allSettled && tenant.status === 'pending') {
tenant.status = 'active'
}
// Mongoose doesn't auto-detect mutations inside nested subdocuments — flag
// these paths as modified so the save() actually persists our changes.
tenant.markModified('provisioningStatus')
tenant.markModified('provisioningErrors')
await tenant.save()
return tenant
}
// Step returns its terminal state explicitly. Returning void means "this step
// ran a real upstream call successfully" — that's mapped to 'ok'. Returning a
// specific state ('skipped', etc.) lets stub integrations be honest about
// not actually doing the work.
private async runStep(
tenant: TenantDocument,
key: 'authentik' | 'stalwart' | 'ocis',
work: () => Promise<IntegrationState | void>,
): Promise<void> {
try {
const result = await work()
tenant.provisioningStatus[key] = result ?? 'ok'
if (tenant.provisioningErrors[key]) delete tenant.provisioningErrors[key]
} catch (err) {
const msg = (err as Error).message
tenant.provisioningStatus[key] = 'error'
tenant.provisioningErrors[key] = msg
this.logger.error(`Tenant "${tenant.slug}" — ${key} step failed: ${msg}`)
}
}
// Maps tenant slug → mail domain. Production should use a real registered
// domain (e.g. acme.dezky.com); locally we use the .local hierarchy.
private domainFor(slug: string): string {
return `${slug}.dezky.local`
}
// Best-effort cleanup. Called when a tenant is hard-deleted (not soft-deleted).
async tearDown(tenant: TenantDocument): Promise<void> {
if (tenant.authentikGroupId) {
await this.authentik.deleteGroup(tenant.authentikGroupId).catch((err) => {
this.logger.error(`Failed to delete Authentik group: ${(err as Error).message}`)
})
}
if (tenant.stalwartDomain) {
await this.stalwart.deleteDomain(tenant.stalwartDomain).catch((err) => {
this.logger.error(`Failed to delete Stalwart domain: ${(err as Error).message}`)
})
}
if (tenant.ocisSpaceId) {
await this.ocis.deleteSpace(tenant.ocisSpaceId).catch((err) => {
this.logger.error(`Failed to delete OCIS space: ${(err as Error).message}`)
})
}
}
}