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.
This commit is contained in:
Ronni Baslund
2026-05-24 00:35:01 +02:00
parent fb3d7aa716
commit 22b2583f0b
49 changed files with 66 additions and 60 deletions
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from './auth/auth.module.js'
import { HealthController } from './health.controller.js'
import { SeedModule } from './seed/seed.module.js'
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
import { TenantsModule } from './tenants/tenants.module.js'
import { UsersModule } from './users/users.module.js'
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot(
process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky',
),
AuthModule,
TenantsModule,
UsersModule,
SubscriptionsModule,
SeedModule,
],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,25 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { AuthentikJwtPayload } from './jwt-payload.interface.js'
// Resolves the JWT-authenticated identity into a Mongo User document. The JWT is
// just identity (sub); all authorization state — tenant memberships, platformAdmin —
// reads from the DB. Keeps the token small regardless of how many tenants a user
// belongs to.
@Injectable()
export class ActorService {
constructor(@InjectModel(User.name) private readonly userModel: Model<UserDocument>) {}
async resolve(jwt: AuthentikJwtPayload): Promise<UserDocument> {
const user = await this.userModel.findOne({ authentikSubjectId: jwt.sub }).exec()
if (!user) {
// First login should always hit /users/me first, which upserts. If we get here
// some other endpoint was called before any /users/me — surface as 401 so the
// caller bootstraps the session.
throw new UnauthorizedException('User record not yet provisioned — call /users/me first')
}
return user
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { User, UserSchema } from '../schemas/user.schema.js'
import { ActorService } from './actor.service.js'
import { JwtAuthGuard } from './jwt-auth.guard.js'
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
providers: [JwtAuthGuard, ActorService],
exports: [JwtAuthGuard, ActorService],
})
export class AuthModule {}
@@ -0,0 +1,13 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common'
import type { AuthentikJwtPayload } from './jwt-payload.interface.js'
// Extract the verified JWT payload that JwtAuthGuard attached to the request.
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthentikJwtPayload => {
const req = ctx.switchToHttp().getRequest<{ user?: AuthentikJwtPayload }>()
if (!req.user) {
throw new Error('CurrentUser used without JwtAuthGuard — guard not applied to this route')
}
return req.user
},
)
@@ -0,0 +1,64 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { createRemoteJWKSet, jwtVerify } from 'jose'
import type { AuthentikJwtPayload } from './jwt-payload.interface.js'
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name)
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null
private readonly issuer: string
private readonly audience: string
private readonly jwksUri: string
constructor(config: ConfigService) {
this.issuer = config.getOrThrow<string>('AUTHENTIK_ISSUER')
this.audience = config.getOrThrow<string>('AUTHENTIK_AUDIENCE')
this.jwksUri = config.getOrThrow<string>('AUTHENTIK_JWKS_URI')
}
// Lazy init so misconfigured env doesn't crash the module bootstrap; the first
// request will surface a useful 401 instead of a startup failure.
private getJwks() {
if (!this.jwks) {
this.jwks = createRemoteJWKSet(new URL(this.jwksUri), {
cacheMaxAge: 10 * 60 * 1000,
cooldownDuration: 30 * 1000,
})
}
return this.jwks
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{
headers: Record<string, string | string[]>
user?: AuthentikJwtPayload
}>()
const authHeader = req.headers['authorization']
const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader
if (!headerValue?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or malformed Authorization header')
}
const token = headerValue.slice('Bearer '.length).trim()
try {
const { payload } = await jwtVerify(token, this.getJwks(), {
issuer: this.issuer,
audience: this.audience,
})
req.user = payload as unknown as AuthentikJwtPayload
return true
} catch (err) {
this.logger.warn(`JWT verification failed: ${(err as Error).message}`)
throw new UnauthorizedException('Invalid access token')
}
}
}
@@ -0,0 +1,20 @@
// Authentik OIDC access token claims we care about.
// Full set of claims is larger — only typing what we read.
export interface AuthentikJwtPayload {
iss: string
sub: string
aud: string | string[]
exp: number
iat: number
jti?: string
// User info (populated when 'profile' + 'email' scopes are requested)
email?: string
email_verified?: boolean
name?: string
given_name?: string
preferred_username?: string
// Authentik groups the user belongs to — we model tenants as groups.
groups?: string[]
}
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common'
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'dezky-platform-api',
timestamp: new Date().toISOString(),
}
}
}
@@ -0,0 +1,72 @@
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}`)
}
}
@@ -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 {}
@@ -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<string>('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<void> {
this.logger.warn(`OCIS space delete is stubbed — would delete ${spaceId} at ${this.base}`)
}
}
@@ -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<string>('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<void> {
this.logger.warn(`Stalwart domain delete is stubbed — would delete "${domain}"`)
}
}
+39
View File
@@ -0,0 +1,39 @@
// Dezky platform API — Entry point.
// Owns the platform control plane: tenants, partners, users, subscriptions,
// plus the provisioning orchestration (Authentik / Stalwart / OCIS).
import { ValidationPipe } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { AppModule } from './app.module.js'
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
)
app.enableCors({
origin: /^https:\/\/([a-z0-9-]+\.)?dezky\.local$/,
credentials: true,
})
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
)
const port = Number(process.env.PORT ?? 3001)
await app.listen(port, '0.0.0.0')
console.log(`platform-api listening on http://0.0.0.0:${port}`)
}
bootstrap().catch((err) => {
console.error('Failed to start platform-api', err)
process.exit(1)
})
@@ -0,0 +1,46 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type SubscriptionDocument = HydratedDocument<Subscription>
export type SubscriptionStatus =
| 'trialing'
| 'active'
| 'past_due'
| 'canceled'
| 'incomplete'
| 'incomplete_expired'
@Schema({ collection: 'subscriptions', timestamps: true })
export class Subscription {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, unique: true, index: true })
tenantId!: Types.ObjectId
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan!: 'mvp' | 'pro' | 'enterprise'
@Prop({
enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired'],
default: 'trialing',
index: true,
})
status!: SubscriptionStatus
// Stripe references — populated when Phase 4 wires the billing flow. Not used yet.
@Prop({ index: true, sparse: true })
stripeCustomerId?: string
@Prop({ index: true, sparse: true })
stripeSubscriptionId?: string
@Prop()
trialEndsAt?: Date
@Prop()
currentPeriodEnd?: Date
@Prop()
canceledAt?: Date
}
export const SubscriptionSchema = SchemaFactory.createForClass(Subscription)
@@ -0,0 +1,84 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument } from 'mongoose'
export type TenantDocument = HydratedDocument<Tenant>
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.
@Prop({ required: true, unique: true, index: true, lowercase: true, trim: true })
slug!: string
@Prop({ required: true, trim: true })
name!: string
@Prop({ enum: ['pending', 'active', 'suspended', 'deleted'], default: 'pending', index: true })
status!: TenantStatus
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan!: TenantPlan
// Custom domains attached to this tenant. First entry is the primary host.
@Prop({ type: [String], default: [] })
domains!: string[]
// External system handles — filled in by the provisioning worker (Phase 4)
@Prop({ index: true, sparse: true })
authentikGroupId?: string
@Prop({ sparse: true })
ocisSpaceId?: string
@Prop({ sparse: true })
stalwartDomain?: string
// Free-form billing context. Stripe IDs live on Subscription, not here.
@Prop({
type: {
companyName: String,
vatId: String,
country: String,
contactEmail: String,
},
default: {},
})
billingInfo!: {
companyName?: string
vatId?: string
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)
@@ -0,0 +1,41 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type UserDocument = HydratedDocument<User>
export type UserRole = 'owner' | 'admin' | 'member'
@Schema({ collection: 'users', timestamps: true })
export class User {
// Authentik subject claim — stable identity across login sessions.
@Prop({ required: true, unique: true, index: true })
authentikSubjectId!: string
// Tenants this user belongs to. A user can belong to multiple tenants (e.g. partner staff).
@Prop({ type: [Types.ObjectId], ref: 'Tenant', default: [], index: true })
tenantIds!: Types.ObjectId[]
@Prop({ required: true, lowercase: true, trim: true, index: true })
email!: string
@Prop({ required: true, trim: true })
name!: string
// Role is per-user globally for the MVP. Refine to per-tenant later if needed.
@Prop({ enum: ['owner', 'admin', 'member'], default: 'member' })
role!: UserRole
@Prop({ default: true })
active!: boolean
// Cross-tenant admin flag — independent of per-tenant role above.
// Set at upsert time based on Authentik group membership; once set, the DB is the
// source of truth and a future revocation requires explicit setUserAdmin().
@Prop({ default: false, index: true })
platformAdmin!: boolean
@Prop()
lastLoginAt?: Date
}
export const UserSchema = SchemaFactory.createForClass(User)
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { SeedService } from './seed.service.js'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
]),
],
providers: [SeedService],
})
export class SeedModule {}
@@ -0,0 +1,64 @@
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
// Idempotent seed: a default 'dezky' tenant + the akadmin user, so the portal can
// query non-empty results immediately. Real user records are bootstrapped via
// UsersController.me() on each user's first authenticated request.
@Injectable()
export class SeedService implements OnApplicationBootstrap {
private readonly logger = new Logger(SeedService.name)
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
private readonly config: ConfigService,
) {}
async onApplicationBootstrap(): Promise<void> {
if (this.config.get('SEED_ENABLED') === 'false') {
this.logger.log('SEED_ENABLED=false — skipping seed')
return
}
const tenant = await this.tenantModel
.findOneAndUpdate(
{ slug: 'dezky' },
{
$setOnInsert: {
slug: 'dezky',
name: 'Dezky',
status: 'active',
plan: 'enterprise',
domains: ['dezky.local'],
billingInfo: { companyName: 'Dezky', country: 'DK' },
},
},
{ new: true, upsert: true },
)
.exec()
this.logger.log(`Tenant ready: ${tenant.slug} (${tenant._id})`)
await this.subModel
.findOneAndUpdate(
{ tenantId: tenant._id },
{
$setOnInsert: {
tenantId: tenant._id,
plan: 'enterprise',
status: 'active',
},
},
{ upsert: true },
)
.exec()
this.logger.log(`Subscription ready for ${tenant.slug}`)
// No user seeded here — UsersController.me() upserts akadmin on first call.
}
}
@@ -0,0 +1,15 @@
import { IsEnum, IsOptional, IsString } from 'class-validator'
export class CreateSubscriptionDto {
@IsString()
tenantSlug!: string
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
@IsOptional() @IsEnum(['trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired'])
status?: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'incomplete_expired'
@IsOptional() @IsString() stripeCustomerId?: string
@IsOptional() @IsString() stripeSubscriptionId?: string
}
@@ -0,0 +1,15 @@
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'
export class UpdateSubscriptionDto {
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
@IsOptional() @IsEnum(['trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired'])
status?: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'incomplete_expired'
@IsOptional() @IsString() stripeCustomerId?: string
@IsOptional() @IsString() stripeSubscriptionId?: string
@IsOptional() @IsDateString() trialEndsAt?: string
@IsOptional() @IsDateString() currentPeriodEnd?: string
@IsOptional() @IsDateString() canceledAt?: string
}
@@ -0,0 +1,67 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.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 { TenantsService } from '../tenants/tenants.service.js'
import { CreateSubscriptionDto } from './dto/create-subscription.dto.js'
import { UpdateSubscriptionDto } from './dto/update-subscription.dto.js'
import { SubscriptionsService } from './subscriptions.service.js'
@Controller('subscriptions')
@UseGuards(JwtAuthGuard)
export class SubscriptionsController {
constructor(
private readonly subs: SubscriptionsService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
@Post()
async create(@Body() dto: CreateSubscriptionDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can create subscriptions')
}
return this.subs.create(dto)
}
@Get()
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (actor.platformAdmin) return this.subs.findAll()
return this.subs.findAllForTenants(actor.tenantIds)
}
@Get(':slug')
async findOne(@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.subs.findByTenantSlug(slug)
}
@Patch(':slug')
async update(
@Param('slug') slug: string,
@Body() dto: UpdateSubscriptionDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can update subscriptions')
}
return this.subs.update(slug, dto)
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from '../auth/auth.module.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { SubscriptionsController } from './subscriptions.controller.js'
import { SubscriptionsService } from './subscriptions.service.js'
@Module({
imports: [
MongooseModule.forFeature([{ name: Subscription.name, schema: SubscriptionSchema }]),
AuthModule,
TenantsModule,
],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}
@@ -0,0 +1,53 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { TenantsService } from '../tenants/tenants.service.js'
import type { CreateSubscriptionDto } from './dto/create-subscription.dto.js'
import type { UpdateSubscriptionDto } from './dto/update-subscription.dto.js'
@Injectable()
export class SubscriptionsService {
constructor(
@InjectModel(Subscription.name) private readonly model: Model<SubscriptionDocument>,
private readonly tenants: TenantsService,
) {}
async create(dto: CreateSubscriptionDto): Promise<SubscriptionDocument> {
const tenant = await this.tenants.findOneBySlug(dto.tenantSlug)
const existing = await this.model.exists({ tenantId: tenant._id })
if (existing) throw new ConflictException(`Tenant "${dto.tenantSlug}" already has a subscription`)
return this.model.create({
tenantId: tenant._id,
plan: dto.plan ?? tenant.plan,
status: dto.status ?? 'trialing',
stripeCustomerId: dto.stripeCustomerId,
stripeSubscriptionId: dto.stripeSubscriptionId,
})
}
async findAllForTenants(tenantIds: Types.ObjectId[]): Promise<SubscriptionDocument[]> {
return this.model.find({ tenantId: { $in: tenantIds } }).sort({ createdAt: -1 }).exec()
}
async findAll(): Promise<SubscriptionDocument[]> {
return this.model.find().sort({ createdAt: -1 }).exec()
}
async findByTenantSlug(slug: string): Promise<SubscriptionDocument> {
const tenant = await this.tenants.findOneBySlug(slug)
const sub = await this.model.findOne({ tenantId: tenant._id }).exec()
if (!sub) throw new NotFoundException(`No subscription for tenant "${slug}"`)
return sub
}
async update(slug: string, dto: UpdateSubscriptionDto): Promise<SubscriptionDocument> {
const tenant = await this.tenants.findOneBySlug(slug)
const sub = await this.model
.findOneAndUpdate({ tenantId: tenant._id }, dto, { new: true, runValidators: true })
.exec()
if (!sub) throw new NotFoundException(`No subscription for tenant "${slug}"`)
return sub
}
}
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsEnum,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator'
class BillingInfoDto {
@IsOptional() @IsString() @MaxLength(200) companyName?: string
@IsOptional() @IsString() @MaxLength(40) vatId?: string
@IsOptional() @IsString() @MaxLength(2) country?: string
@IsOptional() @IsString() @MaxLength(200) contactEmail?: string
}
export class CreateTenantDto {
@IsString()
@Matches(/^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/, {
message: 'slug must be lowercase, 2-40 chars, hyphen-separated',
})
slug!: string
@IsString() @MinLength(2) @MaxLength(120)
name!: string
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
@IsOptional() @IsArray() @IsString({ each: true })
domains?: string[]
@IsOptional() @ValidateNested() @Type(() => BillingInfoDto)
billingInfo?: BillingInfoDto
}
@@ -0,0 +1,15 @@
import { IsArray, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
export class UpdateTenantDto {
@IsOptional() @IsString() @MinLength(2) @MaxLength(120)
name?: string
@IsOptional() @IsEnum(['pending', 'active', 'suspended', 'deleted'])
status?: 'pending' | 'active' | 'suspended' | 'deleted'
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
@IsOptional() @IsArray() @IsString({ each: true })
domains?: string[]
}
@@ -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<TenantDocument>,
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<TenantDocument> {
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<IntegrationState | void>,
): Promise<void> {
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<void> {
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}`)
})
}
}
}
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.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 { CreateTenantDto } from './dto/create-tenant.dto.js'
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { TenantsService } from './tenants.service.js'
@Controller('tenants')
@UseGuards(JwtAuthGuard)
export class TenantsController {
constructor(
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
@Post()
async create(@Body() dto: CreateTenantDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can create tenants')
}
return this.tenants.create(dto)
}
@Get()
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (actor.platformAdmin) return this.tenants.findAll()
return this.tenants.findByIds(actor.tenantIds)
}
@Get(':slug')
async findOne(@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 tenant
}
@Patch(':slug')
async update(
@Param('slug') slug: string,
@Body() dto: UpdateTenantDto,
@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.update(slug, dto)
}
@Delete(':slug')
@HttpCode(204)
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can delete tenants')
}
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)
}
}
@@ -0,0 +1,20 @@
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'
@Module({
imports: [
MongooseModule.forFeature([{ name: Tenant.name, schema: TenantSchema }]),
AuthModule,
IntegrationsModule,
],
controllers: [TenantsController],
providers: [TenantsService, ProvisioningService],
exports: [TenantsService],
})
export class TenantsModule {}
@@ -0,0 +1,68 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
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<TenantDocument>,
private readonly provisioning: ProvisioningService,
) {}
async create(dto: CreateTenantDto): Promise<TenantDocument> {
const exists = await this.tenantModel.exists({ slug: dto.slug })
if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)
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<TenantDocument> {
const tenant = await this.findOneBySlug(slug)
return this.provisioning.reconcile(tenant)
}
async findAll(): Promise<TenantDocument[]> {
return this.tenantModel.find().sort({ createdAt: -1 }).exec()
}
async findByIds(ids: Types.ObjectId[]): Promise<TenantDocument[]> {
if (ids.length === 0) return []
return this.tenantModel
.find({ _id: { $in: ids } })
.sort({ createdAt: -1 })
.exec()
}
async findOneBySlug(slug: string): Promise<TenantDocument> {
const tenant = await this.tenantModel.findOne({ slug }).exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
return tenant
}
async findOneById(id: string | Types.ObjectId): Promise<TenantDocument> {
const tenant = await this.tenantModel.findById(id).exec()
if (!tenant) throw new NotFoundException(`Tenant ${id} not found`)
return tenant
}
async update(slug: string, dto: UpdateTenantDto): Promise<TenantDocument> {
const tenant = await this.tenantModel
.findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
.exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
return tenant
}
async softDelete(slug: string): Promise<void> {
const result = await this.tenantModel
.updateOne({ slug }, { status: 'deleted' })
.exec()
if (result.matchedCount === 0) throw new NotFoundException(`Tenant "${slug}" not found`)
}
}
@@ -0,0 +1,18 @@
import { IsArray, IsEmail, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
export class CreateUserDto {
@IsString() @MinLength(1)
authentikSubjectId!: string
@IsEmail()
email!: string
@IsString() @MinLength(1) @MaxLength(200)
name!: string
@IsOptional() @IsArray() @IsString({ each: true })
tenantSlugs?: string[]
@IsOptional() @IsEnum(['owner', 'admin', 'member'])
role?: 'owner' | 'admin' | 'member'
}
@@ -0,0 +1,15 @@
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
export class UpdateUserDto {
@IsOptional() @IsString() @MinLength(1) @MaxLength(200)
name?: string
@IsOptional() @IsArray() @IsString({ each: true })
tenantSlugs?: string[]
@IsOptional() @IsEnum(['owner', 'admin', 'member'])
role?: 'owner' | 'admin' | 'member'
@IsOptional() @IsBoolean()
active?: boolean
}
@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ActorService } from '../auth/actor.service.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 { CreateUserDto } from './dto/create-user.dto.js'
import { UpdateUserDto } from './dto/update-user.dto.js'
import { UsersService } from './users.service.js'
// Authentik group name that grants platform-wide admin in Dezky. This is the ONLY
// place we look at the JWT's groups claim outside of /users/me — and even here it's
// just for the bootstrap: the resulting platformAdmin boolean on the User doc is
// what every other endpoint reads.
const ADMIN_BOOTSTRAP_GROUP_DEFAULT = 'dezky-platform-admins'
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
private readonly adminBootstrapGroup: string
constructor(
private readonly users: UsersService,
private readonly actor: ActorService,
config: ConfigService,
) {
this.adminBootstrapGroup =
config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? ADMIN_BOOTSTRAP_GROUP_DEFAULT
}
// The signed-in user's own profile — bootstraps the user record on first call,
// and syncs name/email/tenants/platformAdmin from the JWT on every subsequent call.
@Get('me')
async me(@CurrentUser() jwt: AuthentikJwtPayload) {
return this.users.upsertFromAuthentik({
subject: jwt.sub,
email: jwt.email ?? jwt.preferred_username ?? jwt.sub,
name: jwt.name ?? jwt.preferred_username ?? jwt.email ?? jwt.sub,
tenantSlugs: jwt.groups ?? [],
platformAdmin: jwt.groups?.includes(this.adminBootstrapGroup) ?? false,
})
}
@Post()
async create(@Body() dto: CreateUserDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can create users directly')
}
return this.users.create(dto)
}
@Get()
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (actor.platformAdmin) return this.users.findAll()
return this.users.findAllForTenants(actor.tenantIds)
}
@Get(':subject')
async findOne(@Param('subject') subject: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (subject !== jwt.sub && !actor.platformAdmin) {
throw new ForbiddenException('Cannot read other users')
}
return this.users.findOneBySubject(subject)
}
@Patch(':subject')
async update(
@Param('subject') subject: string,
@Body() dto: UpdateUserDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can update users')
}
return this.users.update(subject, dto)
}
@Delete(':subject')
@HttpCode(204)
async deactivate(@Param('subject') subject: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can deactivate users')
}
await this.users.deactivate(subject)
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from '../auth/auth.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'
import { UsersController } from './users.controller.js'
import { UsersService } from './users.service.js'
@Module({
imports: [
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Tenant.name, schema: TenantSchema },
]),
AuthModule,
TenantsModule,
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,98 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
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 { UpdateUserDto } from './dto/update-user.dto.js'
@Injectable()
export class UsersService {
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
) {}
async create(dto: CreateUserDto): Promise<UserDocument> {
const exists = await this.userModel.exists({ authentikSubjectId: dto.authentikSubjectId })
if (exists) throw new ConflictException(`User ${dto.authentikSubjectId} already exists`)
const tenantIds = await this.resolveTenantIds(dto.tenantSlugs ?? [])
return this.userModel.create({
authentikSubjectId: dto.authentikSubjectId,
email: dto.email,
name: dto.name,
role: dto.role ?? 'member',
tenantIds,
})
}
async findAllForTenants(tenantIds: Types.ObjectId[]): Promise<UserDocument[]> {
return this.userModel.find({ tenantIds: { $in: tenantIds } }).sort({ createdAt: -1 }).exec()
}
async findAll(): Promise<UserDocument[]> {
return this.userModel.find().sort({ createdAt: -1 }).exec()
}
async findOneBySubject(subject: string): Promise<UserDocument> {
const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
if (!user) throw new NotFoundException(`User ${subject} not found`)
return user
}
async update(subject: string, dto: UpdateUserDto): Promise<UserDocument> {
const patch: Record<string, unknown> = { ...dto }
if (dto.tenantSlugs !== undefined) {
patch.tenantIds = await this.resolveTenantIds(dto.tenantSlugs)
delete patch.tenantSlugs
}
const user = await this.userModel
.findOneAndUpdate({ authentikSubjectId: subject }, patch, { new: true, runValidators: true })
.exec()
if (!user) throw new NotFoundException(`User ${subject} not found`)
return user
}
async deactivate(subject: string): Promise<void> {
const result = await this.userModel
.updateOne({ authentikSubjectId: subject }, { active: false })
.exec()
if (result.matchedCount === 0) throw new NotFoundException(`User ${subject} not found`)
}
// Called on every authenticated request from /users/me. The JWT's groups claim
// is treated as a hint for first-time membership sync — the DB is the source of
// truth for all subsequent authorization decisions.
async upsertFromAuthentik(payload: {
subject: string
email: string
name: string
tenantSlugs: string[]
platformAdmin: boolean
}): Promise<UserDocument> {
const tenantIds = await this.resolveTenantIds(payload.tenantSlugs)
return this.userModel
.findOneAndUpdate(
{ authentikSubjectId: payload.subject },
{
$set: {
email: payload.email,
name: payload.name,
tenantIds,
platformAdmin: payload.platformAdmin,
lastLoginAt: new Date(),
},
$setOnInsert: { role: 'member', active: true },
},
{ new: true, upsert: true, runValidators: true },
)
.exec()
}
private async resolveTenantIds(slugs: string[]): Promise<Types.ObjectId[]> {
if (slugs.length === 0) return []
const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec()
return tenants.map((t) => t._id)
}
}