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.
This commit is contained in:
Ronni Baslund
2026-06-01 21:19:42 +02:00
parent 2a43a7bbf3
commit 47eb9502f8
40 changed files with 3235 additions and 554 deletions
+2
View File
@@ -4,6 +4,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from './audit/audit.module.js'
import { AuthModule } from './auth/auth.module.js'
import { BillingModule } from './billing/billing.module.js'
import { DomainsModule } from './domains/domains.module.js'
import { FlagsModule } from './flags/flags.module.js'
import { HealthModule } from './health/health.module.js'
import { IngestModule } from './ingest/ingest.module.js'
@@ -25,6 +26,7 @@ import { UsersModule } from './users/users.module.js'
AuditModule,
HealthModule,
TenantsModule,
DomainsModule,
PartnersModule,
UsersModule,
MeModule,
@@ -0,0 +1,131 @@
import { Injectable, Logger } from '@nestjs/common'
import { Resolver } from 'node:dns/promises'
import type { DomainRecord, RecordStatus } from '../schemas/domain.schema.js'
export interface CheckResult {
observed?: string
status: RecordStatus
}
// Verifies a domain's DNS records against what Stalwart expects. Uses a
// dedicated resolver pointed at public DNS (Cloudflare / Google) rather than the
// container's system resolver, so results reflect real-world propagation and
// aren't skewed by Docker's internal resolver cache. The instance is private —
// we never touch the global `dns.setServers`, which would affect the rest of
// the app (Authentik/OCIS/Stripe fetches).
//
// Tone semantics mirror the customer-admin DNS_FIX copy on the Domains page:
// ok — record present and correct
// warn — present but weak/secondary (e.g. SPF ~all, DMARC p=none)
// bad — missing or wrong
@Injectable()
export class DnsVerifierService {
private readonly logger = new Logger(DnsVerifierService.name)
private readonly resolver: Resolver
constructor() {
this.resolver = new Resolver({ timeout: 5000, tries: 2 })
this.resolver.setServers(['1.1.1.1', '8.8.8.8'])
}
// Dispatch a single expected record to the right check. The DomainsService
// calls this for every record on a (re)check and writes back observed+status.
async check(record: DomainRecord, domain: string): Promise<CheckResult> {
switch (record.kind) {
case 'ownership':
return this.checkOwnership(record.fqdn, record.expected)
case 'mx':
return this.checkMx(domain, record.expected)
case 'spf':
return this.checkSpf(domain, record.expected)
case 'dkim':
return this.checkDkim(record.fqdn, record.expected)
case 'dmarc':
return this.checkDmarc(domain)
default:
return { status: 'pending' }
}
}
// Resolve TXT, joining each record's character-strings into one value.
private async txt(fqdn: string): Promise<string[]> {
try {
const recs = await this.resolver.resolveTxt(fqdn)
return recs.map((chunks) => chunks.join(''))
} catch {
// NXDOMAIN / NODATA / SERVFAIL → treat as "no record".
return []
}
}
// Ownership: the `_dezky-verify.<domain>` TXT must contain our token verbatim.
private async checkOwnership(fqdn: string, token: string): Promise<CheckResult> {
const records = await this.txt(fqdn)
const hit = records.find((v) => v.includes(token))
return hit ? { observed: hit, status: 'ok' } : { status: 'bad' }
}
// MX: at least one exchange must point at the expected host. A record that
// exists but doesn't match is a secondary/foreign MX → warn (allowed for
// failover, but the customer should know).
private async checkMx(domain: string, expectedHost: string): Promise<CheckResult> {
let mx: { exchange: string; priority: number }[]
try {
mx = await this.resolver.resolveMx(domain)
} catch {
return { status: 'bad' }
}
if (!mx.length) return { status: 'bad' }
const norm = (h: string) => h.replace(/\.$/, '').toLowerCase()
const want = norm(expectedHost)
const observed = mx
.sort((a, b) => a.priority - b.priority)
.map((r) => `${r.priority} ${norm(r.exchange)}`)
.join(', ')
const match = mx.some((r) => norm(r.exchange) === want)
return { observed, status: match ? 'ok' : 'warn' }
}
// SPF: find the v=spf1 record at the apex. Correct = authorises our sender
// (the expected mechanism — `mx` or an `include:` — is present) AND hardfails
// with `-all`. A softfail (`~all`/`?all`) or a missing mechanism is warn;
// no SPF record at all is bad.
private async checkSpf(domain: string, expected: string): Promise<CheckResult> {
const records = await this.txt(domain)
const spf = records.find((v) => /^v=spf1\b/i.test(v.trim()))
if (!spf) return { status: 'bad' }
const mechanism = expected.match(/include:\S+/i)?.[0] ?? 'mx'
const hasMechanism = new RegExp(`(^|\\s)${escapeRegex(mechanism)}(\\s|$)`, 'i').test(spf)
const hardFail = /[\s]-all\b/.test(spf) || /(^|\s)-all$/.test(spf.trim())
if (hasMechanism && hardFail) return { observed: spf, status: 'ok' }
return { observed: spf, status: 'warn' }
}
// DKIM: the selector TXT must carry the same public key Stalwart generated.
// We compare the `p=` value. Present-but-different = warn; absent = bad.
private async checkDkim(fqdn: string, expected: string): Promise<CheckResult> {
const records = await this.txt(fqdn)
const dkim = records.find((v) => /(^|;)\s*v=DKIM1/i.test(v) || /k=(rsa|ed25519)/i.test(v))
if (!dkim) return { status: 'bad' }
const expectedKey = expected.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
const observedKey = dkim.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
if (expectedKey && observedKey && expectedKey === observedKey) {
return { observed: dkim, status: 'ok' }
}
return { observed: dkim, status: 'warn' }
}
// DMARC: a `_dmarc` TXT must exist. p=none is monitor-only (warn); any
// stronger policy (quarantine/reject) is ok; no record is bad.
private async checkDmarc(domain: string): Promise<CheckResult> {
const records = await this.txt(`_dmarc.${domain}`)
const dmarc = records.find((v) => /^v=DMARC1\b/i.test(v.trim()))
if (!dmarc) return { status: 'bad' }
const policy = dmarc.match(/(^|;)\s*p=(\w+)/i)?.[2]?.toLowerCase()
return { observed: dmarc, status: policy === 'none' ? 'warn' : 'ok' }
}
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { AddDomainDto } from './dto/add-domain.dto.js'
import { SetDmarcPolicyDto } from './dto/set-dmarc-policy.dto.js'
import { DomainsService, type TenantRef } from './domains.service.js'
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
}
// Customer-admin domain management, mounted under the tenant. Same membership
// gate as the other tenant-scoped portal resources (GET :slug/users etc.): any
// member of the tenant — or a platform admin — may manage its domains.
@Controller('tenants/:slug/domains')
@UseGuards(JwtAuthGuard)
export class DomainsController {
constructor(
private readonly domains: DomainsService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
// Resolve the tenant and assert the caller belongs to it (or is a platform
// admin). Returns the lightweight ref the service works with.
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<TenantRef> {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return { _id: tenant._id, slug: tenant.slug }
}
@Get()
async list(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenant = await this.gate(slug, jwt)
return this.domains.list(tenant)
}
@Post()
async add(
@Param('slug') slug: string,
@Body() dto: AddDomainDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
return this.domains.add(tenant, dto.domain, auditActor(user, req))
}
@Get(':domain')
async getOne(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenant = await this.gate(slug, jwt)
return this.domains.getOne(tenant, domain)
}
@Post(':domain/recheck')
async recheck(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenant = await this.gate(slug, jwt)
return this.domains.recheck(tenant, domain)
}
@Patch(':domain/dmarc')
async setDmarc(
@Param('slug') slug: string,
@Param('domain') domain: string,
@Body() dto: SetDmarcPolicyDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
return this.domains.setDmarcPolicy(tenant, domain, dto.dmarcPolicy, auditActor(user, req))
}
@Delete(':domain')
@HttpCode(204)
async remove(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
await this.domains.remove(tenant, domain, auditActor(user, req))
}
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { DnsVerifierService } from './dns-verifier.service.js'
import { DomainsController } from './domains.controller.js'
import { DomainsService } from './domains.service.js'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Domain.name, schema: DomainSchema },
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
TenantsModule, // TenantsService — resolve tenant by slug for the membership gate
],
controllers: [DomainsController],
providers: [DomainsService, DnsVerifierService],
exports: [DomainsService],
})
export class DomainsModule {}
@@ -0,0 +1,463 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes } from 'node:crypto'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import {
DomainInUseError,
StalwartClient,
type StalwartLinkedObject,
type StalwartZoneRecord,
} from '../integrations/stalwart.client.js'
import {
Domain,
DomainDocument,
DomainRecord,
DmarcPolicy,
DomainStatus,
RecordKind,
RecordStatus,
} from '../schemas/domain.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import { DnsVerifierService } from './dns-verifier.service.js'
// The four status slots the customer-admin Domains page renders, plus ownership.
const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc']
// Minimal tenant identity the service needs — the controller resolves the full
// doc for its membership gate and hands us this.
export interface TenantRef {
_id: Types.ObjectId
slug: string
}
@Injectable()
export class DomainsService {
private readonly logger = new Logger(DomainsService.name)
constructor(
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly stalwart: StalwartClient,
private readonly dns: DnsVerifierService,
private readonly audit: AuditService,
) {}
async list(tenant: TenantRef): Promise<DomainView[]> {
const docs = await this.domainModel.find({ tenantId: tenant._id }).sort({ createdAt: 1 }).exec()
return Promise.all(docs.map((d) => this.toView(d, tenant)))
}
async getOne(tenant: TenantRef, domain: string): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
return this.toView(doc, tenant)
}
// Add a domain: provision it in Stalwart (which auto-generates DKIM), seed the
// expected records from Stalwart's zone file + our ownership token, then run an
// immediate DNS check so the page shows live status right away.
async add(tenant: TenantRef, rawDomain: string, actor: AuditActor): Promise<DomainView> {
const domain = rawDomain.trim().toLowerCase()
const existing = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
if (existing) throw new ConflictException(`Domain "${domain}" is already added`)
const isPrimary = (await this.domainModel.countDocuments({ tenantId: tenant._id })) === 0
const verificationToken = `dezky-verify=${randomBytes(16).toString('hex')}`
let stalwartId: string | undefined
let provisioned = false
let records: DomainRecord[] = [ownershipRecord(domain, verificationToken)]
// Provision in Stalwart BEFORE persisting. A rejected domain (bad name,
// Stalwart outage) then fails the add cleanly with the real reason, rather
// than leaving an orphaned error-domain on the customer's list. On a partial
// failure (created but couldn't read the zone) we roll back the Stalwart side.
if (this.stalwart.configured) {
try {
const created = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
stalwartId = created.id
// Stalwart generates the DKIM keypair asynchronously after the domain is
// created, so the zone file omits the DKIM records for a moment. Poll
// briefly so the very first add response already carries them.
const zone = await this.zoneWithDkim(domain)
records = this.buildRecords(domain, zone, verificationToken, 'quarantine')
provisioned = true
} catch (err) {
const msg = (err as Error).message
this.logger.error(`Stalwart provisioning failed for "${domain}": ${msg}`)
await this.stalwart.deleteDomain(domain).catch(() => {})
throw new BadRequestException(
`Could not provision "${domain}" in the mail server: ${cleanStalwartError(msg)}`,
)
}
}
const doc = await this.domainModel.create({
tenantId: tenant._id,
domain,
isPrimary,
verificationToken,
dmarcPolicy: 'quarantine',
status: 'pending',
stalwartId,
stalwartProvisioned: provisioned,
records,
})
await this.tenantModel.updateOne({ _id: tenant._id }, { $addToSet: { domains: domain } }).exec()
await this.audit.record(
{
action: 'domain.added',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
metadata: { stalwartProvisioned: doc.stalwartProvisioned, isPrimary },
},
actor,
)
// Immediate check so the customer sees real status without a manual refresh.
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Re-run the DNS checks for a domain on demand (the "Re-check" buttons).
async recheck(tenant: TenantRef, domain: string): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
// (Re)seed expected records if they're incomplete — Stalwart was unreachable
// at add time, or the async DKIM keys hadn't landed yet (no dkim record).
const hasMail = doc.records.some((r) => r.kind === 'mx')
const hasDkim = doc.records.some((r) => r.kind === 'dkim')
if (this.stalwart.configured && (!hasMail || !hasDkim)) {
try {
const { id } = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
const zone = await this.zoneWithDkim(domain)
doc.stalwartId = id
doc.stalwartProvisioned = true
doc.stalwartError = undefined
doc.records = this.buildRecords(domain, zone, doc.verificationToken, doc.dmarcPolicy)
} catch (err) {
doc.stalwartError = (err as Error).message
}
}
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Read the domain's zone records, polling briefly until the asynchronously
// generated DKIM records have all appeared (Stalwart creates the keypair just
// after the domain — and the ed25519 + RSA selectors land a beat apart). We
// wait until the DKIM count is non-zero AND stable across two reads, so we
// don't capture just the first selector. Budget-capped (~5s) either way.
private async zoneWithDkim(domain: string): Promise<StalwartZoneRecord[]> {
const dkimCount = (z: StalwartZoneRecord[]) =>
z.filter((r) => r.fqdn.includes('._domainkey.')).length
let zone = await this.stalwart.getZoneRecords(domain)
let prev = -1
for (let i = 0; i < 8; i++) {
const count = dkimCount(zone)
if (count > 0 && count === prev) break // stable — all selectors present
prev = count
await new Promise((resolve) => setTimeout(resolve, 600))
zone = await this.stalwart.getZoneRecords(domain)
}
return zone
}
// Set the DMARC enforcement level (wizard step 5) and re-verify.
async setDmarcPolicy(
tenant: TenantRef,
domain: string,
policy: DmarcPolicy,
actor: AuditActor,
): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
doc.dmarcPolicy = policy
const dmarc = doc.records.find((r) => r.kind === 'dmarc')
if (dmarc) dmarc.expected = dmarcExpected(domain, policy)
await this.audit.record(
{
action: 'domain.dmarc_policy_set',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
metadata: { dmarcPolicy: policy },
},
actor,
)
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Remove a domain: delete it from Stalwart (best-effort) and drop our records.
// Guarded — a domain that still has mailboxes can't be removed, or those users
// would lose their email identity. This is enforced here (not just in the UI)
// so the rule holds even for direct API callers.
async remove(tenant: TenantRef, domain: string, actor: AuditActor): Promise<void> {
const doc = await this.findOrThrow(tenant, domain)
const mailboxes = await this.mailboxCount(tenant, doc.domain)
if (mailboxes > 0) {
throw new ConflictException(
`Cannot remove "${doc.domain}" — ${mailboxes} mailbox${mailboxes === 1 ? '' : 'es'} still use${
mailboxes === 1 ? 's' : ''
} it. Remove or reassign those users first.`,
)
}
if (this.stalwart.configured) {
try {
await this.stalwart.deleteDomain(doc.domain)
} catch (err) {
// Accounts / aliases / mailing lists still on the domain — block the
// removal with a clear, actionable message rather than orphaning them.
if (err instanceof DomainInUseError) {
throw new ConflictException(
`Cannot remove "${doc.domain}" — it still has ${summarizeLinks(err.linkedObjects)} in the mail server. Remove ${err.linkedObjects.length === 1 ? 'it' : 'them'} first.`,
)
}
// Any other Stalwart failure: don't drop our record, so the two sides
// stay consistent and the customer can retry.
this.logger.error(`Stalwart delete failed for "${doc.domain}": ${(err as Error).message}`)
throw err
}
}
await this.domainModel.deleteOne({ _id: doc._id }).exec()
await this.tenantModel.updateOne({ _id: tenant._id }, { $pull: { domains: domain } }).exec()
await this.audit.record(
{
action: 'domain.removed',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
},
actor,
)
}
// ── internals ──────────────────────────────────────────────────────────────
private async findOrThrow(tenant: TenantRef, domain: string): Promise<DomainDocument> {
const doc = await this.domainModel
.findOne({ tenantId: tenant._id, domain: domain.trim().toLowerCase() })
.exec()
if (!doc) throw new NotFoundException(`Domain "${domain}" not found`)
return doc
}
// Check every record against DNS, write back observed+status, recompute the
// domain's overall status, and persist. Records out to public DNS in parallel.
private async runChecks(doc: DomainDocument): Promise<void> {
const wasVerified = doc.ownershipVerified
await Promise.all(
doc.records.map(async (rec) => {
const { observed, status } = await this.dns.check(rec, doc.domain)
rec.observed = observed
rec.status = status
rec.checkedAt = new Date()
}),
)
const tone = (kind: RecordKind): RecordStatus => aggregateTone(doc.records, kind)
doc.ownershipVerified = tone('ownership') === 'ok'
if (doc.ownershipVerified && !wasVerified) doc.verifiedAt = new Date()
if (doc.stalwartError) {
doc.status = 'error'
} else if (!doc.ownershipVerified) {
doc.status = 'pending'
} else {
const mail: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc']
doc.status = mail.every((k) => tone(k) === 'ok') ? 'active' : 'verifying'
}
doc.lastCheckedAt = new Date()
doc.markModified('records')
await doc.save()
}
// Map Stalwart's parsed zone records → our verifiable record set. Always
// includes the ownership TXT (which Stalwart doesn't know about). DMARC is
// overridden to reflect the customer's chosen policy.
private buildRecords(
domain: string,
zone: StalwartZoneRecord[],
token: string,
policy: DmarcPolicy,
): DomainRecord[] {
const records: DomainRecord[] = [ownershipRecord(domain, token)]
for (const z of zone) {
const kind = classify(z, domain)
if (!kind) continue
records.push({
kind,
type: z.type,
host: relativeHost(z.fqdn, domain),
fqdn: z.fqdn,
expected: kind === 'dmarc' ? dmarcExpected(domain, policy) : z.value,
priority: z.priority,
status: 'pending',
})
}
return records
}
// Count mailboxes on this domain — tenant users whose email is @domain.
private async mailboxCount(tenant: TenantRef, domain: string): Promise<number> {
return this.userModel
.countDocuments({
tenantIds: tenant._id,
email: { $regex: `@${escapeRegex(domain)}$`, $options: 'i' },
})
.exec()
}
private async toView(doc: DomainDocument, tenant: TenantRef): Promise<DomainView> {
const mailboxes = await this.mailboxCount(tenant, doc.domain)
return {
id: String(doc._id),
domain: doc.domain,
isPrimary: doc.isPrimary,
status: doc.status,
ownershipVerified: doc.ownershipVerified,
verificationToken: doc.verificationToken,
dmarcPolicy: doc.dmarcPolicy,
stalwartProvisioned: doc.stalwartProvisioned,
stalwartError: doc.stalwartError,
mailboxes,
checks: {
ownership: aggregateTone(doc.records, 'ownership'),
mx: aggregateTone(doc.records, 'mx'),
spf: aggregateTone(doc.records, 'spf'),
dkim: aggregateTone(doc.records, 'dkim'),
dmarc: aggregateTone(doc.records, 'dmarc'),
},
records: doc.records.map((r) => ({
kind: r.kind,
type: r.type,
host: r.host,
fqdn: r.fqdn,
expected: r.expected,
priority: r.priority,
observed: r.observed,
status: r.status,
})),
lastCheckedAt: doc.lastCheckedAt?.toISOString(),
}
}
}
// ── view types ─────────────────────────────────────────────────────────────
export interface DomainRecordView {
kind: RecordKind
type: string
host: string
fqdn: string
expected: string
priority?: number
observed?: string
status: RecordStatus
}
export interface DomainView {
id: string
domain: string
isPrimary: boolean
status: DomainStatus
ownershipVerified: boolean
verificationToken: string
dmarcPolicy: DmarcPolicy
stalwartProvisioned: boolean
stalwartError?: string
mailboxes: number
checks: Record<'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc', RecordStatus>
records: DomainRecordView[]
lastCheckedAt?: string
}
// ── pure helpers ─────────────────────────────────────────────────────────────
function ownershipRecord(domain: string, token: string): DomainRecord {
return {
kind: 'ownership',
type: 'TXT',
host: '_dezky-verify',
fqdn: `_dezky-verify.${domain}`,
expected: token,
status: 'pending',
}
}
// Classify a Stalwart zone record into one of our verifiable kinds, or null for
// the records we don't surface as status slots (SRV/MTA-STS/autoconfig/…).
function classify(z: StalwartZoneRecord, domain: string): RecordKind | null {
if (z.type === 'MX' && z.fqdn === domain) return 'mx'
if (z.type === 'TXT' && z.fqdn === domain && /^v=spf1\b/i.test(z.value)) return 'spf'
if (z.type === 'TXT' && z.fqdn.endsWith(`._domainkey.${domain}`)) return 'dkim'
if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc'
return null
}
// Relative host for display ('@' at apex, otherwise the label prefix).
function relativeHost(fqdn: string, domain: string): string {
if (fqdn === domain) return '@'
return fqdn.endsWith(`.${domain}`) ? fqdn.slice(0, -(domain.length + 1)) : fqdn
}
function dmarcExpected(domain: string, policy: DmarcPolicy): string {
return `v=DMARC1; p=${policy}; rua=mailto:postmaster@${domain}`
}
// Aggregate per-kind tone: a kind is only 'ok' if ALL its records are ok (DKIM
// has two — ed25519 + RSA — both must be published). Worst tone wins, with a
// kind that has no records yet reading as 'pending'.
function aggregateTone(records: DomainRecord[], kind: RecordKind): RecordStatus {
const mine = records.filter((r) => r.kind === kind)
if (!mine.length) return 'pending'
const order: RecordStatus[] = ['bad', 'warn', 'pending', 'ok']
return mine
.map((r) => r.status)
.sort((a, b) => order.indexOf(a) - order.indexOf(b))[0]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Human summary of what still links to a domain, e.g. "2 mailboxes and 1
// mailing list". Groups Stalwart's linked-object types and pluralises.
function summarizeLinks(links: StalwartLinkedObject[]): string {
const label: Record<string, string> = {
MailingList: 'mailing list',
Account: 'mailbox',
Principal: 'mailbox',
Individual: 'mailbox',
Group: 'group',
List: 'list',
}
const counts = new Map<string, number>()
for (const l of links) {
const name = label[l.object] ?? l.object.toLowerCase()
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts.entries()]
.map(([name, n]) => `${n} ${name}${n === 1 ? '' : 's'}`)
.join(' and ')
}
// Stalwart JMAP errors arrive as a JSON blob embedded in the message, e.g.
// `…: {"type":"invalidPatch","description":"Invalid domain name",…}`. Surface
// just the human description when present, otherwise the raw message.
function cleanStalwartError(msg: string): string {
return msg.match(/"description":"([^"]+)"/)?.[1] ?? msg
}
@@ -0,0 +1,13 @@
import { IsString, Matches, MaxLength } from 'class-validator'
// Add a custom email domain to a tenant. The domain is lowercased + trimmed by
// the schema; we validate it's a plausible hostname here (no protocol, no path,
// at least one dot). Punycode/IDN is accepted as already-encoded ascii.
export class AddDomainDto {
@IsString()
@MaxLength(253)
@Matches(/^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/i, {
message: 'domain must be a valid hostname like acme.dk',
})
domain!: string
}
@@ -0,0 +1,9 @@
import { IsIn } from 'class-validator'
import type { DmarcPolicy } from '../../schemas/domain.schema.js'
// Set the DMARC enforcement level chosen in the add-domain wizard (step 5).
// Drives the expected `_dmarc` TXT value we verify against.
export class SetDmarcPolicyDto {
@IsIn(['none', 'quarantine', 'reject'])
dmarcPolicy!: DmarcPolicy
}
@@ -70,6 +70,52 @@ export class AuthentikClient {
return created
}
// Fully delete a user from Authentik (used when a member is removed from their
// last tenant). 404 is tolerated so a re-run after a partial removal is safe.
async deleteUser(userPk: number): Promise<void> {
const res = await fetch(`${this.base}/core/users/${userPk}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE user ${userPk}${res.status}: ${body.slice(0, 200)}`)
}
this.logger.log(`Deleted Authentik user ${userPk}`)
}
// Enable / disable a user. is_active=false blocks all sign-in (portal, SSO,
// and OCIS-via-SSO) without deleting anything — the basis of suspend/resume.
async setUserActive(userPk: number, active: boolean): Promise<void> {
await this.request(`/core/users/${userPk}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: active }),
})
this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
}
// Force-logout: terminate the user's active sessions so they must sign in
// again. Returns how many were terminated. We pass the `?user=` filter AND
// re-filter client-side on the session's `user` pk — Authentik's endpoint
// silently ignores an unknown query filter, which would otherwise return (and
// delete) EVERY user's session. The client-side filter makes that impossible.
async terminateSessions(userPk: number): Promise<number> {
const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>(
`/core/authenticated_sessions/?user=${userPk}`,
)
const sessions = (res.results ?? []).filter((s) => s.user === userPk)
await Promise.all(
sessions.map((s) =>
fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
}).catch(() => {}),
),
)
this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`)
return sessions.length
}
async deleteGroup(groupId: string): Promise<void> {
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
method: 'DELETE',
@@ -165,6 +165,34 @@ export class OcisClient {
return (await res.json()) as T
}
// Write-capable variant of request() for POST/DELETE libregraph calls.
private async mutate<T>(
method: 'POST' | 'DELETE',
path: string,
body?: unknown,
): Promise<T | undefined> {
const token = await this.getToken()
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
if (res.status === 401) {
this.accessToken = undefined
this.accessExpiresAt = 0
}
const text = await res.text().catch(() => '')
throw new Error(`OCIS ${method} ${path}${res.status}: ${text.slice(0, 200)}`)
}
if (res.status === 204 || res.headers.get('content-length') === '0') return undefined
return (await res.json()) as T
}
// List all drives, optionally filtered with an OData $filter expression
// (e.g. `driveType eq 'personal'`). Requires the OCIS admin role. libregraph
// caps the page at 100 items; a tenant's personal drives stay well under that.
@@ -183,6 +211,43 @@ export class OcisClient {
return body.value ?? []
}
// Proactively create the OCIS account so the user shows up immediately. OCIS
// runs with PROXY_AUTOPROVISION_ACCOUNTS, so it ALSO creates the account (and
// personal drive) on the user's first SSO login — this just does it up front.
// `username` must match the OIDC claim OCIS keys on (preferred_username, which
// is the Authentik username = the user's email here). Returns the libregraph
// user id, or { deferred: true } if creation isn't available (external-IdP
// setups can reject graph user-create) so the caller falls back to autoprovision.
async ensureUser(input: {
username: string
displayName: string
mail: string
}): Promise<{ id?: string; deferred: boolean }> {
if (!this.configured) return { deferred: true }
try {
const user = await this.mutate<{ id?: string }>('POST', '/graph/v1.0/users', {
onPremisesSamAccountName: input.username,
displayName: input.displayName,
mail: input.mail,
accountEnabled: true,
})
this.logger.log(`Created OCIS user ${input.mail} (id=${user?.id})`)
return { id: user?.id, deferred: false }
} catch (err) {
this.logger.warn(
`OCIS user pre-create unavailable for ${input.mail} (${(err as Error).message.slice(0, 120)}) — will auto-provision on first sign-in`,
)
return { deferred: true }
}
}
async deleteUser(id: string): Promise<void> {
if (!this.configured) return
await this.mutate('DELETE', `/graph/v1.0/users/${id}`).catch((err) => {
this.logger.error(`OCIS user delete failed (id=${id}): ${(err as Error).message}`)
})
}
// ── Provisioning (stubbed) ────────────────────────────────────────────────
// Real implementation needs POST /graph/v1.0/drives { name, driveType:
// 'project' } to create a space and assign it to the tenant's group / users.
@@ -1,30 +1,351 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
// Stalwart v0.16 removed the REST management API — all admin operations now go
// through the JMAP /jmap endpoint with Principal/set, Domain/set, etc. method
// calls. Implementing a JMAP client is meaningful work and out of scope for
// Phase 4. Stubbed for now; the orchestration code records this as 'skipped'.
// Stalwart v0.16 removed the REST management API (`/api/principal`, `/api/dkim`,
// `/api/dns/records` all 404). All admin operations now go through the JMAP
// endpoint at `${base}/jmap` using the Stalwart-specific `urn:stalwart:jmap`
// capability. Domains are `x:Domain` objects (NOT JMAP principals — `type:
// "domain"` is rejected); DKIM signatures are `x:DkimSignature` objects.
//
// TODO (follow-up): Build a minimal JMAP client that wraps Principal/set + the
// DKIM key generation method. See https://stalw.art/docs/api/management/overview
// Auth is HTTP Basic with the fallback admin from config.toml
// (admin / STALWART_ADMIN_PASSWORD). Calls go to the internal docker hostname
// `http://stalwart:8080` — NOT the public `https://mail.dezky.local`, which is
// Traefik + a mkcert cert that Node's fetch rejects.
//
// Creating a domain auto-generates its DKIM keys (dkimManagement defaults to
// "Automatic") and leaves DNS as "Manual" (the customer publishes the records).
// The full set of records to publish comes back on the domain's server-set
// `dnsZoneFile` field as BIND zone text — we parse it rather than computing the
// records ourselves, so the DKIM public keys etc. are always authoritative.
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']
// A single DNS record extracted from a domain's `dnsZoneFile`. `fqdn` carries
// the trailing-dot-stripped name (e.g. `_dmarc.acme.dk`); `value` is the
// unquoted record data (TXT strings joined). `priority` is set for MX only.
export interface StalwartZoneRecord {
fqdn: string
type: string // 'MX' | 'TXT' | 'CNAME' | 'SRV' | …
value: string
priority?: number
}
// What the WebAdmin's x:Domain/get returns (only the fields we read).
interface StalwartDomain {
id: string
name: string
dnsZoneFile?: string
}
type JmapMethodCall = [string, Record<string, unknown>, string]
type JmapMethodResponse = [string, Record<string, any>, string]
@Injectable()
export class StalwartClient {
private readonly logger = new Logger(StalwartClient.name)
private readonly base: string
private readonly authHeader: string
// Live provisioning is gated like billing's `stripeLive`: off by default so
// dev without a reachable Stalwart (or without the flag) records 'skipped'
// instead of erroring. Requires the flag AND an admin password.
readonly enabled: boolean
constructor(config: ConfigService) {
this.base = config.getOrThrow<string>('STALWART_API_URL')
const user = config.get<string>('STALWART_ADMIN_USER') || 'admin'
const password = config.get<string>('STALWART_ADMIN_PASSWORD') || ''
this.authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`
this.enabled =
config.get<string>('STALWART_PROVISIONING_ENABLED') === 'true' && !!password
if (!this.enabled) {
this.logger.warn(
'Stalwart provisioning disabled (STALWART_PROVISIONING_ENABLED != true or no admin password) — domain steps record as skipped.',
)
}
}
async ensureDomain(domain: string, _description?: string): Promise<{ name: string }> {
this.logger.warn(
`Stalwart domain provisioning is stubbed — would create "${domain}" via JMAP at ${this.base}/jmap`,
// Static config present and provisioning turned on. DomainsService checks this
// to decide between a real call and the honest "skipped" state.
get configured(): boolean {
return this.enabled
}
// Run one or more JMAP method calls. Returns the methodResponses array. A
// request-level error (e.g. malformed envelope) comes back as a flat
// {type,status,detail} object with no methodResponses — surfaced as a throw.
private async jmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
const res = await fetch(`${this.base}/jmap`, {
method: 'POST',
headers: {
Authorization: this.authHeader,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ using: JMAP_USING, methodCalls }),
})
const text = await res.text()
if (!res.ok) {
throw new Error(`Stalwart JMAP → ${res.status}: ${text.slice(0, 300)}`)
}
let json: { methodResponses?: JmapMethodResponse[] }
try {
json = JSON.parse(text)
} catch {
throw new Error(`Stalwart JMAP returned non-JSON: ${text.slice(0, 200)}`)
}
if (!json.methodResponses) {
throw new Error(`Stalwart JMAP error: ${text.slice(0, 300)}`)
}
return json.methodResponses
}
// The Stalwart domain id for a name, or undefined if it doesn't exist.
private async findDomainId(name: string): Promise<string | undefined> {
const resp = await this.jmap([['x:Domain/query', { filter: { name } }, '0']])
const ids = resp[0]?.[1]?.ids as string[] | undefined
return ids?.[0]
}
// Idempotent: returns the existing domain id if present, otherwise creates the
// domain (which auto-generates its DKIM keys) and returns the new id.
async ensureDomain(name: string, _description?: string): Promise<{ id: string }> {
const existing = await this.findDomainId(name)
if (existing) {
this.logger.log(`Stalwart domain "${name}" already exists (id=${existing})`)
return { id: existing }
}
const resp = await this.jmap([['x:Domain/set', { create: { d1: { name } } }, '0']])
const result = resp[0][1]
const created = result.created?.d1
if (!created?.id) {
const err = result.notCreated?.d1
throw new Error(`Stalwart domain create failed for "${name}": ${JSON.stringify(err)}`)
}
this.logger.log(`Created Stalwart domain "${name}" (id=${created.id})`)
return { id: created.id }
}
// Fetch the domain's authoritative DNS records (parsed from its dnsZoneFile).
// Returns [] if the domain isn't found. Used to populate the expected records
// the customer must publish, including the live DKIM public keys.
async getZoneRecords(name: string): Promise<StalwartZoneRecord[]> {
const resp = await this.jmap([
['x:Domain/query', { filter: { name } }, '0'],
[
'x:Domain/get',
{ '#ids': { resultOf: '0', name: 'x:Domain/query', path: '/ids' } },
'1',
],
])
const domain = (resp[1]?.[1]?.list as StalwartDomain[] | undefined)?.[0]
if (!domain?.dnsZoneFile) return []
return parseZoneFile(domain.dnsZoneFile)
}
// Delete a domain. Stalwart enforces referential integrity: a domain can't be
// destroyed while anything links to it, reported as notDestroyed/objectIsLinked
// with the linked object ids. The auto-generated DKIM signatures always link,
// so we remove those and retry — but any OTHER link (accounts, aliases, mailing
// lists) is real user data, so we refuse with a DomainInUseError and let the
// caller surface it. 404-equivalent (no such domain) is a silent no-op.
async deleteDomain(name: string): Promise<void> {
const id = await this.findDomainId(name)
if (!id) return
const resp = await this.jmap([['x:Domain/set', { destroy: [id] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(id)) {
this.logger.log(`Deleted Stalwart domain "${name}" (id=${id})`)
return
}
const notDestroyed = result.notDestroyed?.[id]
if (notDestroyed?.type === 'objectIsLinked') {
const links: StalwartLinkedObject[] = notDestroyed.linkedObjects ?? []
const blockers = links.filter((o) => o.object !== 'DkimSignature')
if (blockers.length) {
// The (failed) destroy above mutated nothing, so the domain is untouched.
throw new DomainInUseError(name, blockers)
}
const dkimIds = links.filter((o) => o.object === 'DkimSignature').map((o) => o.id)
await this.jmap([
['x:DkimSignature/set', { destroy: dkimIds }, '0'],
['x:Domain/set', { destroy: [id] }, '1'],
])
this.logger.log(
`Deleted Stalwart domain "${name}" (id=${id}) after removing ${dkimIds.length} DKIM signature(s)`,
)
return
}
throw new Error(
`Stalwart domain delete failed for "${name}": ${JSON.stringify(notDestroyed)}`,
)
return { name: domain }
}
async deleteDomain(domain: string): Promise<void> {
this.logger.warn(`Stalwart domain delete is stubbed — would delete "${domain}"`)
// Create a user mailbox on a domain. The account's address is name@domain
// (Stalwart forms it from the domain), and the password lets the user sign in
// to webmail / IMAP / SMTP. `credentials` is an index-keyed MAP (not an array)
// — a quirk of Stalwart's patch format. Returns the new account id.
async createMailbox(input: {
domainId: string
localPart: string
fullName: string
password: string
}): Promise<{ id: string }> {
const resp = await this.jmap([
[
'x:Account/set',
{
create: {
u1: {
'@type': 'User',
name: input.localPart,
domainId: input.domainId,
description: input.fullName,
credentials: { '0': { '@type': 'Password', secret: input.password } },
},
},
},
'0',
],
])
const result = resp[0][1]
const created = result.created?.u1
if (!created?.id) {
const err = result.notCreated?.u1
throw new Error(`Stalwart mailbox create failed for "${input.localPart}": ${JSON.stringify(err)}`)
}
this.logger.log(`Created Stalwart mailbox "${input.localPart}" (id=${created.id})`)
return { id: created.id }
}
// Freeze / unfreeze a mailbox. Suspending disables the authenticate / send /
// receive permissions (so they can't sign in, send, or receive), while keeping
// the account + password intact — resuming restores the inherited defaults, so
// the user's original credential works again.
async setMailboxSuspended(accountId: string, suspended: boolean): Promise<void> {
const permissions = suspended
? {
'@type': 'Merge',
disabledPermissions: { authenticate: true, emailReceive: true, emailSend: true },
}
: { '@type': 'Inherit' }
const resp = await this.jmap([
['x:Account/set', { update: { [accountId]: { permissions } } }, '0'],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(
`Stalwart mailbox ${suspended ? 'suspend' : 'resume'} failed (id=${accountId}): ${JSON.stringify(notUpdated)}`,
)
}
}
// Set a new mailbox password (replaces the primary credential).
async setMailboxPassword(accountId: string, password: string): Promise<void> {
const resp = await this.jmap([
[
'x:Account/set',
{ update: { [accountId]: { credentials: { '0': { '@type': 'Password', secret: password } } } } },
'0',
],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(`Stalwart mailbox password update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
}
}
// Delete a mailbox by account id. Missing id is a silent no-op.
async deleteMailbox(accountId: string): Promise<void> {
const resp = await this.jmap([['x:Account/set', { destroy: [accountId] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(accountId)) {
this.logger.log(`Deleted Stalwart mailbox (id=${accountId})`)
return
}
const notDestroyed = result.notDestroyed?.[accountId]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
}
}
}
export interface StalwartLinkedObject {
object: string // 'DkimSignature' | 'MailingList' | 'Account' | …
id: string
}
// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
// DKIM signatures (which we remove automatically).
export class DomainInUseError extends Error {
constructor(
public readonly domain: string,
public readonly linkedObjects: StalwartLinkedObject[],
) {
super(`Domain "${domain}" is still in use by ${linkedObjects.length} mail object(s)`)
this.name = 'DomainInUseError'
}
}
// ── BIND zone-file parsing ───────────────────────────────────────────────────
// Stalwart's dnsZoneFile is BIND zone text. RSA DKIM records span multiple lines
// with parenthesised, concatenated quoted strings, e.g.:
// sel._domainkey.acme.dk. IN TXT (
// "v=DKIM1; k=rsa; ... "
// "...more base64..."
// )
// We first fold parenthesised groups onto one logical line, then tokenise each
// line as `<name> IN <type> <rest>`. Exported for unit testing.
export function parseZoneFile(zone: string): StalwartZoneRecord[] {
const records: StalwartZoneRecord[] = []
for (const line of foldZoneLines(zone)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(';')) continue
// <name> IN <TYPE> <rest…>
const m = trimmed.match(/^(\S+)\s+IN\s+(\S+)\s+(.+)$/)
if (!m) continue
const [, rawName, type, rest] = m
const fqdn = rawName.replace(/\.$/, '')
if (type === 'TXT') {
records.push({ fqdn, type, value: unquoteTxt(rest) })
} else if (type === 'MX') {
const mx = rest.match(/^(\d+)\s+(\S+)$/)
if (mx) {
records.push({ fqdn, type, priority: Number(mx[1]), value: mx[2].replace(/\.$/, '') })
}
} else {
records.push({ fqdn, type, value: rest.trim().replace(/\.$/, '') })
}
}
return records
}
// Collapse parenthesised multi-line records into single logical lines.
function foldZoneLines(zone: string): string[] {
const out: string[] = []
let buffer = ''
let depth = 0
for (const raw of zone.split('\n')) {
const line = raw
for (const ch of line) {
if (ch === '(') depth++
else if (ch === ')') depth = Math.max(0, depth - 1)
}
buffer += (buffer ? ' ' : '') + line.replace(/[()]/g, ' ').trim()
if (depth === 0) {
out.push(buffer)
buffer = ''
}
}
if (buffer) out.push(buffer)
return out
}
// Join the quoted character-strings of a TXT record into its logical value.
// `"v=DKIM1; ..." "more"` → `v=DKIM1; ...more`.
function unquoteTxt(rest: string): string {
const parts = rest.match(/"((?:[^"\\]|\\.)*)"/g)
if (!parts) return rest.trim()
return parts.map((p) => p.slice(1, -1)).join('')
}
@@ -11,6 +11,7 @@ export type AuditResourceType =
| 'user'
| 'flag'
| 'subscription'
| 'domain'
| 'system'
export type AuditSource =
| 'platform-api'
@@ -60,7 +61,7 @@ export class AuditEvent {
@Prop({ enum: ['success', 'failure'], default: 'success', index: true })
outcome!: AuditOutcome
@Prop({ enum: ['tenant', 'partner', 'user', 'flag', 'subscription', 'system'] })
@Prop({ enum: ['tenant', 'partner', 'user', 'flag', 'subscription', 'domain', 'system'] })
resourceType?: AuditResourceType
// Free-form (slug, ObjectId-as-string, external id, etc.) since some
@@ -0,0 +1,114 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type DomainDocument = HydratedDocument<Domain>
// Overall lifecycle of a customer email domain:
// pending — created in Stalwart, ownership TXT not yet seen
// verifying — ownership confirmed, mail records still propagating / failing
// active — every required record (MX/SPF/DKIM/DMARC) resolves correctly
// error — Stalwart provisioning failed (see stalwart.error)
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
// Per-record verification tone — mirrors the frontend DNS_FIX semantics exactly
// (ok / warn / bad), plus `pending` for "not checked yet".
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
// Which DNS concern a record belongs to. `ownership` is the one-time TXT proving
// the customer controls the domain; the other four map to the UI's status slots.
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc'
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
// A single expected DNS record + the last observed result. `expected` is the
// authoritative value (from Stalwart's dnsZoneFile, except ownership which we
// mint). `host` is the relative name to paste at a DNS provider ('@' for apex);
// `fqdn` is the full name we actually query.
@Schema({ _id: false })
export class DomainRecord {
@Prop({ required: true, enum: ['ownership', 'mx', 'spf', 'dkim', 'dmarc'] })
kind!: RecordKind
@Prop({ required: true })
type!: string // DNS record type: 'TXT' | 'MX' | 'CNAME'
@Prop({ required: true })
host!: string // relative host, e.g. '@', '_dmarc', 'sel._domainkey'
@Prop({ required: true })
fqdn!: string // full name queried, e.g. '_dmarc.acme.dk'
@Prop({ required: true })
expected!: string
@Prop({ type: Number })
priority?: number // MX only
@Prop()
observed?: string
@Prop({ enum: ['ok', 'warn', 'bad', 'pending'], default: 'pending' })
status!: RecordStatus
@Prop()
checkedAt?: Date
}
export const DomainRecordSchema = SchemaFactory.createForClass(DomainRecord)
// A customer-owned email domain. Distinct from the loose `tenant.domains`
// string[] (kept as a denormalised primary-host convenience) — this collection
// holds the per-domain provisioning + DNS-verification state behind the
// customer-admin Domains page.
@Schema({ collection: 'domains', timestamps: true })
export class Domain {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ required: true, lowercase: true, trim: true, index: true })
domain!: string
// First domain added for a tenant is primary. Display-only for now.
@Prop({ default: false })
isPrimary!: boolean
// Random token published as a `_dezky-verify.<domain>` TXT to prove ownership.
@Prop({ required: true })
verificationToken!: string
@Prop({ default: false })
ownershipVerified!: boolean
@Prop()
verifiedAt?: Date
// Customer's chosen DMARC enforcement level (wizard step 5). Drives the
// expected `_dmarc` value we verify against. Stalwart defaults to reject.
@Prop({ enum: ['none', 'quarantine', 'reject'], default: 'quarantine' })
dmarcPolicy!: DmarcPolicy
// Stalwart x:Domain handle + last provisioning error.
@Prop({ index: true, sparse: true })
stalwartId?: string
@Prop({ default: false })
stalwartProvisioned!: boolean
@Prop()
stalwartError?: string
// Snapshot of the last expected-vs-observed diff. Replaced wholesale on each
// recheck. Empty until the first check runs.
@Prop({ type: [DomainRecordSchema], default: [] })
records!: DomainRecord[]
@Prop({ enum: ['pending', 'verifying', 'active', 'error'], default: 'pending', index: true })
status!: DomainStatus
@Prop()
lastCheckedAt?: Date
}
export const DomainSchema = SchemaFactory.createForClass(Domain)
// A tenant can only register a given domain once.
DomainSchema.index({ tenantId: 1, domain: 1 }, { unique: true })
@@ -74,6 +74,29 @@ export class User {
// (MFA device list, group add/remove) work without an email lookup.
@Prop({ type: Number })
authentikUserPk?: number
// Mail + storage handles, filled by the tenant-member create flow. The mailbox
// address is the user's working email on the tenant's domain.
@Prop({ lowercase: true, trim: true })
mailboxAddress?: string
@Prop()
stalwartAccountId?: string
@Prop()
ocisUserId?: string
// Per-system provisioning outcome for this user. Authentik must succeed (the
// identity); stalwart/ocis are best-effort — 'skipped' means not attempted or
// deferred (e.g. OCIS auto-provisions on first sign-in).
@Prop({ type: Object, default: undefined })
provisioning?: {
authentik?: 'ok' | 'error'
stalwart?: 'ok' | 'error' | 'skipped'
ocis?: 'ok' | 'error' | 'skipped'
stalwartError?: string
ocisNote?: string
}
}
export const UserSchema = SchemaFactory.createForClass(User)
@@ -35,13 +35,15 @@ export class ProvisioningService {
tenant.authentikGroupId = String(group.pk)
})
// Stalwart + OCIS are stubbed — the upstream call no-ops and we record the
// honest 'skipped' state by returning it from the step.
// 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
return 'skipped'
// falls through to 'ok' — a real upstream call succeeded
})
await this.runStep(tenant, 'ocis', async () => {
@@ -0,0 +1,26 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator'
// Create a workspace member. The email is formed as `localPart@<domain>`, where
// domain defaults to the tenant's primary domain. One temp password provisions
// both their SSO login and their mailbox.
export class CreateTenantMemberDto {
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string
@IsString()
@MaxLength(64)
@Matches(/^[a-zA-Z0-9._-]+$/, {
message: 'address prefix may only contain letters, numbers, dots, hyphens and underscores',
})
localPart!: string
@IsIn(['admin', 'member'])
role!: 'admin' | 'member'
// Optional explicit domain (must belong to the tenant); omitted = primary.
@IsOptional()
@IsString()
domain?: string
}
@@ -0,0 +1,166 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
HttpCode,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { CreateTenantMemberDto } from './dto/create-tenant-member.dto.js'
import { UsersService } from './users.service.js'
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
}
// Create a workspace member (the Users & groups "Invite user" flow). Mounted
// under the tenant alongside GET /tenants/:slug/users (which lives in
// TenantsController); same membership gate as the other tenant-scoped resources.
// Lives in UsersModule because the cross-system provisioning is in UsersService,
// and TenantsModule can't import UsersModule (UsersModule already imports it).
@Controller('tenants/:slug/users')
@UseGuards(JwtAuthGuard)
export class TenantMembersController {
constructor(
private readonly users: UsersService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
@Post()
async create(
@Param('slug') slug: string,
@Body() dto: CreateTenantMemberDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.users.createTenantMember(
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
dto,
auditActor(actor, req),
)
}
// Remove a member and tear down their provisioned accounts. Self-removal is
// blocked so an admin can't lock themselves out of their own workspace.
@Delete(':userId')
@HttpCode(204)
async remove(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
if (String(actor._id) === userId) {
throw new ForbiddenException('You cant remove your own account.')
}
await this.users.removeTenantMember(
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
userId,
auditActor(actor, req),
)
}
// Resolve the tenant + assert the caller belongs to it (or is a platform admin).
private async gate(slug: string, jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return { actor, tenant }
}
@Post(':userId/suspend')
@HttpCode(204)
async suspend(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
if (String(actor._id) === userId) {
throw new ForbiddenException('You cant suspend your own account.')
}
await this.users.setMemberSuspended(
{ _id: tenant._id, slug: tenant.slug },
userId,
true,
auditActor(actor, req),
)
}
@Post(':userId/resume')
@HttpCode(204)
async resume(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
await this.users.setMemberSuspended(
{ _id: tenant._id, slug: tenant.slug },
userId,
false,
auditActor(actor, req),
)
}
@Post(':userId/force-logout')
async forceLogout(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
if (String(actor._id) === userId) {
throw new ForbiddenException('You cant force-logout your own session here.')
}
return this.users.forceLogoutMember(
{ _id: tenant._id, slug: tenant.slug },
userId,
auditActor(actor, req),
)
}
@Post(':userId/reset-password')
async resetPassword(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
return this.users.resetMemberPassword(
{ _id: tenant._id, slug: tenant.slug },
userId,
auditActor(actor, req),
)
}
}
@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Price, PriceSchema } from '../schemas/price.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
@@ -10,6 +11,7 @@ import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { PlatformReportsController } from './platform-reports.controller.js'
import { TenantMembersController } from './tenant-members.controller.js'
import { UsersController } from './users.controller.js'
import { UsersService } from './users.service.js'
@@ -28,13 +30,16 @@ import { UsersService } from './users.service.js'
// easy to extend (prorating, multi-currency) later.
{ name: Subscription.name, schema: SubscriptionSchema },
{ name: Price.name, schema: PriceSchema },
// Domain — read by createTenantMember to resolve the tenant's primary
// mail domain + its Stalwart id for mailbox provisioning.
{ name: Domain.name, schema: DomainSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
TenantsModule,
],
controllers: [UsersController, PlatformReportsController],
controllers: [UsersController, PlatformReportsController, TenantMembersController],
providers: [UsersService],
exports: [UsersService],
})
@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
@@ -11,6 +12,9 @@ import { Model, Types } from 'mongoose'
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import { Domain, DomainDocument } from '../schemas/domain.schema.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Price, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
@@ -46,8 +50,11 @@ export class UsersService {
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
private readonly audit: AuditService,
private readonly authentik: AuthentikClient,
private readonly stalwart: StalwartClient,
private readonly ocis: OcisClient,
config: ConfigService,
) {
this.platformAdminGroup =
@@ -628,6 +635,345 @@ export class UsersService {
// swallowed — the wizard wants to show "admin invite failed: ..." in the
// done state so the operator can retry rather than silently shipping a
// tenant with no admin.
// Create a workspace member and provision them across systems: Authentik SSO
// (required), a Stalwart mailbox on the tenant's default domain (best-effort),
// and an OCIS account (best-effort; auto-provisions on first login otherwise).
// One temp password is set on BOTH Authentik and the mailbox, so the new user
// has a single credential — returned once for the admin to hand over.
async createTenantMember(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; localPart: string; role: 'admin' | 'member'; domain?: string },
actor?: AuditActor,
): Promise<{
email: string
tempPassword: string
provisioning: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' }
stalwartError?: string
ocisNote?: string
}> {
if (!tenant.authentikGroupId) {
throw new BadRequestException(
`Workspace "${tenant.slug}" isn't fully provisioned (no identity group). Reconcile it first.`,
)
}
// Resolve the target domain — the named one, else the primary, else the oldest.
let domainDoc: DomainDocument | null
if (dto.domain) {
domainDoc = await this.domainModel
.findOne({ tenantId: tenant._id, domain: dto.domain.toLowerCase() })
.exec()
} else {
domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, isPrimary: true }).exec()
if (!domainDoc) {
domainDoc = await this.domainModel
.findOne({ tenantId: tenant._id })
.sort({ createdAt: 1 })
.exec()
}
}
if (!domainDoc) {
throw new BadRequestException('Add a domain to this workspace before creating users.')
}
const localPart = dto.localPart.trim().toLowerCase()
if (!/^[a-z0-9._-]+$/.test(localPart)) {
throw new BadRequestException(
'The address prefix may only contain letters, numbers, dots, hyphens and underscores.',
)
}
const email = `${localPart}@${domainDoc.domain}`
const dupe = await this.userModel.findOne({ email, tenantIds: tenant._id }).exec()
if (dupe) throw new ConflictException(`${email} already exists in this workspace.`)
const role: 'admin' | 'member' = dto.role === 'admin' ? 'admin' : 'member'
const tempPassword = generateTempPassword()
// 1) Authentik SSO identity (required) — same temp password so one credential
// works for sign-in (and OCIS, which authenticates via Authentik).
const created = await this.authentik.createUser({
username: email,
email,
name: dto.name,
groupPks: [tenant.authentikGroupId],
attributes: {
tenantSlug: tenant.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
await this.authentik.setInitialPassword(created.pk, tempPassword)
const prov: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' } = {
authentik: 'ok',
stalwart: 'skipped',
ocis: 'skipped',
}
// 2) Stalwart mailbox (best-effort) — the same temp password signs into webmail.
let stalwartAccountId: string | undefined
let stalwartError: string | undefined
if (this.stalwart.configured && domainDoc.stalwartId) {
try {
const mbx = await this.stalwart.createMailbox({
domainId: domainDoc.stalwartId,
localPart,
fullName: dto.name,
password: tempPassword,
})
stalwartAccountId = mbx.id
prov.stalwart = 'ok'
} catch (err) {
prov.stalwart = 'error'
stalwartError = (err as Error).message
this.logger.error(`Mailbox provisioning failed for ${email}: ${stalwartError}`)
}
}
// 3) OCIS account (best-effort; auto-provisions on first sign-in otherwise).
let ocisUserId: string | undefined
let ocisNote: string | undefined
const ocisRes = await this.ocis.ensureUser({ username: email, displayName: dto.name, mail: email })
if (ocisRes.deferred) {
prov.ocis = 'skipped'
ocisNote = 'auto-provisions on first sign-in'
} else {
prov.ocis = 'ok'
ocisUserId = ocisRes.id
}
// 4) Our User doc.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email,
name: dto.name,
[`tenantRoles.${tenant._id}`]: role,
mailboxAddress: email,
stalwartAccountId,
ocisUserId,
authentikUserPk: created.pk,
provisioning: { ...prov, stalwartError, ocisNote },
},
$setOnInsert: { role, active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'tenant.user_created',
resourceType: 'user',
resourceId: created.uid,
resourceName: email,
tenantSlug: tenant.slug,
metadata: { name: dto.name, role, mailbox: prov.stalwart, ocis: prov.ocis },
},
actor,
)
return { email, tempPassword, provisioning: prov, stalwartError, ocisNote }
}
// Remove a member from a workspace, tearing down their provisioned accounts.
// The mailbox lives on one of THIS tenant's domains, so it's deleted (only if
// it actually belongs here — a multi-tenant user's mailbox on another tenant is
// left alone). The SSO identity + OCIS account are global, so they're deleted
// only when the user belongs to no other tenant (and isn't partner staff / a
// platform admin); otherwise we just detach this tenant.
async removeTenantMember(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
userId: string,
actor?: AuditActor,
): Promise<void> {
let _id: Types.ObjectId
try {
_id = new Types.ObjectId(userId)
} catch {
throw new NotFoundException('User not found')
}
const user = await this.userModel.findById(_id).exec()
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
throw new NotFoundException('User not found in this workspace')
}
// Is the user's mailbox on a domain owned by THIS tenant?
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
const mailboxIsHere =
!!mailboxDomain &&
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
// 1) Delete the mailbox (best-effort) when it belongs to this tenant.
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.deleteMailbox(user.stalwartAccountId).catch((err) => {
this.logger.error(`Mailbox delete failed for ${user.email}: ${(err as Error).message}`)
})
}
// 2) Remove from this tenant's Authentik group.
if (tenant.authentikGroupId && user.authentikUserPk) {
await this.authentik
.removeUserFromGroup(user.authentikUserPk, tenant.authentikGroupId)
.catch((err) => {
this.logger.error(`Authentik group remove failed for ${user.email}: ${(err as Error).message}`)
})
}
const remaining = user.tenantIds.filter((t) => !t.equals(tenant._id))
const fullyRemove = remaining.length === 0 && !user.partnerId && !user.platformAdmin
if (fullyRemove) {
if (user.ocisUserId) await this.ocis.deleteUser(user.ocisUserId)
if (user.authentikUserPk) {
await this.authentik.deleteUser(user.authentikUserPk).catch((err) => {
this.logger.error(`Authentik user delete failed for ${user.email}: ${(err as Error).message}`)
})
}
await this.userModel.deleteOne({ _id }).exec()
} else {
// Keep the global identity; detach this tenant. Drop the mailbox handle
// only if the mailbox we removed was actually this tenant's.
const unset: Record<string, ''> = { [`tenantRoles.${tenant._id}`]: '' }
if (mailboxIsHere) {
unset.mailboxAddress = ''
unset.stalwartAccountId = ''
}
await this.userModel
.updateOne({ _id }, { $pull: { tenantIds: tenant._id }, $unset: unset })
.exec()
}
void this.audit.record(
{
action: 'tenant.user_removed',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
metadata: { fullyRemoved: fullyRemove },
},
actor,
)
}
// Shared lookup for the per-member lifecycle actions: the user doc (verified to
// belong to this tenant) + whether their mailbox is on one of this tenant's
// domains (so mailbox-side changes only touch mailboxes we own).
private async loadMember(
tenant: { _id: Types.ObjectId },
userId: string,
): Promise<{ user: UserDocument; mailboxIsHere: boolean }> {
let _id: Types.ObjectId
try {
_id = new Types.ObjectId(userId)
} catch {
throw new NotFoundException('User not found')
}
const user = await this.userModel.findById(_id).exec()
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
throw new NotFoundException('User not found in this workspace')
}
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
const mailboxIsHere =
!!mailboxDomain &&
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
return { user, mailboxIsHere }
}
// Suspend or resume a member: toggles Authentik sign-in (is_active) and freezes
// / unfreezes the mailbox. Reversible — resume restores the original password.
async setMemberSuspended(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
suspended: boolean,
actor?: AuditActor,
): Promise<void> {
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
// Identity first — if this fails, abort before touching anything else.
if (user.authentikUserPk) {
await this.authentik.setUserActive(user.authentikUserPk, !suspended)
}
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.setMailboxSuspended(user.stalwartAccountId, suspended).catch((err) => {
this.logger.error(
`Mailbox ${suspended ? 'suspend' : 'resume'} failed for ${user.email}: ${(err as Error).message}`,
)
})
}
user.active = !suspended
await user.save()
void this.audit.record(
{
action: suspended ? 'tenant.user_suspended' : 'tenant.user_resumed',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
},
actor,
)
}
// Force-logout: terminate the member's active SSO sessions.
async forceLogoutMember(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
actor?: AuditActor,
): Promise<{ sessions: number }> {
const { user } = await this.loadMember(tenant, userId)
let sessions = 0
if (user.authentikUserPk) {
sessions = await this.authentik.terminateSessions(user.authentikUserPk).catch(() => 0)
}
void this.audit.record(
{
action: 'tenant.user_logout_forced',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
metadata: { sessions },
},
actor,
)
return { sessions }
}
// Reset a member's password: one fresh temp password set on both their SSO
// login and their mailbox, returned once for the admin to hand over.
async resetMemberPassword(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
actor?: AuditActor,
): Promise<{ email: string; tempPassword: string }> {
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
const tempPassword = generateTempPassword()
if (user.authentikUserPk) {
await this.authentik.setInitialPassword(user.authentikUserPk, tempPassword)
}
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.setMailboxPassword(user.stalwartAccountId, tempPassword).catch((err) => {
this.logger.error(`Mailbox password reset failed for ${user.email}: ${(err as Error).message}`)
})
}
void this.audit.record(
{
action: 'tenant.user_password_reset',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
},
actor,
)
return { email: user.email, tempPassword }
}
async inviteTenantAdmin(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; email: string },