diff --git a/apps/portal/nuxt.config.ts b/apps/portal/nuxt.config.ts index 7e8be6b..c3bcc68 100644 --- a/apps/portal/nuxt.config.ts +++ b/apps/portal/nuxt.config.ts @@ -55,13 +55,16 @@ export default defineNuxtConfig({ // Discovery URL — used by id_token validation to fetch JWKS + issuer openIdConfiguration: 'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration', - scope: ['openid', 'profile', 'email'], + scope: ['openid', 'profile', 'email', 'groups'], userNameClaim: 'preferred_username', responseType: 'code', grantType: 'authorization_code', pkce: true, // Authentik's access tokens aren't always parseable as JWT — skip strict parsing 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, }, }, }, diff --git a/apps/portal/server/api/me.get.ts b/apps/portal/server/api/me.get.ts new file mode 100644 index 0000000..8978ff3 --- /dev/null +++ b/apps/portal/server/api/me.get.ts @@ -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 } +}) diff --git a/docs/NEXT-STEPS.md b/docs/NEXT-STEPS.md index 1b7c46a..1998f8a 100644 --- a/docs/NEXT-STEPS.md +++ b/docs/NEXT-STEPS.md @@ -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 - 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/` -- [ ] 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 +### Endpoints -Schema example: -```typescript -// services/provisioning/src/schemas/tenant.schema.ts -@Schema({ timestamps: true }) -export class Tenant { - @Prop({ required: true, unique: true }) - slug: string +| Method | Path | Notes | +|---|---|---| +| GET | `/health` | open | +| POST/GET | `/tenants`, `/tenants/:slug` | platform admin to create/delete; tenant members can read+update their own | +| GET | `/users/me` | upserts the user on first call from JWT claims | +| GET/POST/PATCH/DELETE | `/users[/:subject]` | platform admin for mutations | +| GET/POST/PATCH | `/subscriptions[/:slug]` | platform admin for mutations | - @Prop({ required: true }) - name: string +### Dev-mode caveats (clean up before prod) - @Prop({ enum: ['pending', 'active', 'suspended', 'deleted'], default: 'pending' }) - status: string - - @Prop({ type: [String], default: [] }) - domains: string[] - - @Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' }) - plan: string - - @Prop() - authentikGroupId?: string - - @Prop() - ocisSpaceId?: string -} -``` +- `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 +- 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 +- 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 ## Phase 4: Provisioning automation (week 2-3) diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml index daef4fb..207b047 100644 --- a/infrastructure/docker-compose/docker-compose.yml +++ b/infrastructure/docker-compose/docker-compose.yml @@ -54,6 +54,7 @@ services: - traefik.dezky.local - auth.dezky.local - app.dezky.local + - api.dezky.local - files.dezky.local - mail.dezky.local - office.dezky.local @@ -331,11 +332,23 @@ services: STALWART_ADMIN_USER: admin STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD} 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: - ../../services/provisioning:/app - provisioning_node_modules:/app/node_modules + - ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro networks: [dezky] depends_on: mongo: 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 diff --git a/services/provisioning/package.json b/services/provisioning/package.json index 4cdbcd9..40f2ec6 100644 --- a/services/provisioning/package.json +++ b/services/provisioning/package.json @@ -18,6 +18,9 @@ "@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" diff --git a/services/provisioning/pnpm-lock.yaml b/services/provisioning/pnpm-lock.yaml index e5dedca..999bc5c 100644 --- a/services/provisioning/pnpm-lock.yaml +++ b/services/provisioning/pnpm-lock.yaml @@ -10,19 +10,28 @@ importers: dependencies: '@nestjs/common': 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': 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': 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': 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': 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: specifier: ^8.7.0 version: 8.24.0 @@ -38,7 +47,7 @@ importers: version: 10.4.9 '@nestjs/testing': 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': specifier: ^20.0.0 version: 20.19.41 @@ -277,6 +286,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -502,6 +514,12 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} 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: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -886,6 +904,9 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -923,6 +944,9 @@ packages: resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} engines: {node: '>=12.0.0'} + libphonenumber-js@1.13.3: + resolution: {integrity: sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==} + light-my-request@5.14.0: resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} @@ -1494,6 +1518,10 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -1720,7 +1748,7 @@ snapshots: - uglify-js - 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: file-type: 20.4.1 iterare: 1.2.1 @@ -1728,20 +1756,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 transitivePeerDependencies: - 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: - '@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-expand: 10.0.0 lodash: 4.17.21 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: - '@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 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -1753,20 +1784,20 @@ snapshots: transitivePeerDependencies: - 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: - '@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/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@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: '@fastify/cors': 9.0.1 '@fastify/formbody': 7.4.0 '@fastify/middie': 8.3.3 - '@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/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) fastify: 4.28.1 light-my-request: 6.3.0 path-to-regexp: 3.3.0 @@ -1783,10 +1814,10 @@ snapshots: transitivePeerDependencies: - 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: - '@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/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) tslib: 2.8.1 '@nuxtjs/opencollective@0.3.2': @@ -1838,6 +1869,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/validator@13.15.10': {} + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -2091,6 +2124,14 @@ snapshots: 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: dependencies: restore-cursor: 3.1.0 @@ -2489,6 +2530,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jose@5.10.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -2519,6 +2562,8 @@ snapshots: kareem@2.6.3: {} + libphonenumber-js@1.13.3: {} + light-my-request@5.14.0: dependencies: cookie: 0.7.2 @@ -2999,6 +3044,8 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + validator@13.15.35: {} + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 diff --git a/services/provisioning/src/app.module.ts b/services/provisioning/src/app.module.ts index d81e356..6b4a8e7 100644 --- a/services/provisioning/src/app.module.ts +++ b/services/provisioning/src/app.module.ts @@ -1,12 +1,24 @@ 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'), + MongooseModule.forRoot( + process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky', + ), + AuthModule, + TenantsModule, + UsersModule, + SubscriptionsModule, + SeedModule, ], controllers: [HealthController], }) diff --git a/services/provisioning/src/auth/actor.service.ts b/services/provisioning/src/auth/actor.service.ts new file mode 100644 index 0000000..1dca778 --- /dev/null +++ b/services/provisioning/src/auth/actor.service.ts @@ -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) {} + + async resolve(jwt: AuthentikJwtPayload): Promise { + 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 + } +} diff --git a/services/provisioning/src/auth/auth.module.ts b/services/provisioning/src/auth/auth.module.ts new file mode 100644 index 0000000..05c9f36 --- /dev/null +++ b/services/provisioning/src/auth/auth.module.ts @@ -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 {} diff --git a/services/provisioning/src/auth/current-user.decorator.ts b/services/provisioning/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..62a89a4 --- /dev/null +++ b/services/provisioning/src/auth/current-user.decorator.ts @@ -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 + }, +) diff --git a/services/provisioning/src/auth/jwt-auth.guard.ts b/services/provisioning/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..7cdee74 --- /dev/null +++ b/services/provisioning/src/auth/jwt-auth.guard.ts @@ -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 | null = null + private readonly issuer: string + private readonly audience: string + private readonly jwksUri: string + + constructor(config: ConfigService) { + this.issuer = config.getOrThrow('AUTHENTIK_ISSUER') + this.audience = config.getOrThrow('AUTHENTIK_AUDIENCE') + this.jwksUri = config.getOrThrow('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 { + const req = context.switchToHttp().getRequest<{ + headers: Record + 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') + } + } +} diff --git a/services/provisioning/src/auth/jwt-payload.interface.ts b/services/provisioning/src/auth/jwt-payload.interface.ts new file mode 100644 index 0000000..13b61de --- /dev/null +++ b/services/provisioning/src/auth/jwt-payload.interface.ts @@ -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[] +} diff --git a/services/provisioning/src/main.ts b/services/provisioning/src/main.ts index 793f2b4..2eb0c14 100644 --- a/services/provisioning/src/main.ts +++ b/services/provisioning/src/main.ts @@ -1,6 +1,7 @@ // Dezky Provisioning Service — Entry point // Handles tenant lifecycle: create, suspend, delete, billing webhooks. +import { ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' import { AppModule } from './app.module.js' @@ -16,6 +17,15 @@ async function bootstrap() { 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') diff --git a/services/provisioning/src/schemas/subscription.schema.ts b/services/provisioning/src/schemas/subscription.schema.ts new file mode 100644 index 0000000..dd0aa9f --- /dev/null +++ b/services/provisioning/src/schemas/subscription.schema.ts @@ -0,0 +1,46 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument, Types } from 'mongoose' + +export type SubscriptionDocument = HydratedDocument + +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) diff --git a/services/provisioning/src/schemas/tenant.schema.ts b/services/provisioning/src/schemas/tenant.schema.ts new file mode 100644 index 0000000..636322b --- /dev/null +++ b/services/provisioning/src/schemas/tenant.schema.ts @@ -0,0 +1,56 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument } from 'mongoose' + +export type TenantDocument = HydratedDocument + +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) diff --git a/services/provisioning/src/schemas/user.schema.ts b/services/provisioning/src/schemas/user.schema.ts new file mode 100644 index 0000000..7c12d90 --- /dev/null +++ b/services/provisioning/src/schemas/user.schema.ts @@ -0,0 +1,41 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument, Types } from 'mongoose' + +export type UserDocument = HydratedDocument + +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) diff --git a/services/provisioning/src/seed/seed.module.ts b/services/provisioning/src/seed/seed.module.ts new file mode 100644 index 0000000..7cb34e7 --- /dev/null +++ b/services/provisioning/src/seed/seed.module.ts @@ -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 {} diff --git a/services/provisioning/src/seed/seed.service.ts b/services/provisioning/src/seed/seed.service.ts new file mode 100644 index 0000000..5bf8ed9 --- /dev/null +++ b/services/provisioning/src/seed/seed.service.ts @@ -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, + @InjectModel(User.name) private readonly userModel: Model, + @InjectModel(Subscription.name) private readonly subModel: Model, + private readonly config: ConfigService, + ) {} + + async onApplicationBootstrap(): Promise { + 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. + } +} diff --git a/services/provisioning/src/subscriptions/dto/create-subscription.dto.ts b/services/provisioning/src/subscriptions/dto/create-subscription.dto.ts new file mode 100644 index 0000000..efebfe6 --- /dev/null +++ b/services/provisioning/src/subscriptions/dto/create-subscription.dto.ts @@ -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 +} diff --git a/services/provisioning/src/subscriptions/dto/update-subscription.dto.ts b/services/provisioning/src/subscriptions/dto/update-subscription.dto.ts new file mode 100644 index 0000000..d9c3a93 --- /dev/null +++ b/services/provisioning/src/subscriptions/dto/update-subscription.dto.ts @@ -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 +} diff --git a/services/provisioning/src/subscriptions/subscriptions.controller.ts b/services/provisioning/src/subscriptions/subscriptions.controller.ts new file mode 100644 index 0000000..6a640e8 --- /dev/null +++ b/services/provisioning/src/subscriptions/subscriptions.controller.ts @@ -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) + } +} diff --git a/services/provisioning/src/subscriptions/subscriptions.module.ts b/services/provisioning/src/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..27804e2 --- /dev/null +++ b/services/provisioning/src/subscriptions/subscriptions.module.ts @@ -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 {} diff --git a/services/provisioning/src/subscriptions/subscriptions.service.ts b/services/provisioning/src/subscriptions/subscriptions.service.ts new file mode 100644 index 0000000..320f601 --- /dev/null +++ b/services/provisioning/src/subscriptions/subscriptions.service.ts @@ -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, + private readonly tenants: TenantsService, + ) {} + + async create(dto: CreateSubscriptionDto): Promise { + 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 { + return this.model.find({ tenantId: { $in: tenantIds } }).sort({ createdAt: -1 }).exec() + } + + async findAll(): Promise { + return this.model.find().sort({ createdAt: -1 }).exec() + } + + async findByTenantSlug(slug: string): Promise { + 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 { + 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 + } +} diff --git a/services/provisioning/src/tenants/dto/create-tenant.dto.ts b/services/provisioning/src/tenants/dto/create-tenant.dto.ts new file mode 100644 index 0000000..11ce178 --- /dev/null +++ b/services/provisioning/src/tenants/dto/create-tenant.dto.ts @@ -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 +} diff --git a/services/provisioning/src/tenants/dto/update-tenant.dto.ts b/services/provisioning/src/tenants/dto/update-tenant.dto.ts new file mode 100644 index 0000000..7e880ef --- /dev/null +++ b/services/provisioning/src/tenants/dto/update-tenant.dto.ts @@ -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[] +} diff --git a/services/provisioning/src/tenants/tenants.controller.ts b/services/provisioning/src/tenants/tenants.controller.ts new file mode 100644 index 0000000..96b084e --- /dev/null +++ b/services/provisioning/src/tenants/tenants.controller.ts @@ -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) + } +} diff --git a/services/provisioning/src/tenants/tenants.module.ts b/services/provisioning/src/tenants/tenants.module.ts new file mode 100644 index 0000000..5744133 --- /dev/null +++ b/services/provisioning/src/tenants/tenants.module.ts @@ -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 {} diff --git a/services/provisioning/src/tenants/tenants.service.ts b/services/provisioning/src/tenants/tenants.service.ts new file mode 100644 index 0000000..8760852 --- /dev/null +++ b/services/provisioning/src/tenants/tenants.service.ts @@ -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) {} + + async create(dto: CreateTenantDto): Promise { + const exists = await this.tenantModel.exists({ slug: dto.slug }) + if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`) + return this.tenantModel.create({ ...dto, status: 'pending' }) + } + + async findAll(): Promise { + return this.tenantModel.find().sort({ createdAt: -1 }).exec() + } + + async findByIds(ids: Types.ObjectId[]): Promise { + if (ids.length === 0) return [] + return this.tenantModel + .find({ _id: { $in: ids } }) + .sort({ createdAt: -1 }) + .exec() + } + + async findOneBySlug(slug: string): Promise { + 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 { + 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 { + 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 { + const result = await this.tenantModel + .updateOne({ slug }, { status: 'deleted' }) + .exec() + if (result.matchedCount === 0) throw new NotFoundException(`Tenant "${slug}" not found`) + } +} diff --git a/services/provisioning/src/users/dto/create-user.dto.ts b/services/provisioning/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..338397a --- /dev/null +++ b/services/provisioning/src/users/dto/create-user.dto.ts @@ -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' +} diff --git a/services/provisioning/src/users/dto/update-user.dto.ts b/services/provisioning/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..132a401 --- /dev/null +++ b/services/provisioning/src/users/dto/update-user.dto.ts @@ -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 +} diff --git a/services/provisioning/src/users/users.controller.ts b/services/provisioning/src/users/users.controller.ts new file mode 100644 index 0000000..d3e475d --- /dev/null +++ b/services/provisioning/src/users/users.controller.ts @@ -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('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) + } +} diff --git a/services/provisioning/src/users/users.module.ts b/services/provisioning/src/users/users.module.ts new file mode 100644 index 0000000..2d53efb --- /dev/null +++ b/services/provisioning/src/users/users.module.ts @@ -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 {} diff --git a/services/provisioning/src/users/users.service.ts b/services/provisioning/src/users/users.service.ts new file mode 100644 index 0000000..8944a45 --- /dev/null +++ b/services/provisioning/src/users/users.service.ts @@ -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, + @InjectModel(Tenant.name) private readonly tenantModel: Model, + ) {} + + async create(dto: CreateUserDto): Promise { + 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 { + return this.userModel.find({ tenantIds: { $in: tenantIds } }).sort({ createdAt: -1 }).exec() + } + + async findAll(): Promise { + return this.userModel.find().sort({ createdAt: -1 }).exec() + } + + async findOneBySubject(subject: string): Promise { + 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 { + const patch: Record = { ...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 { + 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 { + 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 { + if (slugs.length === 0) return [] + const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec() + return tenants.map((t) => t._id) + } +}