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:
@@ -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 can’t 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 can’t 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 can’t 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 },
|
||||
|
||||
Reference in New Issue
Block a user