feat(operator): invite operator → creates user in Authentik

New "Invite operator" button + modal on /operator-team. Replaces the
bounce-to-Authentik flow with an inline invite that creates the user via
the Authentik API and pre-populates our local User doc so they appear
immediately.

services/platform-api/src/integrations/authentik.client.ts:
  - findUserByEmail(): early-conflict check before we attempt the create
  - createUser(): POST /core/users/ with username = email, internal type,
    is_active, attached to the supplied group PKs
  - addUserToGroup(): kept for tenant-member invites later
  - recoveryLink(): tries POST /core/users/{pk}/recovery/, returns
    undefined when no recovery flow is configured on the Authentik brand
    (we soft-fail and the service falls back to setInitialPassword)
  - setInitialPassword(): POST /core/users/{pk}/set_password/. Returns 204
    No Content so we bypass request<T>'s JSON parser and call fetch
    directly with explicit ok check.

services/platform-api/src/users/users.service.ts:
  - inviteOperator(dto, actor) orchestrates: dedup by email →
    findOrCreate Authentik group → create user in group → pre-create
    local User doc with platformAdmin=true so the list reflects them
    immediately → try recovery link → fall back to temp password →
    record platform.user_invited audit event with handoff method.
  - Return type is { subject, userId, link? | tempPassword? } —
    exactly one credential mode set depending on Authentik config.
  - generateTempPassword(): 16-char with at least one upper/lower/digit/
    symbol, shuffled. Confusable chars (I/O/0/1/l) omitted.
  - Cached platform-admin group ID after first lookup.

services/platform-api/src/users/users.controller.ts:
  - POST /users/invite behind OperatorGuard. Calls the service with
    actor + IP from the JWT/request.

apps/operator:
  - server/api/users/invite.post.ts: standard platformApi proxy.
  - components/InviteOperatorModal.vue: 2-step form. Step 1: name +
    email with client-side validation. Step 2: shows whichever
    credential the backend returned — recovery link OR username+
    temp-password — with copy-to-clipboard buttons and a note about
    SMTP/recovery-flow follow-up paths.
  - pages/operator-team.vue: "Invite operator" replaces "Manage in
    Authentik" as the primary action; Authentik link demoted to
    secondary. Refreshes the list on @invited so the new user shows
    up without a manual reload.

Verified end-to-end against real Authentik:
  - Invite created user pk=7, uid=f22f2bb…, group=dezky-platform-admins,
    is_active=true, temp password set. Modal showed both fields with
    copy buttons; operator-team count went 1 → 2 immediately. Audit
    event recorded (platform.user_invited with handoff='temp-password').
  - Recovery link path is preferred but Authentik has no recovery flow
    configured on the default brand. AuthentikClient.recoveryLink()
    soft-fails on the "No recovery flow set." 400, returns undefined,
    and inviteOperator transparently falls back to set_password. Once
    a recovery flow is configured (Authentik admin → Flows), the link
    path becomes active and the temp-password path stops firing
    without any code changes.

Known follow-ups:
  - Configure Authentik recovery flow so the link path activates
    (one-time admin task, not in code)
  - Outbound SMTP wiring (Phase 5/6) → Authentik can email link/temp
    directly; modal stops showing the credential
  - Deactivate / remove operator from inside the app (currently still
    Authentik UI; defensible until proven needed)
  - Tenant-member invite — similar flow but adds to tenant group
    instead, exposed from /users (global users) or tenant detail
