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:
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+67
-20
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user