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:
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@dezky/platform-api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Dezky platform API — tenants, partners, users, provisioning orchestration (NestJS)",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"test": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-fastify": "^10.4.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/mongoose": "^10.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"jose": "^5.9.0",
|
||||
"mongoose": "^8.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/testing": "^10.4.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0"
|
||||
}
|
||||
Generated
+3139
File diff suppressed because it is too large
Load Diff
@@ -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}"`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"declaration": true,
|
||||
"removeComments": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user