diff --git a/apps/portal/server/api/tenants/[slug]/reconcile.post.ts b/apps/portal/server/api/tenants/[slug]/reconcile.post.ts new file mode 100644 index 0000000..e5ab024 --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/reconcile.post.ts @@ -0,0 +1,15 @@ +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + const slug = getRouterParam(event, 'slug') + const base = process.env.PROVISIONING_INTERNAL_URL ?? 'http://provisioning:3001' + return $fetch(`${base}/tenants/${slug}/reconcile`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }) +}) diff --git a/apps/portal/server/api/tenants/index.post.ts b/apps/portal/server/api/tenants/index.post.ts new file mode 100644 index 0000000..0bcd7b3 --- /dev/null +++ b/apps/portal/server/api/tenants/index.post.ts @@ -0,0 +1,23 @@ +// Dev/scaffolding: proxies POST /tenants to the provisioning service with the +// logged-in user's access token. Lets you create a tenant from the browser +// without minting tokens by hand. Will be replaced by a real "create workspace" +// flow with proper UI later. + +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + + const body = await readBody(event) + const base = process.env.PROVISIONING_INTERNAL_URL ?? 'http://provisioning:3001' + + return $fetch(`${base}/tenants`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) +}) diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml index 3b4f5c3..6e52951 100644 --- a/infrastructure/docker-compose/docker-compose.yml +++ b/infrastructure/docker-compose/docker-compose.yml @@ -401,12 +401,12 @@ services: NODE_ENV: development PORT: 3001 MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin - AUTHENTIK_API_URL: http://authentik-server:9000/api/v3 + AUTHENTIK_API_URL: https://auth.dezky.local/api/v3 AUTHENTIK_API_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN} - STALWART_API_URL: http://stalwart:8080 + STALWART_API_URL: https://mail.dezky.local STALWART_ADMIN_USER: admin STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD} - OCIS_API_URL: http://ocis:9200 + OCIS_API_URL: https://files.dezky.local # JWT validation against Authentik for portal-issued access tokens AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/ AUTHENTIK_AUDIENCE: dezky-portal diff --git a/services/provisioning/src/integrations/authentik.client.ts b/services/provisioning/src/integrations/authentik.client.ts new file mode 100644 index 0000000..2c6003c --- /dev/null +++ b/services/provisioning/src/integrations/authentik.client.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +interface AuthentikGroup { + pk: string + name: string + attributes?: Record +} + +// 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('AUTHENTIK_API_URL') + this.token = config.getOrThrow('AUTHENTIK_API_TOKEN') + } + + private async request(path: string, init: RequestInit = {}): Promise { + 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 = {}): Promise { + 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('/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 { + 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}`) + } +} diff --git a/services/provisioning/src/integrations/integrations.module.ts b/services/provisioning/src/integrations/integrations.module.ts new file mode 100644 index 0000000..4c09cd4 --- /dev/null +++ b/services/provisioning/src/integrations/integrations.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { AuthentikClient } from './authentik.client.js' +import { OcisClient } from './ocis.client.js' +import { StalwartClient } from './stalwart.client.js' + +@Module({ + providers: [AuthentikClient, StalwartClient, OcisClient], + exports: [AuthentikClient, StalwartClient, OcisClient], +}) +export class IntegrationsModule {} diff --git a/services/provisioning/src/integrations/ocis.client.ts b/services/provisioning/src/integrations/ocis.client.ts new file mode 100644 index 0000000..cda9a62 --- /dev/null +++ b/services/provisioning/src/integrations/ocis.client.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +// OCIS provisioning is stubbed for now. Real implementation needs: +// 1. Service-to-service auth via OIDC client_credentials (or admin user) +// 2. Call the libregraph /graph/v1.0/drives endpoint to create a project space +// 3. Assign the space to the tenant's group / users +// Phase 4 ships the orchestration; OCIS hooks up in a follow-up. +@Injectable() +export class OcisClient { + private readonly logger = new Logger(OcisClient.name) + private readonly base: string + + constructor(config: ConfigService) { + this.base = config.getOrThrow('OCIS_API_URL') + } + + async ensureSpace(slug: string): Promise<{ id: string }> { + this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`) + return { id: `stub-${slug}` } + } + + async deleteSpace(spaceId: string): Promise { + this.logger.warn(`OCIS space delete is stubbed — would delete ${spaceId} at ${this.base}`) + } +} diff --git a/services/provisioning/src/integrations/stalwart.client.ts b/services/provisioning/src/integrations/stalwart.client.ts new file mode 100644 index 0000000..0f271f8 --- /dev/null +++ b/services/provisioning/src/integrations/stalwart.client.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +// Stalwart v0.16 removed the REST management API — all admin operations now go +// through the JMAP /jmap endpoint with Principal/set, Domain/set, etc. method +// calls. Implementing a JMAP client is meaningful work and out of scope for +// Phase 4. Stubbed for now; the orchestration code records this as 'skipped'. +// +// TODO (follow-up): Build a minimal JMAP client that wraps Principal/set + the +// DKIM key generation method. See https://stalw.art/docs/api/management/overview +@Injectable() +export class StalwartClient { + private readonly logger = new Logger(StalwartClient.name) + private readonly base: string + + constructor(config: ConfigService) { + this.base = config.getOrThrow('STALWART_API_URL') + } + + async ensureDomain(domain: string, _description?: string): Promise<{ name: string }> { + this.logger.warn( + `Stalwart domain provisioning is stubbed — would create "${domain}" via JMAP at ${this.base}/jmap`, + ) + return { name: domain } + } + + async deleteDomain(domain: string): Promise { + this.logger.warn(`Stalwart domain delete is stubbed — would delete "${domain}"`) + } +} diff --git a/services/provisioning/src/schemas/tenant.schema.ts b/services/provisioning/src/schemas/tenant.schema.ts index 636322b..bf5d225 100644 --- a/services/provisioning/src/schemas/tenant.schema.ts +++ b/services/provisioning/src/schemas/tenant.schema.ts @@ -6,6 +6,10 @@ export type TenantDocument = HydratedDocument export type TenantStatus = 'pending' | 'active' | 'suspended' | 'deleted' export type TenantPlan = 'mvp' | 'pro' | 'enterprise' +// One field per external integration. 'pending' = not yet tried; 'ok' = synced; +// 'error' = last attempt failed (see provisioningErrors for detail). +export type IntegrationState = 'pending' | 'ok' | 'error' | 'skipped' + @Schema({ collection: 'tenants', timestamps: true }) export class Tenant { // URL-safe identifier, also used as Authentik group name. Lowercase, hyphenated. @@ -51,6 +55,30 @@ export class Tenant { country?: string contactEmail?: string } + + // Per-integration provisioning state. Each one is updated independently when its + // upstream API call succeeds or fails — orchestration is best-effort, not atomic. + @Prop({ + type: { + authentik: { type: String, enum: ['pending', 'ok', 'error', 'skipped'], default: 'pending' }, + stalwart: { type: String, enum: ['pending', 'ok', 'error', 'skipped'], default: 'pending' }, + ocis: { type: String, enum: ['pending', 'ok', 'error', 'skipped'], default: 'pending' }, + }, + default: () => ({ authentik: 'pending', stalwart: 'pending', ocis: 'pending' }), + }) + provisioningStatus!: { + authentik: IntegrationState + stalwart: IntegrationState + ocis: IntegrationState + } + + // Last error message per integration. Cleared when a subsequent attempt succeeds. + @Prop({ type: Object, default: {} }) + provisioningErrors!: { + authentik?: string + stalwart?: string + ocis?: string + } } export const TenantSchema = SchemaFactory.createForClass(Tenant) diff --git a/services/provisioning/src/tenants/provisioning.service.ts b/services/provisioning/src/tenants/provisioning.service.ts new file mode 100644 index 0000000..fa7526b --- /dev/null +++ b/services/provisioning/src/tenants/provisioning.service.ts @@ -0,0 +1,119 @@ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { AuthentikClient } from '../integrations/authentik.client.js' +import { OcisClient } from '../integrations/ocis.client.js' +import { StalwartClient } from '../integrations/stalwart.client.js' +import { + IntegrationState, + Tenant, + TenantDocument, +} from '../schemas/tenant.schema.js' + +// Orchestrates provisioning across Authentik / Stalwart / OCIS. Each step is +// independent — one failure doesn't roll back the others — and the per-step +// status is recorded on the tenant document so the operation is idempotent +// when retried. +@Injectable() +export class ProvisioningService { + private readonly logger = new Logger(ProvisioningService.name) + + constructor( + @InjectModel(Tenant.name) private readonly tenantModel: Model, + private readonly authentik: AuthentikClient, + private readonly stalwart: StalwartClient, + private readonly ocis: OcisClient, + ) {} + + // Runs all integrations and writes back per-step state. Returns the refreshed + // tenant doc so the controller can return it to the caller. + async reconcile(tenant: TenantDocument): Promise { + this.logger.log(`Reconciling tenant "${tenant.slug}"`) + + await this.runStep(tenant, 'authentik', async () => { + const group = await this.authentik.ensureGroup(tenant.slug, { tenantId: tenant.id }) + tenant.authentikGroupId = String(group.pk) + }) + + // Stalwart + OCIS are stubbed — the upstream call no-ops and we record the + // honest 'skipped' state by returning it from the step. + await this.runStep(tenant, 'stalwart', async () => { + const domain = this.domainFor(tenant.slug) + await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`) + tenant.stalwartDomain = domain + return 'skipped' + }) + + await this.runStep(tenant, 'ocis', async () => { + const space = await this.ocis.ensureSpace(tenant.slug) + tenant.ocisSpaceId = space.id + return 'skipped' + }) + + // If every required integration is either 'ok' or 'skipped' (not 'error' / + // 'pending'), activate the tenant. Skipped steps don't block activation — + // they just won't have their resources wired up yet. + const keys = ['authentik', 'stalwart', 'ocis'] as const + const allSettled = keys.every((k) => { + const s = tenant.provisioningStatus[k] + return s === 'ok' || s === 'skipped' + }) + if (allSettled && tenant.status === 'pending') { + tenant.status = 'active' + } + + // Mongoose doesn't auto-detect mutations inside nested subdocuments — flag + // these paths as modified so the save() actually persists our changes. + tenant.markModified('provisioningStatus') + tenant.markModified('provisioningErrors') + + await tenant.save() + return tenant + } + + // Step returns its terminal state explicitly. Returning void means "this step + // ran a real upstream call successfully" — that's mapped to 'ok'. Returning a + // specific state ('skipped', etc.) lets stub integrations be honest about + // not actually doing the work. + private async runStep( + tenant: TenantDocument, + key: 'authentik' | 'stalwart' | 'ocis', + work: () => Promise, + ): Promise { + try { + const result = await work() + tenant.provisioningStatus[key] = result ?? 'ok' + if (tenant.provisioningErrors[key]) delete tenant.provisioningErrors[key] + } catch (err) { + const msg = (err as Error).message + tenant.provisioningStatus[key] = 'error' + tenant.provisioningErrors[key] = msg + this.logger.error(`Tenant "${tenant.slug}" — ${key} step failed: ${msg}`) + } + } + + // Maps tenant slug → mail domain. Production should use a real registered + // domain (e.g. acme.dezky.com); locally we use the .local hierarchy. + private domainFor(slug: string): string { + return `${slug}.dezky.local` + } + + // Best-effort cleanup. Called when a tenant is hard-deleted (not soft-deleted). + async tearDown(tenant: TenantDocument): Promise { + if (tenant.authentikGroupId) { + await this.authentik.deleteGroup(tenant.authentikGroupId).catch((err) => { + this.logger.error(`Failed to delete Authentik group: ${(err as Error).message}`) + }) + } + if (tenant.stalwartDomain) { + await this.stalwart.deleteDomain(tenant.stalwartDomain).catch((err) => { + this.logger.error(`Failed to delete Stalwart domain: ${(err as Error).message}`) + }) + } + if (tenant.ocisSpaceId) { + await this.ocis.deleteSpace(tenant.ocisSpaceId).catch((err) => { + this.logger.error(`Failed to delete OCIS space: ${(err as Error).message}`) + }) + } + } +} diff --git a/services/provisioning/src/tenants/tenants.controller.ts b/services/provisioning/src/tenants/tenants.controller.ts index 96b084e..e0c73b2 100644 --- a/services/provisioning/src/tenants/tenants.controller.ts +++ b/services/provisioning/src/tenants/tenants.controller.ts @@ -75,4 +75,17 @@ export class TenantsController { } await this.tenants.softDelete(slug) } + + // Manually re-run provisioning. Useful when an integration was down at create + // time, or when external state drifted (someone deleted the Authentik group + // out of band). Idempotent — already-OK steps no-op. + @Post(':slug/reconcile') + async reconcile(@Param('slug') slug: string, @CurrentUser() 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 this.tenants.reconcile(slug) + } } diff --git a/services/provisioning/src/tenants/tenants.module.ts b/services/provisioning/src/tenants/tenants.module.ts index 5744133..0fdc866 100644 --- a/services/provisioning/src/tenants/tenants.module.ts +++ b/services/provisioning/src/tenants/tenants.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' import { AuthModule } from '../auth/auth.module.js' +import { IntegrationsModule } from '../integrations/integrations.module.js' import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' +import { ProvisioningService } from './provisioning.service.js' import { TenantsController } from './tenants.controller.js' import { TenantsService } from './tenants.service.js' @@ -9,9 +11,10 @@ import { TenantsService } from './tenants.service.js' imports: [ MongooseModule.forFeature([{ name: Tenant.name, schema: TenantSchema }]), AuthModule, + IntegrationsModule, ], controllers: [TenantsController], - providers: [TenantsService], + providers: [TenantsService, ProvisioningService], exports: [TenantsService], }) export class TenantsModule {} diff --git a/services/provisioning/src/tenants/tenants.service.ts b/services/provisioning/src/tenants/tenants.service.ts index 8760852..95f7399 100644 --- a/services/provisioning/src/tenants/tenants.service.ts +++ b/services/provisioning/src/tenants/tenants.service.ts @@ -4,15 +4,27 @@ import { Model, Types } from 'mongoose' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import type { CreateTenantDto } from './dto/create-tenant.dto.js' import type { UpdateTenantDto } from './dto/update-tenant.dto.js' +import { ProvisioningService } from './provisioning.service.js' @Injectable() export class TenantsService { - constructor(@InjectModel(Tenant.name) private readonly tenantModel: Model) {} + constructor( + @InjectModel(Tenant.name) private readonly tenantModel: Model, + private readonly provisioning: ProvisioningService, + ) {} async create(dto: CreateTenantDto): Promise { const exists = await this.tenantModel.exists({ slug: dto.slug }) if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`) - return this.tenantModel.create({ ...dto, status: 'pending' }) + const tenant = await this.tenantModel.create({ ...dto, status: 'pending' }) + // Provision external resources best-effort. Errors are recorded on the doc; + // the caller can re-POST or call /tenants/:slug/reconcile to retry. + return this.provisioning.reconcile(tenant) + } + + async reconcile(slug: string): Promise { + const tenant = await this.findOneBySlug(slug) + return this.provisioning.reconcile(tenant) } async findAll(): Promise {