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:
@@ -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