From 2db41fec5ecd9980294f76d415355a3ba46aa3bf Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 07:08:59 +0200 Subject: [PATCH] feat(platform-api): multi-audience JWT + Partner CRUD + tenant lifecycle (O.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JwtAuthGuard now accepts a comma-separated AUTHENTIK_AUDIENCE ('dezky-portal,dezky-operator'). jose.jwtVerify takes an array and succeeds on any match — both customer-portal and operator-portal tokens validate against this service. Per-endpoint guards restrict further. New OperatorGuard enforces operator-only mutations: 1. JWT audience claim includes 'dezky-operator' (proof from the token alone that this is a privileged session) 2. ActorService-resolved User has platformAdmin=true (DB check so revocation works without waiting for the token to expire) Both required; either alone is insufficient. Partner module: - Partner schema: slug, name, domain, status, marginPct, contactInfo, billingInfo. marginPct is one number per partner (decided in grilling) - CRUD endpoints under @UseGuards(JwtAuthGuard, OperatorGuard) — every partner mutation requires operator scope - GET /partners returns each row with a computed customers count from aggregating Tenant.partnerId. MRR aggregation deferred until Subscription gains a price column - GET /partners/:slug/tenants for the partner detail view - DELETE soft-terminates (status='terminated') — never hard-delete because tenants may still reference the partner Tenant changes: - partnerId?: Types.ObjectId (ref Partner, indexed sparse) added to Tenant schema - UpdateTenantDto accepts partnerId so PATCH can attach/detach - POST /tenants/:slug/suspend and /resume — operator-only via OperatorGuard. PATCH already covers plan/domains/partnerId changes Smoke test: customer-portal session sends POST /api/partners through the portal proxy → 403 "This endpoint requires an operator-scoped token". The positive test (operator-token → 200) waits for O.3 when there's an operator app to mint the right token. apps/portal/server/api/partners/index.post.ts is a temporary verification proxy — delete once the operator portal exists. --- apps/portal/server/api/partners/index.post.ts | 27 ++++++ docs/OPERATOR-PLAN.md | 39 +++++---- .../docker-compose/docker-compose.yml | 5 +- services/platform-api/src/app.module.ts | 2 + services/platform-api/src/auth/auth.module.ts | 5 +- .../platform-api/src/auth/jwt-auth.guard.ts | 13 ++- .../platform-api/src/auth/operator.guard.ts | 41 +++++++++ .../src/partners/dto/create-partner.dto.ts | 53 +++++++++++ .../src/partners/dto/update-partner.dto.ts | 46 ++++++++++ .../src/partners/partners.controller.ts | 60 +++++++++++++ .../src/partners/partners.module.ts | 21 +++++ .../src/partners/partners.service.ts | 87 +++++++++++++++++++ .../src/schemas/partner.schema.ts | 68 +++++++++++++++ .../platform-api/src/schemas/tenant.schema.ts | 2 +- .../src/tenants/dto/update-tenant.dto.ts | 6 +- .../src/tenants/tenants.controller.ts | 15 ++++ .../src/tenants/tenants.service.ts | 8 ++ 17 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 apps/portal/server/api/partners/index.post.ts create mode 100644 services/platform-api/src/auth/operator.guard.ts create mode 100644 services/platform-api/src/partners/dto/create-partner.dto.ts create mode 100644 services/platform-api/src/partners/dto/update-partner.dto.ts create mode 100644 services/platform-api/src/partners/partners.controller.ts create mode 100644 services/platform-api/src/partners/partners.module.ts create mode 100644 services/platform-api/src/partners/partners.service.ts create mode 100644 services/platform-api/src/schemas/partner.schema.ts diff --git a/apps/portal/server/api/partners/index.post.ts b/apps/portal/server/api/partners/index.post.ts new file mode 100644 index 0000000..4a735f5 --- /dev/null +++ b/apps/portal/server/api/partners/index.post.ts @@ -0,0 +1,27 @@ +// Temporary proxy used only to verify O.2's audience-gating from the browser. +// This forwards the customer-portal session's access token — which has aud +// 'dezky-portal' — to platform-api's POST /partners. We expect 403 because +// that endpoint requires aud='dezky-operator'. Delete this file once the +// operator portal exists and the real (positive) test runs from there. + +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + const body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + try { + return await $fetch(`${base}/partners`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) + } catch (err: unknown) { + const e = err as { statusCode?: number; data?: unknown } + throw createError({ statusCode: e.statusCode ?? 500, data: e.data }) + } +}) diff --git a/docs/OPERATOR-PLAN.md b/docs/OPERATOR-PLAN.md index bfc6cce..c7cf46e 100644 --- a/docs/OPERATOR-PLAN.md +++ b/docs/OPERATOR-PLAN.md @@ -289,23 +289,30 @@ done in order — earlier ones unblock later ones. docs is gone in 2025.10. Use `policies/bindings/` with a direct `group` reference instead -### O.2 · platform-api — multi-audience + Partner CRUD +### O.2 · platform-api — multi-audience + Partner CRUD ✓ -- [ ] `JwtAuthGuard`: accept audience list `['dezky-portal', 'dezky-operator']` -- [ ] New decorator/guard `@RequiresOperatorAudience()` enforcing - `aud === 'dezky-operator' && actor.platformAdmin` -- [ ] `schemas/partner.schema.ts` — Partner model (slug, name, domain, - status, marginPct, contactInfo, billingInfo) -- [ ] `partners/` module: controller + service + DTOs (create / read / - update / soft-delete) -- [ ] Add `partnerId?: Types.ObjectId` (ref Partner, index) to Tenant schema -- [ ] Aggregations: `Partner.customers` (count) and `Partner.mrr` (sum) - computed at query time -- [ ] Tenant lifecycle endpoints: `POST /tenants/:slug/suspend`, - `POST /tenants/:slug/resume`, plan/seat-cap change via existing PATCH -- [ ] All operator-only mutations gated by `@RequiresOperatorAudience()` -- [ ] Smoke test: `curl` create-partner with a `dezky-operator` token works, - same call with a `dezky-portal` token gets 403 +- [x] `JwtAuthGuard`: accepts comma-separated `AUTHENTIK_AUDIENCE` + (`dezky-portal,dezky-operator`). Both audiences validate; per-endpoint + guards further restrict +- [x] `OperatorGuard` (not a decorator — a regular `CanActivate` guard) + enforcing `aud includes 'dezky-operator' && actor.platformAdmin`. + Applied via `@UseGuards(JwtAuthGuard, OperatorGuard)` +- [x] `schemas/partner.schema.ts` — Partner model +- [x] `partners/` module: controller + service + DTOs (create / read / + update / soft-terminate / list tenants under partner) +- [x] `partnerId?: Types.ObjectId` added to Tenant schema (indexed, sparse). + `UpdateTenantDto` accepts `partnerId` to attach/detach +- [x] `Partner.customers` aggregated at query time (count of Tenants by + partnerId). MRR aggregation **deferred** — Tenant has no monthly + amount yet and Subscription lacks a price column. Will land when + Subscription gains pricing +- [x] Tenant lifecycle endpoints: `POST /tenants/:slug/suspend`, + `POST /tenants/:slug/resume` (operator-only). PATCH already accepts + plan/domains/partnerId changes +- [x] Smoke test: customer-portal token → `POST /partners` returns 403 + "This endpoint requires an operator-scoped token" ✓. Positive test + (operator token → 200) deferred until O.3 when the operator app + exists to mint that token ### O.3 · Scaffold `apps/operator/` diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml index 4bf49a9..b00621b 100644 --- a/infrastructure/docker-compose/docker-compose.yml +++ b/infrastructure/docker-compose/docker-compose.yml @@ -411,7 +411,10 @@ services: OCIS_API_URL: https://files.dezky.local # JWT validation against Authentik for portal-issued access tokens AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/ - AUTHENTIK_AUDIENCE: dezky-portal + # Comma-separated list of accepted JWT audiences. Tokens issued for either + # the customer portal or the operator portal are valid against this service; + # per-endpoint guards further restrict operator-only mutations. + AUTHENTIK_AUDIENCE: dezky-portal,dezky-operator AUTHENTIK_JWKS_URI: https://auth.dezky.local/application/o/dezky-portal/jwks/ # Trust mkcert root CA for Node fetch (dev only) NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem diff --git a/services/platform-api/src/app.module.ts b/services/platform-api/src/app.module.ts index 6b4a8e7..63170b0 100644 --- a/services/platform-api/src/app.module.ts +++ b/services/platform-api/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config' import { MongooseModule } from '@nestjs/mongoose' import { AuthModule } from './auth/auth.module.js' import { HealthController } from './health.controller.js' +import { PartnersModule } from './partners/partners.module.js' import { SeedModule } from './seed/seed.module.js' import { SubscriptionsModule } from './subscriptions/subscriptions.module.js' import { TenantsModule } from './tenants/tenants.module.js' @@ -16,6 +17,7 @@ import { UsersModule } from './users/users.module.js' ), AuthModule, TenantsModule, + PartnersModule, UsersModule, SubscriptionsModule, SeedModule, diff --git a/services/platform-api/src/auth/auth.module.ts b/services/platform-api/src/auth/auth.module.ts index 05c9f36..5401508 100644 --- a/services/platform-api/src/auth/auth.module.ts +++ b/services/platform-api/src/auth/auth.module.ts @@ -3,10 +3,11 @@ 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' +import { OperatorGuard } from './operator.guard.js' @Module({ imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])], - providers: [JwtAuthGuard, ActorService], - exports: [JwtAuthGuard, ActorService], + providers: [JwtAuthGuard, OperatorGuard, ActorService], + exports: [JwtAuthGuard, OperatorGuard, ActorService], }) export class AuthModule {} diff --git a/services/platform-api/src/auth/jwt-auth.guard.ts b/services/platform-api/src/auth/jwt-auth.guard.ts index 7cdee74..b6894e9 100644 --- a/services/platform-api/src/auth/jwt-auth.guard.ts +++ b/services/platform-api/src/auth/jwt-auth.guard.ts @@ -14,12 +14,19 @@ export class JwtAuthGuard implements CanActivate { private readonly logger = new Logger(JwtAuthGuard.name) private jwks: ReturnType | null = null private readonly issuer: string - private readonly audience: string + // AUTHENTIK_AUDIENCE is comma-separated to accept tokens from multiple + // OAuth clients (customer portal + operator portal + future surfaces). + // jose.jwtVerify with an array succeeds if the token's aud matches any. + private readonly audiences: string[] private readonly jwksUri: string constructor(config: ConfigService) { this.issuer = config.getOrThrow('AUTHENTIK_ISSUER') - this.audience = config.getOrThrow('AUTHENTIK_AUDIENCE') + this.audiences = config + .getOrThrow('AUTHENTIK_AUDIENCE') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) this.jwksUri = config.getOrThrow('AUTHENTIK_JWKS_URI') } @@ -52,7 +59,7 @@ export class JwtAuthGuard implements CanActivate { try { const { payload } = await jwtVerify(token, this.getJwks(), { issuer: this.issuer, - audience: this.audience, + audience: this.audiences, }) req.user = payload as unknown as AuthentikJwtPayload return true diff --git a/services/platform-api/src/auth/operator.guard.ts b/services/platform-api/src/auth/operator.guard.ts new file mode 100644 index 0000000..c97d0c7 --- /dev/null +++ b/services/platform-api/src/auth/operator.guard.ts @@ -0,0 +1,41 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common' +import { ActorService } from './actor.service.js' +import type { AuthentikJwtPayload } from './jwt-payload.interface.js' + +// Applied AFTER JwtAuthGuard. Enforces that the request is coming from an +// operator session — both proofs required: +// 1. Token audience is 'dezky-operator' (the operator OAuth client) +// 2. The DB-resolved User has platformAdmin=true +// +// Either proof alone is insufficient. The token claim makes "is this a +// privileged session" derivable from the JWT alone; the DB check ensures +// revocation works without waiting for the token to expire. +@Injectable() +export class OperatorGuard implements CanActivate { + constructor(private readonly actor: ActorService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest<{ user?: AuthentikJwtPayload }>() + const jwt = req.user + if (!jwt) { + throw new ForbiddenException('Authentication required (JwtAuthGuard must run first)') + } + + const aud = Array.isArray(jwt.aud) ? jwt.aud : [jwt.aud] + if (!aud.includes('dezky-operator')) { + throw new ForbiddenException('This endpoint requires an operator-scoped token') + } + + const actor = await this.actor.resolve(jwt) + if (!actor.platformAdmin) { + throw new ForbiddenException('platformAdmin role required') + } + + return true + } +} diff --git a/services/platform-api/src/partners/dto/create-partner.dto.ts b/services/platform-api/src/partners/dto/create-partner.dto.ts new file mode 100644 index 0000000..96dd019 --- /dev/null +++ b/services/platform-api/src/partners/dto/create-partner.dto.ts @@ -0,0 +1,53 @@ +import { Type } from 'class-transformer' +import { + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator' + +class ContactInfoDto { + @IsOptional() @IsString() @MaxLength(200) primaryName?: string + @IsOptional() @IsEmail() primaryEmail?: string + @IsOptional() @IsEmail() billingEmail?: string +} + +class BillingInfoDto { + @IsOptional() @IsString() @MaxLength(200) companyName?: string + @IsOptional() @IsString() @MaxLength(40) vatId?: string + @IsOptional() @IsString() @MaxLength(2) country?: string + @IsOptional() @IsEmail() contactEmail?: string +} + +export class CreatePartnerDto { + @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 + + @IsString() @MinLength(3) @MaxLength(120) + domain!: string + + @IsOptional() @IsEnum(['active', 'in-negotiation', 'paused', 'terminated']) + status?: 'active' | 'in-negotiation' | 'paused' | 'terminated' + + @IsOptional() @IsInt() @Min(0) @Max(100) + marginPct?: number + + @IsOptional() @ValidateNested() @Type(() => ContactInfoDto) + contactInfo?: ContactInfoDto + + @IsOptional() @ValidateNested() @Type(() => BillingInfoDto) + billingInfo?: BillingInfoDto +} diff --git a/services/platform-api/src/partners/dto/update-partner.dto.ts b/services/platform-api/src/partners/dto/update-partner.dto.ts new file mode 100644 index 0000000..99a9a1c --- /dev/null +++ b/services/platform-api/src/partners/dto/update-partner.dto.ts @@ -0,0 +1,46 @@ +import { Type } from 'class-transformer' +import { + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator' + +class ContactInfoDto { + @IsOptional() @IsString() @MaxLength(200) primaryName?: string + @IsOptional() @IsEmail() primaryEmail?: string + @IsOptional() @IsEmail() billingEmail?: string +} + +class BillingInfoDto { + @IsOptional() @IsString() @MaxLength(200) companyName?: string + @IsOptional() @IsString() @MaxLength(40) vatId?: string + @IsOptional() @IsString() @MaxLength(2) country?: string + @IsOptional() @IsEmail() contactEmail?: string +} + +export class UpdatePartnerDto { + @IsOptional() @IsString() @MinLength(2) @MaxLength(120) + name?: string + + @IsOptional() @IsString() @MinLength(3) @MaxLength(120) + domain?: string + + @IsOptional() @IsEnum(['active', 'in-negotiation', 'paused', 'terminated']) + status?: 'active' | 'in-negotiation' | 'paused' | 'terminated' + + @IsOptional() @IsInt() @Min(0) @Max(100) + marginPct?: number + + @IsOptional() @ValidateNested() @Type(() => ContactInfoDto) + contactInfo?: ContactInfoDto + + @IsOptional() @ValidateNested() @Type(() => BillingInfoDto) + billingInfo?: BillingInfoDto +} diff --git a/services/platform-api/src/partners/partners.controller.ts b/services/platform-api/src/partners/partners.controller.ts new file mode 100644 index 0000000..385657f --- /dev/null +++ b/services/platform-api/src/partners/partners.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common' +import { JwtAuthGuard } from '../auth/jwt-auth.guard.js' +import { OperatorGuard } from '../auth/operator.guard.js' +import { CreatePartnerDto } from './dto/create-partner.dto.js' +import { UpdatePartnerDto } from './dto/update-partner.dto.js' +import { PartnersService } from './partners.service.js' + +// Partners are operator-managed only. Every endpoint requires an +// operator-scoped token (aud === 'dezky-operator') plus platformAdmin on the +// resolved user. A self-serve partner portal (partner.dezky.local) is a +// future surface and will hit different endpoints scoped to "this partner's +// own customers" rather than the full set. +@Controller('partners') +@UseGuards(JwtAuthGuard, OperatorGuard) +export class PartnersController { + constructor(private readonly partners: PartnersService) {} + + @Post() + create(@Body() dto: CreatePartnerDto) { + return this.partners.create(dto) + } + + @Get() + async findAll() { + const rows = await this.partners.findAllWithStats() + return rows.map((r) => ({ ...r.partner.toObject(), customers: r.customers })) + } + + @Get(':slug') + async findOne(@Param('slug') slug: string) { + const row = await this.partners.findOneWithStats(slug) + return { ...row.partner.toObject(), customers: row.customers } + } + + @Get(':slug/tenants') + listTenants(@Param('slug') slug: string) { + return this.partners.listTenants(slug) + } + + @Patch(':slug') + update(@Param('slug') slug: string, @Body() dto: UpdatePartnerDto) { + return this.partners.update(slug, dto) + } + + @Delete(':slug') + @HttpCode(204) + async terminate(@Param('slug') slug: string) { + await this.partners.terminate(slug) + } +} diff --git a/services/platform-api/src/partners/partners.module.ts b/services/platform-api/src/partners/partners.module.ts new file mode 100644 index 0000000..8ff436b --- /dev/null +++ b/services/platform-api/src/partners/partners.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { AuthModule } from '../auth/auth.module.js' +import { Partner, PartnerSchema } from '../schemas/partner.schema.js' +import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' +import { PartnersController } from './partners.controller.js' +import { PartnersService } from './partners.service.js' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Partner.name, schema: PartnerSchema }, + { name: Tenant.name, schema: TenantSchema }, + ]), + AuthModule, + ], + controllers: [PartnersController], + providers: [PartnersService], + exports: [PartnersService], +}) +export class PartnersModule {} diff --git a/services/platform-api/src/partners/partners.service.ts b/services/platform-api/src/partners/partners.service.ts new file mode 100644 index 0000000..c648788 --- /dev/null +++ b/services/platform-api/src/partners/partners.service.ts @@ -0,0 +1,87 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model, Types } from 'mongoose' +import { Partner, PartnerDocument } from '../schemas/partner.schema.js' +import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' +import type { CreatePartnerDto } from './dto/create-partner.dto.js' +import type { UpdatePartnerDto } from './dto/update-partner.dto.js' + +// Aggregated view of a Partner — adds counts that are computed at query time +// rather than stored on the document. Storing denormalized would force a sync +// hop on every tenant create/suspend; the count query is cheap with the index +// on Tenant.partnerId. +export interface PartnerWithStats { + partner: PartnerDocument + customers: number + // MRR sum is intentionally absent for now — Tenant doesn't carry a + // monthlyAmount yet (lives on Subscription, no pricing tables). Wire when + // Subscription gains a price column. +} + +@Injectable() +export class PartnersService { + constructor( + @InjectModel(Partner.name) private readonly partnerModel: Model, + @InjectModel(Tenant.name) private readonly tenantModel: Model, + ) {} + + async create(dto: CreatePartnerDto): Promise { + const exists = await this.partnerModel.exists({ slug: dto.slug }) + if (exists) throw new ConflictException(`Partner "${dto.slug}" already exists`) + return this.partnerModel.create({ + ...dto, + status: dto.status ?? 'in-negotiation', + }) + } + + async findAll(): Promise { + return this.partnerModel.find().sort({ createdAt: -1 }).exec() + } + + async findAllWithStats(): Promise { + const partners = await this.findAll() + if (partners.length === 0) return [] + const counts = await this.tenantModel.aggregate<{ _id: Types.ObjectId; n: number }>([ + { $match: { partnerId: { $in: partners.map((p) => p._id) } } }, + { $group: { _id: '$partnerId', n: { $sum: 1 } } }, + ]) + const countMap = new Map(counts.map((c) => [String(c._id), c.n])) + return partners.map((p) => ({ partner: p, customers: countMap.get(String(p._id)) ?? 0 })) + } + + async findOneBySlug(slug: string): Promise { + const partner = await this.partnerModel.findOne({ slug }).exec() + if (!partner) throw new NotFoundException(`Partner "${slug}" not found`) + return partner + } + + async findOneWithStats(slug: string): Promise { + const partner = await this.findOneBySlug(slug) + const customers = await this.tenantModel.countDocuments({ partnerId: partner._id }) + return { partner, customers } + } + + async listTenants(slug: string): Promise { + const partner = await this.findOneBySlug(slug) + return this.tenantModel.find({ partnerId: partner._id }).sort({ createdAt: -1 }).exec() + } + + async update(slug: string, dto: UpdatePartnerDto): Promise { + const partner = await this.partnerModel + .findOneAndUpdate({ slug }, dto, { new: true, runValidators: true }) + .exec() + if (!partner) throw new NotFoundException(`Partner "${slug}" not found`) + return partner + } + + // Soft-terminate. We never hard-delete a partner — there may be customer + // tenants pointing at it and we want the historical reference to survive. + async terminate(slug: string): Promise { + const result = await this.partnerModel + .updateOne({ slug }, { status: 'terminated' }) + .exec() + if (result.matchedCount === 0) { + throw new NotFoundException(`Partner "${slug}" not found`) + } + } +} diff --git a/services/platform-api/src/schemas/partner.schema.ts b/services/platform-api/src/schemas/partner.schema.ts new file mode 100644 index 0000000..ddce112 --- /dev/null +++ b/services/platform-api/src/schemas/partner.schema.ts @@ -0,0 +1,68 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument } from 'mongoose' + +export type PartnerDocument = HydratedDocument + +export type PartnerStatus = 'active' | 'in-negotiation' | 'paused' | 'terminated' + +@Schema({ collection: 'partners', timestamps: true }) +export class Partner { + // URL-safe identifier, same convention as Tenant.slug. + @Prop({ required: true, unique: true, index: true, lowercase: true, trim: true }) + slug!: string + + @Prop({ required: true, trim: true }) + name!: string + + // The partner's own organization domain (e.g. 'nordicmsp.dk'). Not used by + // any service-level integration — purely metadata for display + contact. + @Prop({ required: true, trim: true }) + domain!: string + + @Prop({ + enum: ['active', 'in-negotiation', 'paused', 'terminated'], + default: 'in-negotiation', + index: true, + }) + status!: PartnerStatus + + // Revenue share. 20 = partner keeps 20% of their customers' MRR. One number + // per partner — we don't negotiate per-tenant margin under a partner. + @Prop({ default: 0, min: 0, max: 100 }) + marginPct!: number + + @Prop() + partnershipStartedAt?: Date + + @Prop({ + type: { + primaryName: String, + primaryEmail: String, + billingEmail: String, + }, + default: {}, + }) + contactInfo!: { + primaryName?: string + primaryEmail?: string + billingEmail?: string + } + + @Prop({ + type: { + companyName: String, + vatId: String, + country: String, + contactEmail: String, + }, + default: {}, + }) + billingInfo!: { + companyName?: string + vatId?: string + country?: string + contactEmail?: string + } +} + +export const PartnerSchema = SchemaFactory.createForClass(Partner) diff --git a/services/platform-api/src/schemas/tenant.schema.ts b/services/platform-api/src/schemas/tenant.schema.ts index bf5d225..11bcb8a 100644 --- a/services/platform-api/src/schemas/tenant.schema.ts +++ b/services/platform-api/src/schemas/tenant.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' -import { HydratedDocument } from 'mongoose' +import { HydratedDocument, Types } from 'mongoose' export type TenantDocument = HydratedDocument diff --git a/services/platform-api/src/tenants/dto/update-tenant.dto.ts b/services/platform-api/src/tenants/dto/update-tenant.dto.ts index 7e880ef..8251237 100644 --- a/services/platform-api/src/tenants/dto/update-tenant.dto.ts +++ b/services/platform-api/src/tenants/dto/update-tenant.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator' +import { IsArray, IsEnum, IsMongoId, IsOptional, IsString, MaxLength, MinLength } from 'class-validator' export class UpdateTenantDto { @IsOptional() @IsString() @MinLength(2) @MaxLength(120) @@ -12,4 +12,8 @@ export class UpdateTenantDto { @IsOptional() @IsArray() @IsString({ each: true }) domains?: string[] + + // Attach / move tenant under a partner (or pass null to detach). + @IsOptional() @IsMongoId() + partnerId?: string | null } diff --git a/services/platform-api/src/tenants/tenants.controller.ts b/services/platform-api/src/tenants/tenants.controller.ts index e0c73b2..0b66fd4 100644 --- a/services/platform-api/src/tenants/tenants.controller.ts +++ b/services/platform-api/src/tenants/tenants.controller.ts @@ -13,6 +13,7 @@ import { import { ActorService } from '../auth/actor.service.js' import { CurrentUser } from '../auth/current-user.decorator.js' import { JwtAuthGuard } from '../auth/jwt-auth.guard.js' +import { OperatorGuard } from '../auth/operator.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' @@ -88,4 +89,18 @@ export class TenantsController { } return this.tenants.reconcile(slug) } + + // Lifecycle: operator-only. JwtAuthGuard validates the token first, then + // OperatorGuard requires audience='dezky-operator' AND platformAdmin=true. + @Post(':slug/suspend') + @UseGuards(OperatorGuard) + suspend(@Param('slug') slug: string) { + return this.tenants.setStatus(slug, 'suspended') + } + + @Post(':slug/resume') + @UseGuards(OperatorGuard) + resume(@Param('slug') slug: string) { + return this.tenants.setStatus(slug, 'active') + } } diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts index 95f7399..9e9c218 100644 --- a/services/platform-api/src/tenants/tenants.service.ts +++ b/services/platform-api/src/tenants/tenants.service.ts @@ -65,4 +65,12 @@ export class TenantsService { .exec() if (result.matchedCount === 0) throw new NotFoundException(`Tenant "${slug}" not found`) } + + async setStatus(slug: string, status: 'active' | 'suspended'): Promise { + const tenant = await this.tenantModel + .findOneAndUpdate({ slug }, { status }, { new: true }) + .exec() + if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) + return tenant + } }