feat(provisioning): tenant data model + CRUD with JWT-validated authz

Implements Phase 3 from docs/NEXT-STEPS.md.

Mongoose schemas (services/provisioning/src/schemas/):
- Tenant: slug, name, status, plan, domains, billingInfo, plus handles for
  Authentik group, OCIS space, and Stalwart domain (set in Phase 4)
- User: authentikSubjectId, tenantIds[], email, name, role, platformAdmin flag
- Subscription: tenantId, plan, status, Stripe IDs (unused until Phase 4)

Auth (services/provisioning/src/auth/):
- JwtAuthGuard verifies Authentik access tokens against the provider's JWKS
  with issuer + audience checks. Uses NODE_EXTRA_CA_CERTS to trust the
  mkcert root for the local Authentik cert
- ActorService resolves the verified JWT into a Mongo User document — every
  controller reads tenantIds + platformAdmin from the DB, not the token
- CurrentUser decorator extracts the JWT payload onto controllers

CRUD modules:
- /tenants, /users, /subscriptions with create/read/update/delete
- /users/me upserts the caller's User record on every request, syncing email,
  name, tenantIds, and platformAdmin from the JWT's groups claim — the only
  place we read JWT.groups outside the bootstrap

Why DB-derived authz: putting all group memberships in the JWT doesn't scale
past ~50 tenants per user (header/cookie size limits, no mid-session
revocation, stale data until re-login). JWT now carries identity only; the
DB is the source of truth for who can see what.

Seed (SeedService.OnApplicationBootstrap): idempotent creation of the
default 'dezky' tenant + matching subscription. User records are created on
first /users/me hit.

Infrastructure:
- Traefik label exposes provisioning at https://api.dezky.local (dev only)
- api.dezky.local added to Docker network aliases on Traefik
- mkcert root CA mounted into the provisioning container for JWKS fetch
- Authentik 'groups' scope mapping created + attached to dezky-portal
  provider; portal now requests it as a scope
- nuxt.config.ts portal: exposeAccessToken=true so Nitro forwards token;
  NUXT_OIDC_TOKEN_KEY fixed to base64-encoded 32 bytes (was hex, causing
  "Invalid key length" once exposeAccessToken turned on)

