47eb9502f8
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.
122 lines
4.8 KiB
TypeScript
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}`)
|
|
})
|
|
}
|
|
}
|
|
}
|