From 9a97945565b12556748feca2fdb1c5415feab968 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 21:27:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(operator):=20invite=20operator=20=E2=86=92?= =?UTF-8?q?=20creates=20user=20in=20Authentik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'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 --- .../components/InviteOperatorModal.vue | 301 ++++++++++++++++++ apps/operator/pages/operator-team.vue | 17 +- apps/operator/server/api/users/invite.post.ts | 6 + .../src/integrations/authentik.client.ts | 103 ++++++ .../src/users/dto/invite-operator.dto.ts | 13 + .../src/users/users.controller.ts | 21 ++ .../platform-api/src/users/users.module.ts | 2 + .../platform-api/src/users/users.service.ts | 151 ++++++++- 8 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 apps/operator/components/InviteOperatorModal.vue create mode 100644 apps/operator/server/api/users/invite.post.ts create mode 100644 services/platform-api/src/users/dto/invite-operator.dto.ts diff --git a/apps/operator/components/InviteOperatorModal.vue b/apps/operator/components/InviteOperatorModal.vue new file mode 100644 index 0000000..48638d1 --- /dev/null +++ b/apps/operator/components/InviteOperatorModal.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/apps/operator/pages/operator-team.vue b/apps/operator/pages/operator-team.vue index e9d683d..a36bdbc 100644 --- a/apps/operator/pages/operator-team.vue +++ b/apps/operator/pages/operator-team.vue @@ -7,6 +7,8 @@ const { data: users, pending, refresh } = await useFetch('/api/u const operators = computed(() => (users.value ?? []).filter((u) => u.platformAdmin)) +const inviteOpen = ref(false) + function lastSeen(u: PlatformUser) { if (!u.lastLoginAt) return '—' const diff = Date.now() - new Date(u.lastLoginAt).getTime() @@ -18,6 +20,13 @@ function lastSeen(u: PlatformUser) { const d = Math.floor(h / 24) return `${d} d ago` } + +async function onInvited() { + // Refresh the list so the newly-invited operator shows up immediately — + // platform-api pre-creates the local User doc, so they appear with + // platformAdmin=true even before their first login. + await refresh() +} + +
diff --git a/apps/operator/server/api/users/invite.post.ts b/apps/operator/server/api/users/invite.post.ts new file mode 100644 index 0000000..c204f03 --- /dev/null +++ b/apps/operator/server/api/users/invite.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + return platformApi(event, '/users/invite', { method: 'POST', body }) +}) diff --git a/services/platform-api/src/integrations/authentik.client.ts b/services/platform-api/src/integrations/authentik.client.ts index 1929caf..db5565e 100644 --- a/services/platform-api/src/integrations/authentik.client.ts +++ b/services/platform-api/src/integrations/authentik.client.ts @@ -86,6 +86,109 @@ export class AuthentikClient { if (since) params.set('created__gt', since.toISOString()) return this.request(`/events/events/?${params}`) } + + // Look up a user by email. Returns undefined if not found. Used by the + // invite flow so we can give a friendly conflict error instead of letting + // Authentik 400. + async findUserByEmail(email: string): Promise { + const res = await this.request<{ results: AuthentikUser[] }>( + `/core/users/?email=${encodeURIComponent(email)}`, + ) + return res.results[0] + } + + // Create a user. Authentik's `uid` field becomes the JWT `sub` claim once + // they log in — this is the same value our User.authentikSubjectId is keyed + // on. We set type='internal' (real human user, not service account) and + // is_active=true so the recovery link they receive lets them set a password. + async createUser(input: { + username: string + email: string + name: string + attributes?: Record + groupPks?: string[] + }): Promise { + const created = await this.request('/core/users/', { + method: 'POST', + body: JSON.stringify({ + username: input.username, + email: input.email, + name: input.name, + type: 'internal', + is_active: true, + path: 'users', + groups: input.groupPks ?? [], + attributes: input.attributes ?? {}, + }), + }) + this.logger.log(`Created Authentik user ${input.email} (pk=${created.pk}, uid=${created.uid})`) + return created + } + + // Add an existing user to a group by ID. Idempotent — adding twice is a + // no-op on Authentik's side. + async addUserToGroup(userPk: number, groupId: string): Promise { + await this.request(`/core/groups/${groupId}/add_user/`, { + method: 'POST', + body: JSON.stringify({ pk: userPk }), + }) + this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`) + } + + // Generate a single-use recovery link the new user clicks to set their + // password + enroll MFA. Requires a "recovery flow" configured on the + // Authentik brand — if not set, returns undefined so callers can fall + // back to setInitialPassword. + async recoveryLink(userPk: number): Promise { + try { + const res = await this.request<{ link: string }>( + `/core/users/${userPk}/recovery/`, + { method: 'POST' }, + ) + return res.link + } catch (err) { + // Authentik returns 400 with "No recovery flow set." when the brand has + // no recovery flow wired. Treat as soft-fail; caller fallback path + // sets an initial password instead. + if (err instanceof Error && err.message.includes('recovery flow')) { + this.logger.warn('Authentik recovery link unavailable — no recovery flow configured') + return undefined + } + throw err + } + } + + // Direct set_password — used when no recovery flow is configured. The + // operator hands the temp password to the new user out-of-band; the user + // changes it after first login via Authentik's password-change flow. + // Authentik returns 204 No Content (empty body) on success, so we skip + // request()'s JSON parser and call fetch directly. + async setInitialPassword(userPk: number, password: string): Promise { + const res = await fetch(`${this.base}/core/users/${userPk}/set_password/`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ password }), + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Authentik set_password ${userPk} → ${res.status}: ${body.slice(0, 200)}`) + } + this.logger.log(`Set initial password for Authentik user ${userPk}`) + } +} + +export interface AuthentikUser { + pk: number + uid: string // becomes JWT `sub` on first login + username: string + email: string + name?: string + is_active: boolean + groups?: string[] } // Shape returned by /events/events/. Only the fields we read; Authentik diff --git a/services/platform-api/src/users/dto/invite-operator.dto.ts b/services/platform-api/src/users/dto/invite-operator.dto.ts new file mode 100644 index 0000000..7e6ef8c --- /dev/null +++ b/services/platform-api/src/users/dto/invite-operator.dto.ts @@ -0,0 +1,13 @@ +import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator' + +// Operator-only: invite a new platform admin. Body is just identity — group +// membership is implicit (we add them to the dezky-platform-admins Authentik +// group). Tenant-member invites land later via a different endpoint with +// tenantSlug + role fields. +export class InviteOperatorDto { + @IsString() @MinLength(2) @MaxLength(120) + name!: string + + @IsEmail() @MaxLength(254) + email!: string +} diff --git a/services/platform-api/src/users/users.controller.ts b/services/platform-api/src/users/users.controller.ts index 20da87f..6c4271c 100644 --- a/services/platform-api/src/users/users.controller.ts +++ b/services/platform-api/src/users/users.controller.ts @@ -17,7 +17,9 @@ 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 { OperatorGuard } from '../auth/operator.guard.js' import { CreateUserDto } from './dto/create-user.dto.js' +import { InviteOperatorDto } from './dto/invite-operator.dto.js' import { UpdateUserDto } from './dto/update-user.dto.js' import { UsersService } from './users.service.js' @@ -63,6 +65,25 @@ export class UsersController { return this.users.create(dto) } + // Operator-only: invite a new platform admin. Creates the user in Authentik, + // adds them to the dezky-platform-admins group, returns a recovery link the + // operator shares manually. Once outbound SMTP is wired, Authentik can + // email the link directly and the response link is mostly informational. + @Post('invite') + @UseGuards(OperatorGuard) + async invite( + @Body() dto: InviteOperatorDto, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const actor = await this.actor.resolve(jwt) + return this.users.inviteOperator(dto, { + userId: String(actor._id), + email: actor.email, + ip: clientIp(req), + }) + } + @Get() async findAll(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) diff --git a/services/platform-api/src/users/users.module.ts b/services/platform-api/src/users/users.module.ts index a1bdb00..78cff6f 100644 --- a/services/platform-api/src/users/users.module.ts +++ b/services/platform-api/src/users/users.module.ts @@ -2,6 +2,7 @@ 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 { Tenant, TenantSchema } from '../schemas/tenant.schema.js' import { User, UserSchema } from '../schemas/user.schema.js' import { TenantsModule } from '../tenants/tenants.module.js' @@ -16,6 +17,7 @@ import { UsersService } from './users.service.js' ]), AuthModule, AuditModule, + IntegrationsModule, TenantsModule, ], controllers: [UsersController], diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts index 3130148..f49bc92 100644 --- a/services/platform-api/src/users/users.service.ts +++ b/services/platform-api/src/users/users.service.ts @@ -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, @InjectModel(Tenant.name) private readonly tenantModel: Model, private readonly audit: AuditService, - ) {} + private readonly authentik: AuthentikClient, + config: ConfigService, + ) { + this.platformAdminGroup = + config.get('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins' + } async create(dto: CreateUserDto): Promise { 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 { + 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('') }