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
@@ -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 },