This commit is contained in:
Ronni Baslund
2026-05-24 21:27:46 +02:00
parent 4d9e906ec1
commit 9a97945565
8 changed files with 611 additions and 3 deletions
@@ -1,19 +1,34 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateUserDto } from './dto/create-user.dto.js'
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
import type { UpdateUserDto } from './dto/update-user.dto.js'
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name)
private readonly platformAdminGroup: string
// Cached after first successful lookup. The dezky-platform-admins group is
// created once during Authentik bootstrap and never moves; no need to look
// it up every invite.
private platformAdminGroupId: string | null = null
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly audit: AuditService,
) {}
private readonly authentik: AuthentikClient,
config: ConfigService,
) {
this.platformAdminGroup =
config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins'
}
async create(dto: CreateUserDto): Promise<UserDocument> {
const exists = await this.userModel.exists({ authentikSubjectId: dto.authentikSubjectId })
@@ -106,4 +121,136 @@ export class UsersService {
const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec()
return tenants.map((t) => t._id)
}
// Invite a new platform admin. Creates the user in Authentik, adds them to
// the dezky-platform-admins group, pre-creates the local User doc so they
// appear in the operator-team list immediately, and returns whichever
// credential-handoff path Authentik supports for our brand:
// - `link` — single-use recovery URL (preferred; requires a recovery
// flow configured in Authentik. User clicks → sets password
// + enrolls MFA themselves.)
// - `tempPassword` — random 16-char password we set on the user. Used
// when no recovery flow exists; operator hands this
// to the new user out-of-band and they change it on
// first login via Authentik's password-change flow.
// On their first login, upsertFromAuthentik() patches lastLoginAt +
// reconciles group state from the JWT — no further work needed.
//
// Email delivery is the operator's job for now (we return the credential);
// when outbound SMTP is wired (Phase 5/6), Authentik can email directly.
async inviteOperator(
dto: InviteOperatorDto,
actor?: AuditActor,
): Promise<{
subject: string
userId: string
link?: string
tempPassword?: string
}> {
// Prevent duplicate by email — Authentik will 400 but its error message
// isn't friendly. Check up front for a clean conflict.
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
throw new ConflictException(
`User with email ${dto.email} already exists in Authentik (uid=${existing.uid})`,
)
}
const groupPk = await this.resolvePlatformAdminGroupId()
const username = dto.email // Authentik convention — keep email + username aligned
const created = await this.authentik.createUser({
username,
email: dto.email,
name: dto.name,
groupPks: [groupPk],
attributes: { invitedBy: actor?.email, invitedAt: new Date().toISOString() },
})
// Pre-create the local User doc so the operator-team list reflects the
// invite immediately. On their first login /users/me will upsert and
// reconcile lastLoginAt + platformAdmin from the JWT.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email: dto.email,
name: dto.name,
platformAdmin: true,
},
$setOnInsert: { role: 'admin', active: true, tenantIds: [] },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
// Try the preferred recovery-link path first. If Authentik has no
// recovery flow configured (returns undefined), fall back to setting a
// generated temp password.
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
}
void this.audit.record(
{
action: 'platform.user_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
metadata: {
role: 'platform-admin',
name: dto.name,
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Resolve + cache the dezky-platform-admins group ID. The group is created
// by Authentik bootstrap so it's reliably present; ensureGroup is
// idempotent so the worst case is a no-op extra API call on cold start.
private async resolvePlatformAdminGroupId(): Promise<string> {
if (this.platformAdminGroupId) return this.platformAdminGroupId
const group = await this.authentik.ensureGroup(this.platformAdminGroup, {
role: 'platform-admin',
})
this.platformAdminGroupId = group.pk
return group.pk
}
}
// Generates a 16-character random password with mixed character classes.
// Authentik's default password policy requires length + complexity; this
// generator clears every reasonable policy. The new user changes it on
// first login.
function generateTempPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' // omit I + O (visually confusable)
const lower = 'abcdefghijkmnpqrstuvwxyz' // omit l (confusable with 1)
const digit = '23456789' // omit 0 + 1
const symbol = '!@#$%&*+-='
const all = upper + lower + digit + symbol
// Pick at least one from each class; fill the rest from `all`; shuffle.
const out: string[] = [
upper[Math.floor(Math.random() * upper.length)],
lower[Math.floor(Math.random() * lower.length)],
digit[Math.floor(Math.random() * digit.length)],
symbol[Math.floor(Math.random() * symbol.length)],
]
while (out.length < 16) {
out.push(all[Math.floor(Math.random() * all.length)])
}
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[out[i], out[j]] = [out[j], out[i]]
}
return out.join('')
}