Portal: apps/portal/server/api/me.get.ts is a scaffolding route that
forwards the user's access token to provisioning and returns profile +
tenants + subscriptions — verifies the full chain end to end.
This commit is contained in:
Ronni Baslund
2026-05-23 21:53:53 +02:00
parent adfd9baafe
commit 3d370caa62
33 changed files with 1148 additions and 54 deletions
+4 -1
View File
@@ -55,13 +55,16 @@ export default defineNuxtConfig({
// Discovery URL — used by id_token validation to fetch JWKS + issuer // Discovery URL — used by id_token validation to fetch JWKS + issuer
openIdConfiguration: openIdConfiguration:
'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration', 'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration',
scope: ['openid', 'profile', 'email'], scope: ['openid', 'profile', 'email', 'groups'],
userNameClaim: 'preferred_username', userNameClaim: 'preferred_username',
responseType: 'code', responseType: 'code',
grantType: 'authorization_code', grantType: 'authorization_code',
pkce: true, pkce: true,
// Authentik's access tokens aren't always parseable as JWT — skip strict parsing // Authentik's access tokens aren't always parseable as JWT — skip strict parsing
skipAccessTokenParsing: true, skipAccessTokenParsing: true,
// Expose access token in the server-side session so Nitro route handlers can
// forward it to provisioning. Token never reaches the browser.
exposeAccessToken: true,
}, },
}, },
}, },
+27
View File
@@ -0,0 +1,27 @@
// Scaffolding route: pulls the signed-in user's profile + tenants + subscriptions
// from the provisioning service, using the user's Authentik access token forwarded
// from the encrypted server-side session.
//
// Verifies the full chain: portal session → access token → provisioning JWT guard → Mongo.
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 or no access token' })
}
const base = process.env.PROVISIONING_INTERNAL_URL ?? 'http://provisioning:3001'
const headers = { Authorization: `Bearer ${accessToken}` }
const [profile, tenants, subscriptions] = await Promise.all([
$fetch(`${base}/users/me`, { headers }),
$fetch(`${base}/tenants`, { headers }),
$fetch(`${base}/subscriptions`, { headers }),
])
return { profile, tenants, subscriptions }
})
+23 -32
View File
@@ -39,43 +39,34 @@ Goal: Users can log in to the portal via Authentik.
- `docker-compose.yml` mounts `infrastructure/docker-compose/certs/mkcert-root.pem` into the portal at `/etc/ssl/mkcert-root.pem` and sets `NODE_EXTRA_CA_CERTS` so Node fetch trusts the mkcert root CA. In prod, replace with real CA-signed certs - `docker-compose.yml` mounts `infrastructure/docker-compose/certs/mkcert-root.pem` into the portal at `/etc/ssl/mkcert-root.pem` and sets `NODE_EXTRA_CA_CERTS` so Node fetch trusts the mkcert root CA. In prod, replace with real CA-signed certs
- Traefik has Docker network aliases for `auth.dezky.local`, `app.dezky.local`, etc. so container-to-Authentik fetch resolves inside the network without going through host `/etc/hosts` - Traefik has Docker network aliases for `auth.dezky.local`, `app.dezky.local`, etc. so container-to-Authentik fetch resolves inside the network without going through host `/etc/hosts`
## Phase 3: Tenant data model (week 1-2) ## Phase 3: Tenant data model (week 1-2) — done
Goal: MongoDB schema for tenants, users, subscriptions. - [x] Mongoose schemas in `services/provisioning/src/schemas/` (Tenant, User, Subscription)
- [x] Tenant: slug, name, status, plan, domains, authentikGroupId, ocisSpaceId, stalwartDomain, billingInfo
- [x] User: authentikSubjectId, tenantIds[], email, name, role, active, lastLoginAt
- [x] Subscription: tenantId, plan, status, stripeCustomerId, stripeSubscriptionId, period dates
- [x] CRUD endpoints behind `JwtAuthGuard` (validates Authentik JWT via JWKS)
- [x] Group-based authorization: users see only tenants whose slug matches one of their Authentik `groups`; `dezky-platform-admins` group has global access
- [x] Idempotent seed (`SeedService`) creates the `dezky` tenant + matching subscription on bootstrap
- [x] Provisioning exposed at `https://api.dezky.local` (Traefik label, dev only) and via internal `http://provisioning:3001`
- [x] Portal Nitro route at `/api/me` forwards the user's encrypted access token to provisioning — verified end-to-end
- [ ] Define Mongoose schemas in `services/provisioning/src/schemas/` ### Endpoints
- [ ] Tenant schema: id, name, slug, status, plan, billingInfo, domains
- [ ] User schema: id, tenantId, email, name, role, authentikId
- [ ] Subscription schema: tenantId, plan, status, stripeCustomerId
- [ ] Add CRUD endpoints in NestJS
Schema example: | Method | Path | Notes |
```typescript |---|---|---|
// services/provisioning/src/schemas/tenant.schema.ts | GET | `/health` | open |
@Schema({ timestamps: true }) | POST/GET | `/tenants`, `/tenants/:slug` | platform admin to create/delete; tenant members can read+update their own |
export class Tenant { | GET | `/users/me` | upserts the user on first call from JWT claims |
@Prop({ required: true, unique: true }) | GET/POST/PATCH/DELETE | `/users[/:subject]` | platform admin for mutations |
slug: string | GET/POST/PATCH | `/subscriptions[/:slug]` | platform admin for mutations |
@Prop({ required: true }) ### Dev-mode caveats (clean up before prod)
name: string
@Prop({ enum: ['pending', 'active', 'suspended', 'deleted'], default: 'pending' }) - `NUXT_OIDC_TOKEN_KEY` must be base64-encoded 32 bytes (`openssl rand -base64 32`) — NOT hex. Module silently fails with "Invalid key length" if wrong
status: string - Portal config has `exposeAccessToken: true` so Nitro routes can forward the token; token still never reaches the browser
- The `dezky` group in Authentik is the single tenant for dev. New tenants in Phase 4 need to create matching Authentik groups
@Prop({ type: [String], default: [] }) - A `dezky-platform-admins` group doesn't exist yet — for now akadmin's membership in `authentik Admins` does NOT grant platform-admin rights. Create that group if you want admin-only endpoints to work for you
domains: string[]
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan: string
@Prop()
authentikGroupId?: string
@Prop()
ocisSpaceId?: string
}
```
## Phase 4: Provisioning automation (week 2-3) ## Phase 4: Provisioning automation (week 2-3)
@@ -54,6 +54,7 @@ services:
- traefik.dezky.local - traefik.dezky.local
- auth.dezky.local - auth.dezky.local
- app.dezky.local - app.dezky.local
- api.dezky.local
- files.dezky.local - files.dezky.local
- mail.dezky.local - mail.dezky.local
- office.dezky.local - office.dezky.local
@@ -331,11 +332,23 @@ services:
STALWART_ADMIN_USER: admin STALWART_ADMIN_USER: admin
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD} STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
OCIS_API_URL: http://ocis:9200 OCIS_API_URL: http://ocis:9200
# JWT validation against Authentik for portal-issued access tokens
AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/
AUTHENTIK_AUDIENCE: dezky-portal
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
volumes: volumes:
- ../../services/provisioning:/app - ../../services/provisioning:/app
- provisioning_node_modules:/app/node_modules - provisioning_node_modules:/app/node_modules
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
networks: [dezky] networks: [dezky]
depends_on: depends_on:
mongo: mongo:
condition: service_healthy condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.dezky.local`)
- traefik.http.routers.api.tls=true
- traefik.http.services.api.loadbalancer.server.port=3001
+3
View File
@@ -18,6 +18,9 @@
"@nestjs/platform-fastify": "^10.4.0", "@nestjs/platform-fastify": "^10.4.0",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/mongoose": "^10.1.0", "@nestjs/mongoose": "^10.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jose": "^5.9.0",
"mongoose": "^8.7.0", "mongoose": "^8.7.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0" "rxjs": "^7.8.0"
+67 -20
View File
@@ -10,19 +10,28 @@ importers:
dependencies: dependencies:
'@nestjs/common': '@nestjs/common':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config': '@nestjs/config':
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) version: 3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mongoose': '@nestjs/mongoose':
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(mongoose@8.24.0)(rxjs@7.8.2) version: 10.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(mongoose@8.24.0)(rxjs@7.8.2)
'@nestjs/platform-fastify': '@nestjs/platform-fastify':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.14.1
version: 0.14.4
jose:
specifier: ^5.9.0
version: 5.10.0
mongoose: mongoose:
specifier: ^8.7.0 specifier: ^8.7.0
version: 8.24.0 version: 8.24.0
@@ -38,7 +47,7 @@ importers:
version: 10.4.9 version: 10.4.9
'@nestjs/testing': '@nestjs/testing':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@types/node': '@types/node':
specifier: ^20.0.0 specifier: ^20.0.0
version: 20.19.41 version: 20.19.41
@@ -277,6 +286,9 @@ packages:
'@types/node@20.19.41': '@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/webidl-conversions@7.0.3': '@types/webidl-conversions@7.0.3':
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
@@ -502,6 +514,12 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
class-validator@0.14.4:
resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==}
cli-cursor@3.1.0: cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -886,6 +904,9 @@ packages:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -923,6 +944,9 @@ packages:
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
libphonenumber-js@1.13.3:
resolution: {integrity: sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==}
light-my-request@5.14.0: light-my-request@5.14.0:
resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==}
@@ -1494,6 +1518,10 @@ packages:
v8-compile-cache-lib@3.0.1: v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'}
watchpack@2.5.1: watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -1720,7 +1748,7 @@ snapshots:
- uglify-js - uglify-js
- webpack-cli - webpack-cli
'@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)': '@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies: dependencies:
file-type: 20.4.1 file-type: 20.4.1
iterare: 1.2.1 iterare: 1.2.1
@@ -1728,20 +1756,23 @@ snapshots:
rxjs: 7.8.2 rxjs: 7.8.2
tslib: 2.8.1 tslib: 2.8.1
uid: 2.0.2 uid: 2.0.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.14.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/config@3.3.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': '@nestjs/config@3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
dotenv: 16.4.5 dotenv: 16.4.5
dotenv-expand: 10.0.0 dotenv-expand: 10.0.0
lodash: 4.17.21 lodash: 4.17.21
rxjs: 7.8.2 rxjs: 7.8.2
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxtjs/opencollective': 0.3.2 '@nuxtjs/opencollective': 0.3.2
fast-safe-stringify: 2.1.1 fast-safe-stringify: 2.1.1
iterare: 1.2.1 iterare: 1.2.1
@@ -1753,20 +1784,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
'@nestjs/mongoose@10.1.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(mongoose@8.24.0)(rxjs@7.8.2)': '@nestjs/mongoose@10.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(mongoose@8.24.0)(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
mongoose: 8.24.0 mongoose: 8.24.0
rxjs: 7.8.2 rxjs: 7.8.2
'@nestjs/platform-fastify@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': '@nestjs/platform-fastify@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies: dependencies:
'@fastify/cors': 9.0.1 '@fastify/cors': 9.0.1
'@fastify/formbody': 7.4.0 '@fastify/formbody': 7.4.0
'@fastify/middie': 8.3.3 '@fastify/middie': 8.3.3
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
fastify: 4.28.1 fastify: 4.28.1
light-my-request: 6.3.0 light-my-request: 6.3.0
path-to-regexp: 3.3.0 path-to-regexp: 3.3.0
@@ -1783,10 +1814,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@nestjs/testing@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies: dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1 tslib: 2.8.1
'@nuxtjs/opencollective@0.3.2': '@nuxtjs/opencollective@0.3.2':
@@ -1838,6 +1869,8 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/validator@13.15.10': {}
'@types/webidl-conversions@7.0.3': {} '@types/webidl-conversions@7.0.3': {}
'@types/whatwg-url@11.0.5': '@types/whatwg-url@11.0.5':
@@ -2091,6 +2124,14 @@ snapshots:
chrome-trace-event@1.0.4: {} chrome-trace-event@1.0.4: {}
class-transformer@0.5.1: {}
class-validator@0.14.4:
dependencies:
'@types/validator': 13.15.10
libphonenumber-js: 1.13.3
validator: 13.15.35
cli-cursor@3.1.0: cli-cursor@3.1.0:
dependencies: dependencies:
restore-cursor: 3.1.0 restore-cursor: 3.1.0
@@ -2489,6 +2530,8 @@ snapshots:
merge-stream: 2.0.0 merge-stream: 2.0.0
supports-color: 8.1.1 supports-color: 8.1.1
jose@5.10.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@@ -2519,6 +2562,8 @@ snapshots:
kareem@2.6.3: {} kareem@2.6.3: {}
libphonenumber-js@1.13.3: {}
light-my-request@5.14.0: light-my-request@5.14.0:
dependencies: dependencies:
cookie: 0.7.2 cookie: 0.7.2
@@ -2999,6 +3044,8 @@ snapshots:
v8-compile-cache-lib@3.0.1: {} v8-compile-cache-lib@3.0.1: {}
validator@13.15.35: {}
watchpack@2.5.1: watchpack@2.5.1:
dependencies: dependencies:
glob-to-regexp: 0.4.1 glob-to-regexp: 0.4.1
+13 -1
View File
@@ -1,12 +1,24 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config' import { ConfigModule } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose' import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from './auth/auth.module.js'
import { HealthController } from './health.controller.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({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky'), MongooseModule.forRoot(
process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky',
),
AuthModule,
TenantsModule,
UsersModule,
SubscriptionsModule,
SeedModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
@@ -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[]
}
+10
View File
@@ -1,6 +1,7 @@
// Dezky Provisioning Service — Entry point // Dezky Provisioning Service — Entry point
// Handles tenant lifecycle: create, suspend, delete, billing webhooks. // Handles tenant lifecycle: create, suspend, delete, billing webhooks.
import { ValidationPipe } from '@nestjs/common'
import { NestFactory } from '@nestjs/core' import { NestFactory } from '@nestjs/core'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { AppModule } from './app.module.js' import { AppModule } from './app.module.js'
@@ -16,6 +17,15 @@ async function bootstrap() {
credentials: true, credentials: true,
}) })
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
)
const port = Number(process.env.PORT ?? 3001) const port = Number(process.env.PORT ?? 3001)
await app.listen(port, '0.0.0.0') await app.listen(port, '0.0.0.0')
@@ -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,56 @@
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'
@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
}
}
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,78 @@
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)
}
}
@@ -0,0 +1,17 @@
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 { TenantsController } from './tenants.controller.js'
import { TenantsService } from './tenants.service.js'
@Module({
imports: [
MongooseModule.forFeature([{ name: Tenant.name, schema: TenantSchema }]),
AuthModule,
],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}
@@ -0,0 +1,56 @@
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'
@Injectable()
export class TenantsService {
constructor(@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>) {}
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`)
return this.tenantModel.create({ ...dto, status: 'pending' })
}
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)
}
}