Files
dezky/services/platform-api/src/integrations/authentik.client.ts
T
Ronni Baslund 22b2583f0b chore(services): rename services/provisioning -> services/platform-api
O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner
management and operator-specific endpoints. The service now owns more than
just provisioning orchestration (it'll soon own partners, tenant lifecycle
actions, multi-audience JWT validation), so the name 'platform-api' reflects
its scope better.

What changed:
- Directory: services/provisioning/ -> services/platform-api/
- Package: @dezky/provisioning -> @dezky/platform-api
- Docker: container_name dezky-provisioning -> dezky-platform-api;
  compose service key 'provisioning' -> 'platform-api'; volume
  provisioning_node_modules -> platform_api_node_modules
- Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL,
  default URL http://provisioning:3001 -> http://platform-api:3001 in all
  three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/
  reconcile.post.ts), plus NUXT_API_BASE updated
- Health endpoint service identifier and main.ts log lines updated to
  'dezky-platform-api'
- Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md,
  NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml

What deliberately stays:
- Internal module names ProvisioningService / ProvisioningModule (those
  describe an orchestration sub-concern, not the service's purpose)
- Tenant.provisioningStatus / provisioningErrors field names (state
  per integration, not service name)
- File services/platform-api/src/tenants/provisioning.service.ts
- 'Hetzner provisioning' references in production-prep docs (infrastructure
  provisioning, unrelated)

Verified end-to-end after rename: /api/me returns 200 with profile + 2
tenants + subscription, /api/tenants/dezky/reconcile returns 200 with
Authentik integration still ok.

OPERATOR-PLAN.md O.0 checkboxes ticked.
2026-05-24 00:35:01 +02:00

73 lines
2.5 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)}`)
}
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}`)
}
}