0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common'
|
|
import { ConfigService } from '@nestjs/config'
|
|
|
|
interface AuthentikGroup {
|
|
pk: string
|
|
name: string
|
|
attributes?: Record<string, unknown>
|
|
}
|
|
|
|
// Thin wrapper around the Authentik API for the operations the provisioning
|
|
// service needs. We never expose raw Authentik errors to API callers — they
|
|
// surface as provisioningErrors.authentik strings.
|
|
@Injectable()
|
|
export class AuthentikClient {
|
|
private readonly logger = new Logger(AuthentikClient.name)
|
|
private readonly base: string
|
|
private readonly token: string
|
|
|
|
constructor(config: ConfigService) {
|
|
this.base = config.getOrThrow<string>('AUTHENTIK_API_URL')
|
|
this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
|
|
}
|
|
|
|
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(`${this.base}${path}`, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
...(init.headers ?? {}),
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
// 204 No Content (and other empty-body successes) crash res.json().
|
|
// Endpoints like /core/groups/:id/add_user/ return 204; callers with a
|
|
// void return type don't care about the payload, so hand back undefined.
|
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
|
return undefined as T
|
|
}
|
|
return (await res.json()) as T
|
|
}
|
|
|
|
// Idempotent: returns existing group if name is taken, creates otherwise.
|
|
async ensureGroup(slug: string, attributes: Record<string, unknown> = {}): Promise<AuthentikGroup> {
|
|
const search = await this.request<{ results: AuthentikGroup[] }>(
|
|
`/core/groups/?name=${encodeURIComponent(slug)}`,
|
|
)
|
|
if (search.results.length > 0) {
|
|
this.logger.log(`Authentik group "${slug}" already exists (pk=${search.results[0].pk})`)
|
|
return search.results[0]
|
|
}
|
|
const created = await this.request<AuthentikGroup>('/core/groups/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: slug,
|
|
attributes: { role: 'tenant', slug, ...attributes },
|
|
}),
|
|
})
|
|
this.logger.log(`Created Authentik group "${slug}" (pk=${created.pk})`)
|
|
return created
|
|
}
|
|
|
|
async deleteGroup(groupId: string): Promise<void> {
|
|
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
})
|
|
if (!res.ok && res.status !== 404) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik DELETE group ${groupId} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
this.logger.log(`Deleted Authentik group ${groupId}`)
|
|
}
|
|
|
|
// Pull a window of Authentik events. Used by the audit ingest worker.
|
|
// `since` filters by created timestamp (strict greater-than); pagination is
|
|
// forward-only via `page`. Authentik's default page size is 100.
|
|
async listEvents(
|
|
since?: Date,
|
|
page = 1,
|
|
pageSize = 100,
|
|
): Promise<AuthentikEventPage> {
|
|
const params = new URLSearchParams({
|
|
ordering: 'created',
|
|
page: String(page),
|
|
page_size: String(pageSize),
|
|
})
|
|
if (since) params.set('created__gt', since.toISOString())
|
|
return this.request<AuthentikEventPage>(`/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<AuthentikUser | undefined> {
|
|
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<string, unknown>
|
|
groupPks?: string[]
|
|
}): Promise<AuthentikUser> {
|
|
const created = await this.request<AuthentikUser>('/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<void> {
|
|
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<string | undefined> {
|
|
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<T>()'s JSON parser and call fetch directly.
|
|
async setInitialPassword(userPk: number, password: string): Promise<void> {
|
|
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}`)
|
|
}
|
|
|
|
// Mark the user's password as expired so Authentik forces a change at next
|
|
// login. Used by the temp-password fallback path so a stolen temp password
|
|
// can't outlive the first real session. The recovery-link path doesn't
|
|
// need this — clicking the link runs through the recovery flow which sets
|
|
// a fresh password anyway.
|
|
async markPasswordExpired(userPk: number): Promise<void> {
|
|
// Authentik stores per-user policy state under `attributes`. PATCH merges
|
|
// top-level keys but replaces nested objects, so we have to read-modify-
|
|
// write to avoid clobbering other attributes.
|
|
const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
|
|
`/core/users/${userPk}/`,
|
|
)
|
|
const attrs = {
|
|
...(user.attributes ?? {}),
|
|
// Authentik's expiry check looks at `passwordExpired` (camelCase).
|
|
passwordExpired: true,
|
|
passwordExpiredAt: new Date().toISOString(),
|
|
}
|
|
await this.request(`/core/users/${userPk}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ attributes: attrs }),
|
|
})
|
|
this.logger.log(`Marked password as expired for Authentik user ${userPk}`)
|
|
}
|
|
|
|
// ── Recovery flow bootstrap ────────────────────────────────────────────
|
|
// Default Authentik installs don't ship a recovery flow — operators have to
|
|
// either create one in the admin UI or have us provision it. The methods
|
|
// below make the bootstrap idempotent: re-running on an already-configured
|
|
// Authentik is a no-op.
|
|
|
|
async findRecoveryFlow(): Promise<AuthentikFlow | undefined> {
|
|
const res = await this.request<{ results: AuthentikFlow[] }>(
|
|
`/flows/instances/?designation=recovery`,
|
|
)
|
|
return res.results[0]
|
|
}
|
|
|
|
async getDefaultBrand(): Promise<AuthentikBrand | undefined> {
|
|
const res = await this.request<{ results: AuthentikBrand[] }>('/core/brands/')
|
|
return res.results.find((b) => b._default) ?? res.results[0]
|
|
}
|
|
|
|
async findStageByName(name: string): Promise<AuthentikStage | undefined> {
|
|
const res = await this.request<{ results: AuthentikStage[] }>(
|
|
`/stages/all/?name=${encodeURIComponent(name)}`,
|
|
)
|
|
return res.results.find((s) => s.name === name)
|
|
}
|
|
|
|
async createFlow(input: {
|
|
name: string
|
|
slug: string
|
|
title: string
|
|
designation: 'recovery' | 'authentication' | 'authorization' | 'enrollment' | 'invalidation' | 'stage_configuration' | 'unenrollment'
|
|
}): Promise<AuthentikFlow> {
|
|
return this.request<AuthentikFlow>('/flows/instances/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: input.name,
|
|
slug: input.slug,
|
|
title: input.title,
|
|
designation: input.designation,
|
|
// 'none' = no extra auth required to start the flow (the recovery
|
|
// token in the link is the auth). 'require_authenticated' would
|
|
// break the link-click path.
|
|
authentication: 'none',
|
|
// No background; the consent screen is up to the brand.
|
|
background: '',
|
|
}),
|
|
})
|
|
}
|
|
|
|
async bindStageToFlow(input: { target: string; stage: string; order: number }): Promise<void> {
|
|
await this.request('/flows/bindings/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
target: input.target,
|
|
stage: input.stage,
|
|
order: input.order,
|
|
evaluate_on_plan: true,
|
|
re_evaluate_policies: false,
|
|
}),
|
|
})
|
|
}
|
|
|
|
async setBrandRecoveryFlow(brandUuid: string, flowUuid: string): Promise<void> {
|
|
await this.request(`/core/brands/${brandUuid}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ flow_recovery: flowUuid }),
|
|
})
|
|
this.logger.log(`Set brand ${brandUuid} flow_recovery → ${flowUuid}`)
|
|
}
|
|
}
|
|
|
|
export interface AuthentikFlow {
|
|
pk: string
|
|
slug: string
|
|
name: string
|
|
title: string
|
|
designation: string
|
|
}
|
|
|
|
export interface AuthentikBrand {
|
|
brand_uuid: string
|
|
domain: string
|
|
_default?: boolean
|
|
flow_recovery?: string | null
|
|
}
|
|
|
|
export interface AuthentikStage {
|
|
pk: string
|
|
name: string
|
|
component: string
|
|
}
|
|
|
|
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
|
|
// includes a number of others (tenant, brand) we don't need.
|
|
export interface AuthentikEvent {
|
|
pk: string
|
|
action: string
|
|
app?: string
|
|
user?: { pk?: number; username?: string; name?: string; email?: string }
|
|
context?: Record<string, unknown>
|
|
client_ip?: string
|
|
created: string
|
|
}
|
|
export interface AuthentikEventPage {
|
|
pagination: { next: number; previous: number; count: number; current: number; total_pages: number; start_index: number; end_index: number }
|
|
results: AuthentikEvent[]
|
|
}
|