Phase 3 Completed

This commit is contained in:
Rayyan
2026-08-02 17:44:50 +07:00
parent 7ce0de4e91
commit ffc5ecd259
68 changed files with 3132 additions and 163 deletions
+165
View File
@@ -0,0 +1,165 @@
Phase 1 Deliverables - RayLab Core
=================================
Contents
--------
1. Architecture explanation
2. Folder structure
3. Source code references (what was added/changed)
4. Database schema (Prisma)
5. Migration (SQL)
6. Seed data (script)
7. API design (initial endpoints)
8. Design decisions
9. Advantages
10. Possible future extensions
11. How to run (migrate & seed)
1) Architecture explanation
--------------------------
Phase 1 establishes the foundational data model and project layout for RayLab Core. It prepares the system for secure integration with Authentik (OIDC) and supports role/permission based authorization owned by RayLab Core. The design follows Clean Architecture, SOLID principles, modular NestJS structure, and Prisma for type-safe DB access.
Key principles applied:
- RayLab Core does NOT implement authentication; it only validates external OIDC tokens (Phase 2).
- Roles, permissions, role mappings, users, and audit logs are owned by RayLab Core. Media Manager and Scheduler are intentionally excluded from Phase 1 and will be implemented in Phase 3.
- No Group entity is created in RayLab Core; only mapping configuration (auth group name string -> role) is stored.
- Critical operations (synchronization) will be transactional (Phase 2 implementation).
2) Folder structure
-------------------
(Only top-level relevant directories shown)
- prisma/
- schema.prisma // Prisma schema (source of truth for DB)
- migrations/0001_init/ // Initial SQL migration
- seed.ts // Seed script (idempotent upserts)
- src/
- main.ts
- app.module.ts
- modules/
- identity/ // DDD-based users/roles/permissions implementation
- auth/ // (Phase 2) OIDC integration
- authorization/ // (Phase 2) permission guards & services
- health/ // basic health endpoint (added)
- media/ // media manager module (phase 3)
- audit/ // audit module (phase 3)
- ... other domain modules
- shared/
- prisma.service.ts
- ...
3) Source code (what was added/changed)
---------------------------------------
- prisma/schema.prisma (UPDATED): replaced with production-ready schema containing:
User, Role, Permission, RolePermission, UserRole, AuthGroupRoleMapping, AuditLog
- prisma/migrations/0001_init/migration.sql (ADDED): initial SQL migration DDL
- prisma/seed.ts (UPDATED): seed script that creates default roles, permissions, role-permission assignments, and Authentik group -> role mappings
- src/modules/health/* (ADDED): HealthModule, Controller, Service (thin controller)
- src/shared/prisma.service.ts (EXISTING): PrismaService (already present)
4) Database schema
------------------
The Prisma schema (prisma/schema.prisma) is the authoritative schema. High-level model summary:
- User: id (uuid), externalId (unique), email, name, picture, metadata, createdAt, updatedAt
- Role: id, name (unique), displayName, description
- Permission: id, name (unique), description
- RolePermission: join table role <-> permission (unique constraint roleId+permissionId)
- UserRole: join table user <-> role with source (e.g., 'authentik') and syncedAt timestamp (unique userId+roleId)
- AuthGroupRoleMapping: mapping table authGroup (string) -> roleId. One authGroup may map to multiple roles (no uniqueness enforced on authGroup).
- AuditLog: append-only audit logs with JSON details
Indexes and constraints are included for common lookup paths. The migration SQL contains explicit DDL statements.
5) Migration
------------
- prisma/migrations/0001_init/migration.sql contains the SQL to create the schema.
- The migration file includes CREATE EXTENSION IF NOT EXISTS "pgcrypto" to provide gen_random_uuid().
- Recommended migration procedure in production:
1. Ensure backups and schedule maintenance window for initial deployment.
2. Run migrations with Prisma or psql: "psql < migration.sql" or use "prisma migrate deploy" after generating migrations from schema.prisma.
6) Seed data
------------
- prisma/seed.ts seeds the following:
- Default roles: Owner, Employee, Family
- Default permissions following resource.action naming convention (users.create, users.read, ..., scheduler.manage)
- RolePermission assignments:
- Owner receives all permissions
- Employee and Family receive reasonable subsets (editable later)
- Authentik group mappings:
- RL-Owner -> Owner
- RL-Employee -> Employee
- RL-Family -> Family
The seed script is idempotent using upsert operations.
7) API design (initial)
-----------------------
Phase 1 provides a minimal, safe public surface to verify system health and readiness.
- GET /api/health
- Returns: { status: 'ok', timestamp, service }
Identity module controllers (users, roles, permissions) are present under src/modules/identity/presentation/controllers and follow RESTful conventions, but full auth/guards are implemented in Phase 2.
8) Design decisions
-------------------
- Prisma selected for type-safe DB access and migrations. (User requested Prisma)
- UUID primary keys (Postgres gen_random_uuid()) for scalability and horizontal distribution.
- JSONB (Prisma Json) used for flexible metadata and audit details.
- AuthGroupRoleMapping stores external group string only; RayLab Core does NOT model Groups as domain entities.
- Seed sets conservative permission assignments; owners get full permissions.
- Minimal initial API surface (health) avoids unintentionally exposing functionality before auth & guards are in place.
9) Advantages
-------------
- Production-ready DB schema with indices and constraints for performance and data integrity.
- Modular NestJS structure aligned with Clean Architecture / DDD; easy to extend in subsequent phases.
- Prisma provides type-safety and reduces runtime errors.
- Seed script allows reproducible environments and CI setup.
- Audit and media tables are present to support required domain features.
10) Possible future extensions
------------------------------
Planned for Phase 2 and 3 (once Phase 1 is approved):
- OIDC validation using openid-client and configuration from env vars
- Role synchronization flow (validate token -> read groups -> map -> compute hash -> replace UserRole in transaction -> invalidate permission cache -> write audit)
- Permission cache using Redis, invalidation hooks
- Auth guards and AuthorizationService for permission checks
- Media Manager: presigned URLs, storage backend (S3/MinIO)
- Scheduler: BullMQ integration with Redis
- Monitoring: Prometheus metrics, structured logging, Sentry
11) How to run (migrate & seed)
-------------------------------
Prerequisites:
- Node 20+
- PostgreSQL reachable via DATABASE_URL env var
- Install dependencies: npm ci
Generate Prisma client and run migration:
1) Generate Prisma client:
npm run prisma:generate
2) Apply migration (development):
npm run prisma:migrate
Or run SQL directly against the database:
psql "$DATABASE_URL" -f prisma/migrations/0001_init/migration.sql
3) Run seed script:
npm run prisma:seed
4) Start the app (development):
npm run start:dev
Next steps
----------
Please review Phase 1 deliverables. After your approval I will implement Phase 2 (OIDC module, Authentik integration, Group->Role Sync, Authorization Guard, Permission Cache, Audit logging) following the same level of production-quality implementation.
+203
View File
@@ -0,0 +1,203 @@
API Spec - Contoh REST API (Bahasa Indonesia)
Ringkasan
--------
API ini adalah contoh RESTful API yang bisa dipakai untuk manajemen pengguna, item, dan pesanan. Menggunakan JSON untuk request/response. Autentikasi memakai Bearer Token (JWT).
Base URL
--------
- https://api.example.com/v1
Header Umum
-----------
- Authorization: Bearer <token> (kecuali endpoint login/register)
- Content-Type: application/json
- Accept: application/json
Autentikasi
-----------
- POST /auth/login
- Body (application/json):
{
"email": "user@example.com",
"password": "string"
}
- Response 200:
{
"access_token": "<jwt_token>",
"token_type": "Bearer",
"expires_in": 3600
}
- Kesalahan: 400 (invalid input), 401 (invalid credentials)
- POST /auth/register
- Body:
{
"name": "Nama User",
"email": "user@example.com",
"password": "password123"
}
- Response 201: user created (id, name, email)
Endpoint Pengguna (Users)
-------------------------
1) GET /users
- Deskripsi: Mendapatkan daftar pengguna (admin)
- Query params:
- page (int, optional, default=1)
- per_page (int, optional, default=20, max=100)
- sort (string, optional, contoh: "created_at:desc")
- q (string, optional) - pencarian nama/email
- Response 200:
{
"data": [ {"id":1, "name":"...", "email":"..."} , ...],
"meta": {"page":1, "per_page":20, "total":123}
}
2) GET /users/{id}
- Path params:
- id (integer, required)
- Response 200: user object
- Errors: 404 jika tidak ditemukan
3) PUT /users/{id}
- Path params: id
- Body (application/json):
{
"name": "Nama Baru",
"email": "email@baru.com"
}
- Validasi: email harus format valid; name min 2 karakter
- Response 200: updated user
4) DELETE /users/{id}
- Path params: id
- Response 204: no content
- Permissions: hanya admin
Endpoint Item (Items)
---------------------
1) GET /items
- Query params:
- page, per_page, sort (lihat Users)
- category (string, optional)
- min_price, max_price (decimal, optional)
- Response: list items dengan fields id, name, description, price, stock
2) POST /items
- Body:
{
"name": "Nama Item",
"description": "Deskripsi...",
"price": 125000.50,
"stock": 10,
"category": "Elektronik"
}
- Validasi:
- name (required, max 255)
- price (required, >=0)
- stock (integer, >=0)
- Response 201: created item
- Permissions: admin atau vendor
3) GET /items/{id}
- Response 200: item object
- 404 jika tidak ditemukan
4) PUT /items/{id}
- Body: fields yang boleh diupdate (name, description, price, stock, category)
- Response 200
5) DELETE /items/{id}
- Response 204
Endpoint Pesanan (Orders)
-------------------------
1) POST /orders
- Body:
{
"user_id": 12, // optional jika token sudah mewakili user
"items": [
{"item_id": 5, "quantity": 2},
{"item_id": 7, "quantity": 1}
],
"shipping_address": "Alamat lengkap",
"note": "Catatan opsional"
}
- Validasi: setiap item quantity >= 1 dan tersedia di stock
- Response 201:
{
"order_id": 987,
"status": "pending",
"total": 375000.00
}
2) GET /orders/{id}
- Path param: id
- Response 200: full order detail (items, prices, shipping, status)
- Permissions: hanya pemilik order atau admin
3) PATCH /orders/{id}/status
- Body: {"status": "shipped"}
- Allowed status: pending, confirmed, shipped, delivered, cancelled
- Permissions: hanya admin atau staff
Format Tanggal dan Numerik
--------------------------
- Tanggal/waktu: ISO 8601 (UTC), contoh: "2024-08-01T12:34:56Z"
- Desimal: titik sebagai pemisah desimal, misal 125000.50
Pagination
----------
- Gunakan page & per_page
- Meta object harus mengandung total, page, per_page, total_pages
Response Error Umum
-------------------
- 400 Bad Request - payload tidak valid
{
"error": "invalid_request",
"message": "Deskripsi kesalahan",
"details": { "field": ["pesan validasi"] }
}
- 401 Unauthorized - token tidak ada/invalid/expired
- 403 Forbidden - tidak cukup izin
- 404 Not Found
- 429 Too Many Requests - rate limit
- 500 Internal Server Error
Rate Limiting
-------------
- Contoh: 1000 requests per 1 jam per API key
- Header terkait:
- X-RateLimit-Limit: 1000
- X-RateLimit-Remaining: 750
- X-RateLimit-Reset: 1650000000 (epoch seconds)
Keamanan dan Persyaratan
------------------------
- Semua permintaan harus lewat HTTPS (TLS 1.2+)
- Gunakan header Authorization: Bearer <token>
- CORS: domain yang diijinkan harus didaftarkan
- Validasi input di server (length, tipe, range)
- Sanitasi data untuk mencegah injection
Versioning
----------
- Versi di URL: /v1/
- Buat v2 jika ada breaking change
Contoh Request/Response (cURL)
-----------------------------
Login:
curl -X POST "https://api.example.com/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
Ambil daftar item:
curl "https://api.example.com/v1/items?page=1&per_page=20" \
-H "Authorization: Bearer <token>"
Footer
------
Spesifikasi ini adalah contoh umum. Sesuaikan endpoint, nama field, aturan autentikasi, dan kebijakan rate limit sesuai kebutuhan aplikasi Anda.
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.spec.ts', '**/?(*.)+(spec|test).ts'],
testPathIgnorePatterns: ['/tests/api/', '/tests/integration/'],
moduleFileExtensions: ['ts', 'js', 'json'],
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
},
};
+680 -7
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.379.0",
"@nestjs/common": "^10.4.20",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.20",
@@ -18,13 +19,16 @@
"@nestjs/platform-express": "^10.4.20",
"@nestjs/swagger": "^7.4.2",
"@prisma/client": "^5.22.0",
"axios": "^1.4.0",
"bcrypt": "^5.1.1",
"bullmq": "^1.73.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^16.6.1",
"ioredis": "^5.3.2",
"jose": "^6.2.6",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.4",
"openid-client": "^6.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -61,6 +65,314 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.24",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.24.tgz",
"integrity": "sha512-7TWLjypP8kk3savsDBRuhZJx7mBuFFA2136BQhwwLllsAnO4Tmq/p+SXZaNxbuulkzUFz3BZzj0bb4YzexZcNQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1101.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1101.0.tgz",
"integrity": "sha512-16EFb1aTEBgPcfUAWAjjlB57IZCyn7B3rlfT+xqE7M6WoH8AMMU3vFZO0UOitwh/xvvzVx73YED1/n0PU4qBMw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.24",
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/credential-provider-node": "^3.972.76",
"@aws-sdk/middleware-sdk-s3": "^3.972.70",
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.977.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz",
"integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.2",
"@aws-sdk/xml-builder": "^3.972.37",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.31.1",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.65",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.65.tgz",
"integrity": "sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.67",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.67.tgz",
"integrity": "sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.10.tgz",
"integrity": "sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/credential-provider-env": "^3.972.65",
"@aws-sdk/credential-provider-http": "^3.972.67",
"@aws-sdk/credential-provider-login": "^3.972.72",
"@aws-sdk/credential-provider-process": "^3.972.65",
"@aws-sdk/credential-provider-sso": "^3.973.9",
"@aws-sdk/credential-provider-web-identity": "^3.972.71",
"@aws-sdk/nested-clients": "^3.997.39",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.72.tgz",
"integrity": "sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/nested-clients": "^3.997.39",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.76.tgz",
"integrity": "sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.65",
"@aws-sdk/credential-provider-http": "^3.972.67",
"@aws-sdk/credential-provider-ini": "^3.973.10",
"@aws-sdk/credential-provider-process": "^3.972.65",
"@aws-sdk/credential-provider-sso": "^3.973.9",
"@aws-sdk/credential-provider-web-identity": "^3.972.71",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.65",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.65.tgz",
"integrity": "sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.9.tgz",
"integrity": "sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/nested-clients": "^3.997.39",
"@aws-sdk/token-providers": "3.1100.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.71",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.71.tgz",
"integrity": "sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/nested-clients": "^3.997.39",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.70",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.70.tgz",
"integrity": "sha512-APdP0iODt39AkjCjzTFIoFrxDH/Cz3CpWRDKLcsJg7eOnfE1htkxL9BhDoe/xL7cXdoMwh2HBYv3DiT1uf64NQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.39.tgz",
"integrity": "sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz",
"integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.2",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1100.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1100.0.tgz",
"integrity": "sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/nested-clients": "^3.997.39",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz",
"integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz",
"integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1278,6 +1590,84 @@
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@nestjs/common": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz",
@@ -1674,6 +2064,87 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@smithy/core": {
"version": "3.31.1",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz",
"integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.16",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz",
"integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.6.13",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz",
"integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.9.13",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz",
"integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.12",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz",
"integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz",
"integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@@ -2480,8 +2951,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
@@ -2491,6 +2961,18 @@
"node": ">=8.0.0"
}
},
"node_modules/axios": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2688,6 +3170,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -2773,6 +3261,78 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/bullmq": {
"version": "1.91.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-1.91.1.tgz",
"integrity": "sha512-u7dat9I8ZwouZ651AMZkBSvB6NVUPpnAjd4iokd9DM41whqIBnDjuL11h7+kEjcpiDKj6E+wxZiER00FqirZQg==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.6.0",
"get-port": "6.1.2",
"glob": "^8.0.3",
"ioredis": "^5.2.2",
"lodash": "^4.17.21",
"msgpackr": "^1.6.2",
"semver": "^7.3.7",
"tslib": "^2.0.0",
"uuid": "^9.0.0"
}
},
"node_modules/bullmq/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/bullmq/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/bullmq/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bullmq/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -3028,7 +3588,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -3157,6 +3716,19 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"deprecated": "v4 is no longer maintained, upgrade to v5",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3220,7 +3792,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -3426,7 +3997,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -3943,11 +4513,30 @@
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -4111,6 +4700,18 @@
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-6.1.2.tgz",
"integrity": "sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -4240,7 +4841,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
@@ -5462,6 +6062,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -5668,6 +6277,37 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/msgpackr": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz",
"integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
@@ -5729,6 +6369,21 @@
}
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -5744,6 +6399,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -6326,6 +6990,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+6 -1
View File
@@ -47,7 +47,12 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"ioredis": "^5.3.2",
"bullmq": "^1.73.0",
"nodemailer": "^6.9.4",
"axios": "^1.4.0",
"@aws-sdk/client-s3": "^3.379.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
+112
View File
@@ -0,0 +1,112 @@
-- Initial migration for RayLab Core (PostgreSQL)
-- Requires: CREATE EXTENSION IF NOT EXISTS pgcrypto; (for gen_random_uuid())
BEGIN;
-- Extension for UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Enum types
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'userrolesource') THEN
CREATE TYPE "UserRoleSource" AS ENUM ('AUTHENTIK', 'SYSTEM');
END IF;
END $$;
-- Users
CREATE TABLE IF NOT EXISTS "User" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_id text UNIQUE,
authentik_user_id text UNIQUE,
authentik_subject text UNIQUE,
username varchar(255) UNIQUE,
email varchar(320) UNIQUE,
name varchar(255),
picture text,
is_active boolean NOT NULL DEFAULT true,
deleted_at timestamptz,
last_seen_at timestamptz,
last_synced_at timestamptz,
sync_status text,
storage_quota bigint DEFAULT 10737418240,
storage_used bigint DEFAULT 0,
metadata jsonb,
last_group_hash text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_authentik_id ON "User" (authentik_id);
CREATE INDEX IF NOT EXISTS idx_user_username ON "User" (username);
CREATE INDEX IF NOT EXISTS idx_user_email ON "User" (email);
-- Roles
CREATE TABLE IF NOT EXISTS "Role" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(100) NOT NULL UNIQUE,
name varchar(255) NOT NULL,
display_name varchar(255),
description text,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Permissions
CREATE TABLE IF NOT EXISTS "Permission" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(150) NOT NULL UNIQUE,
name varchar(255) NOT NULL,
display_name varchar(255),
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- RolePermission
CREATE TABLE IF NOT EXISTS "RolePermission" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
permission_id uuid NOT NULL REFERENCES "Permission"(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_role_permission UNIQUE (role_id, permission_id)
);
CREATE INDEX IF NOT EXISTS idx_rolepermission_role_id ON "RolePermission" (role_id);
CREATE INDEX IF NOT EXISTS idx_rolepermission_permission_id ON "RolePermission" (permission_id);
-- UserRole
CREATE TABLE IF NOT EXISTS "UserRole" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
source "UserRoleSource" NOT NULL DEFAULT 'AUTHENTIK',
synced_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_user_role UNIQUE (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_userrole_user_id ON "UserRole" (user_id);
CREATE INDEX IF NOT EXISTS idx_userrole_role_id ON "UserRole" (role_id);
-- AuthGroupRoleMapping
CREATE TABLE IF NOT EXISTS "AuthGroupRoleMapping" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
auth_group varchar(255) NOT NULL,
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_authgroup_name ON "AuthGroupRoleMapping" (auth_group);
-- AuditLog
CREATE TABLE IF NOT EXISTS "AuditLog" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES "User"(id) ON DELETE SET NULL,
action varchar(200) NOT NULL,
resource varchar(200),
resource_id text,
details jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_auditlog_user_id ON "AuditLog" (user_id);
CREATE INDEX IF NOT EXISTS idx_auditlog_action ON "AuditLog" (action);
COMMIT;
@@ -0,0 +1,37 @@
-- Phase 4 migration: add ScheduledJob and MediaObject
BEGIN;
-- ScheduledJob
CREATE TABLE IF NOT EXISTS "ScheduledJob" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(255) NOT NULL,
payload jsonb,
cron varchar(255),
run_at timestamptz,
status varchar(50) NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
last_run_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_scheduledjob_name ON "ScheduledJob" (name);
CREATE INDEX IF NOT EXISTS idx_scheduledjob_status ON "ScheduledJob" (status);
-- MediaObject
CREATE TABLE IF NOT EXISTS "MediaObject" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id uuid REFERENCES "User"(id) ON DELETE SET NULL,
storage_key varchar(1024) NOT NULL,
filename varchar(1024),
mime_type varchar(255),
size bigint,
is_public boolean NOT NULL DEFAULT false,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_media_owner_user_id ON "MediaObject" (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_media_storage_key ON "MediaObject" (storage_key);
COMMIT;
@@ -0,0 +1,18 @@
-- Migration: add Application table
CREATE TABLE IF NOT EXISTS "Application" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"code" varchar(100) NOT NULL UNIQUE,
"name" varchar(255) NOT NULL,
"description" text,
"icon" text,
"url" text,
"applicationsClaim" varchar(255) NOT NULL UNIQUE,
"isActive" boolean NOT NULL DEFAULT true,
"displayOrder" integer NOT NULL DEFAULT 0,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "Application_code_idx" ON "Application" ("code");
CREATE INDEX IF NOT EXISTS "Application_applicationsClaim_idx" ON "Application" ("applicationsClaim");
+135 -64
View File
@@ -1,5 +1,5 @@
// Prisma schema
// Basic User model for RayLab Core
// Prisma schema for RayLab Core - Phase 1
// PostgreSQL datasource. DATABASE_URL must be set in environment.
generator client {
provider = "prisma-client-js"
@@ -11,99 +11,170 @@ datasource db {
}
model User {
id String @id @default(uuid())
id String @id @default(uuid()) @db.Uuid
authentikId String? @unique
authentikUserId String? @unique
authentikSubject String? @unique
username String @unique
email String @unique
username String? @db.VarChar(255) @unique
email String? @db.VarChar(320) @unique
name String? @db.VarChar(255)
picture String?
isActive Boolean @default(true)
// storage (in bytes)
storageQuota BigInt @default(10737418240)
storageUsed BigInt @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
lastSeenAt DateTime?
lastSyncedAt DateTime?
syncStatus String?
storageQuota BigInt? @default(10737418240)
storageUsed BigInt? @default(0)
metadata Json?
userRoles UserRole[]
auditLogs AuditLog[]
lastGroupHash String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
permissions UserPermission[]
@@index([authentikId])
@@index([username])
@@index([email])
}
model Role {
id String @id @default(uuid())
code String @unique
name String
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(100)
name String @db.VarChar(255)
displayName String? @db.VarChar(255)
description String?
isDefault Boolean @default(false)
rolePermissions RolePermission[]
userRoles UserRole[]
mappings AuthGroupRoleMapping[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users UserRole[]
permissions RolePermission[]
}
model Permission {
id String @id @default(uuid())
code String @unique
name String
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(150)
name String @db.VarChar(255)
displayName String? @db.VarChar(255)
description String?
rolePermissions RolePermission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles RolePermission[]
users UserPermission[]
}
model UserRole {
userId String
roleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([userId, roleId])
}
model RolePermission {
roleId String
permissionId String
id String @id @default(uuid()) @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId String @db.Uuid
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
permissionId String @db.Uuid
createdAt DateTime @default(now())
assignedAt DateTime @default(now())
assignedBy String
@@id([roleId, permissionId])
@@unique([roleId, permissionId])
@@index([roleId])
@@index([permissionId])
}
model UserPermission {
userId String
permissionId String
enum UserRoleSource {
AUTHENTIK
SYSTEM
}
model UserRole {
id String @id @default(uuid()) @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
userId String @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId String @db.Uuid
source UserRoleSource @default(AUTHENTIK)
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
assignedAt DateTime @default(now())
assignedBy String
@@id([userId, permissionId])
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
}
model AuthGroupRoleMapping {
id String @id @default(uuid()) @db.Uuid
authGroup String @db.VarChar(255)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authGroup])
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String? @db.Uuid
action String @db.VarChar(200)
resource String? @db.VarChar(200)
resourceId String?
details Json?
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
}
model ScheduledJob {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(255)
payload Json?
cron String?
runAt DateTime?
status String @db.VarChar(50) @default("pending")
attempts Int @default(0)
lastRunAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
@@index([status])
}
model MediaObject {
id String @id @default(uuid()) @db.Uuid
ownerUserId String? @db.Uuid
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
storageKey String @db.VarChar(1024)
filename String? @db.VarChar(1024)
mimeType String? @db.VarChar(255)
size BigInt?
isPublic Boolean @default(false)
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ownerUserId])
@@index([storageKey])
}
model Application {
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(100)
name String @db.VarChar(255)
description String?
icon String?
url String?
applicationsClaim String @unique @db.VarChar(255)
isActive Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([code])
@@index([applicationsClaim])
}
+171 -1
View File
@@ -1 +1,171 @@
// prisma seed placeholder
/**
* Prisma seed script for RayLab Core - Phase 1
* Run with: npx ts-node prisma/seed.ts
* Requires DATABASE_URL env var.
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding default roles, permissions, and mappings...');
// Default roles
const roles = [
{ name: 'Owner', displayName: 'Owner', description: 'Platform owner with full access' },
{ name: 'Employee', displayName: 'Employee', description: 'Employee role with internal permissions' },
{ name: 'Family', displayName: 'Family', description: 'Family role with limited access' },
];
for (const r of roles) {
const code = r.name.toLowerCase();
await prisma.role.upsert({
where: { code },
update: { name: r.name, displayName: r.displayName, description: r.description },
create: { code, name: r.name, displayName: r.displayName, description: r.description },
});
}
// Default permissions (resource.action lowercase as requested)
const permissions = [
'users.create',
'users.read',
'users.update',
'users.delete',
'roles.create',
'roles.read',
'roles.update',
'roles.delete',
'permissions.create',
'permissions.read',
'permissions.update',
'permissions.delete',
'audit.read',
'storage.read',
'storage.write',
'storage.delete',
'media.read',
'media.write',
'media.delete',
'scheduler.read',
'scheduler.manage',
];
for (const p of permissions) {
// Use code as canonical identifier (resource.action). Use name == code for now.
const code = p;
await prisma.permission.upsert({
where: { code },
update: { name: p, description: null },
create: { code, name: p, description: null },
});
}
// Assign permissions to roles (example mapping)
const owner = await prisma.role.findUnique({ where: { code: 'owner' } });
const employee = await prisma.role.findUnique({ where: { code: 'employee' } });
const family = await prisma.role.findUnique({ where: { code: 'family' } });
if (!owner || !employee || !family) {
throw new Error('Default roles missing after upsert');
}
// Owner -> all permissions
const allPerms = await prisma.permission.findMany();
for (const perm of allPerms) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: owner.id, permissionId: perm.id } },
update: {},
create: { roleId: owner.id, permissionId: perm.id },
});
}
// Employee -> a subset
const employeePerms = [
'users.read',
'permissions.read',
'scheduler.read',
'scheduler.manage',
'media.read',
'media.write',
];
for (const p of employeePerms) {
const perm = await prisma.permission.findUnique({ where: { code: p } });
if (perm) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: employee.id, permissionId: perm.id } },
update: {},
create: { roleId: employee.id, permissionId: perm.id },
});
}
}
// Family -> limited
const familyPerms = [
'media.read',
'media.write',
'media.delete',
];
for (const p of familyPerms) {
const perm = await prisma.permission.findUnique({ where: { code: p } });
if (perm) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: family.id, permissionId: perm.id } },
update: {},
create: { roleId: family.id, permissionId: perm.id },
});
}
}
// Authentik group mappings
const mappings = [
{ authGroup: 'RL-Owner', roleName: 'Owner' },
{ authGroup: 'RL-Employee', roleName: 'Employee' },
{ authGroup: 'RL-Family', roleName: 'Family' },
];
for (const m of mappings) {
const role = await prisma.role.findUnique({ where: { name: m.roleName } });
if (!role) continue;
// Ensure mapping exists (authGroup + roleId). AuthGroup can map to multiple roles.
const existing = await prisma.authGroupRoleMapping.findFirst({ where: { authGroup: m.authGroup, roleId: role.id } });
if (!existing) {
await prisma.authGroupRoleMapping.create({ data: { authGroup: m.authGroup, roleId: role.id } });
}
}
// Seed application catalog
const apps = [
{ code: 'core', name: 'Core', description: 'RayLab Core Dashboard', icon: 'mdi-apps', url: '/', applicationsClaim: 'core', displayOrder: 0 },
{ code: 'warung', name: 'Warung', description: 'Warung app', icon: 'mdi-store', url: '/warung', applicationsClaim: 'warung', displayOrder: 10 },
{ code: 'nextcloud', name: 'Nextcloud', description: 'Nextcloud', icon: 'mdi-cloud', url: 'https://nextcloud.example', applicationsClaim: 'nextcloud', displayOrder: 20 },
{ code: 'jellyfin', name: 'Jellyfin', description: 'Jellyfin media server', icon: 'mdi-movie', url: 'https://jellyfin.example', applicationsClaim: 'jellyfin', displayOrder: 30 },
{ code: 'forgejo', name: 'Forgejo', description: 'Forgejo git server', icon: 'mdi-source-repository', url: 'https://forgejo.example', applicationsClaim: 'forgejo', displayOrder: 40 },
{ code: 'immich', name: 'Immich', description: 'Immich photo server', icon: 'mdi-image', url: 'https://immich.example', applicationsClaim: 'immich', displayOrder: 50 },
{ code: 'grafana', name: 'Grafana', description: 'Grafana monitoring', icon: 'mdi-chart-line', url: 'https://grafana.example', applicationsClaim: 'grafana', displayOrder: 60 },
{ code: 'vaultwarden', name: 'Vaultwarden', description: 'Vaultwarden password manager', icon: 'mdi-lock', url: 'https://vaultwarden.example', applicationsClaim: 'vaultwarden', displayOrder: 70 },
{ code: 'homepage', name: 'Homepage', description: 'Homepage', icon: 'mdi-home', url: 'https://home.example', applicationsClaim: 'homepage', displayOrder: 80 },
];
for (const a of apps) {
await prisma.application.upsert({
where: { code: a.code },
update: { name: a.name, description: a.description, icon: a.icon, url: a.url, applicationsClaim: a.applicationsClaim, isActive: true, displayOrder: a.displayOrder },
create: { code: a.code, name: a.name, description: a.description, icon: a.icon, url: a.url, applicationsClaim: a.applicationsClaim, isActive: true, displayOrder: a.displayOrder },
});
}
console.log('Seeding completed.');
}
main()
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+11
View File
@@ -3,6 +3,11 @@ import { ConfigModule } from '@nestjs/config';
import { IdentityModule } from './modules/identity/identity.module';
import { AuthModule } from './modules/auth/auth.module';
import { HealthModule } from './modules/health/health.module';
import { AuthorizationModule } from './modules/authorization/authorization.module';
import { AuditModule } from './modules/audit/audit.module';
import { ApplicationModule } from './modules/application/application.module';
@Module({
imports: [
@@ -12,6 +17,12 @@ import { AuthModule } from './modules/auth/auth.module';
IdentityModule,
AuthModule,
HealthModule,
// Authorization module provides permission checks and cache
AuthorizationModule,
AuditModule,
ApplicationModule,
],
})
export class AppModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEnvelope } from './event.interface';
type Handler = (event: EventEnvelope<any>) => Promise<void> | void;
@Injectable()
export class EventBus {
private handlers: Map<string, Handler[]> = new Map();
private readonly logger = new Logger(EventBus.name);
publish(event: EventEnvelope<any>) {
const handlers = this.handlers.get(event.type) || [];
for (const h of handlers) {
try {
Promise.resolve(h(event)).catch((err) => this.logger.error('Event handler error', err));
} catch (e) {
this.logger.error('Event handler threw', e as any);
}
}
}
subscribe(eventType: string, handler: Handler) {
const list = this.handlers.get(eventType) || [];
list.push(handler);
this.handlers.set(eventType, list);
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface EventEnvelope<T = any> {
id: string;
timestamp: string; // ISO
type: string; // PascalCase event type
payload: T;
}
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { ApplicationsController } from './presentation/controllers/applications.controller';
import { PrismaApplicationRepository } from './infrastructure/repositories/prisma-application.repository';
import { IApplication } from './domain/repositories/application.interface';
import { GetApplicationsHandler } from './application/handlers/get-applications.handler';
import { GetApplicationHandler } from './application/handlers/get-application.handler';
import { CreateApplicationHandler } from './application/handlers/create-application.handler';
import { UpdateApplicationHandler } from './application/handlers/update-application.handler';
import { DeleteApplicationHandler } from './application/handlers/delete-application.handler';
import { GetMeApplicationsHandler } from './application/handlers/get-me-applications.handler';
import { ApplicationValidator } from './application/validators/application.validator';
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
import { CurrentUserGuard } from '../identity/presentation/guards/current-user.guard';
@Module({
imports: [],
providers: [
PrismaService,
PrismaApplicationRepository,
GetApplicationsHandler,
GetApplicationHandler,
CreateApplicationHandler,
UpdateApplicationHandler,
DeleteApplicationHandler,
GetMeApplicationsHandler,
ApplicationValidator,
JwtAuthGuard,
CurrentUserGuard,
{
provide: IApplication,
useClass: PrismaApplicationRepository,
},
],
controllers: [ApplicationsController],
})
export class ApplicationModule {}
@@ -0,0 +1,26 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
import { ApplicationValidator } from '../../application/validators/application.validator';
import { ApplicationData } from '../../domain/entities/application.entity';
@Injectable()
export class CreateApplicationHandler {
constructor(private readonly appRepo: IApplication, private readonly validator: ApplicationValidator) {}
async execute(payload: any) {
await this.validator.validateCreate(payload);
const app = ApplicationData.create({
code: payload.code,
name: payload.name,
description: payload.description,
icon: payload.icon,
url: payload.url,
applicationsClaim: payload.applicationsClaim,
displayOrder: payload.displayOrder,
});
const created = await this.appRepo.create(app);
return created;
}
}
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class DeleteApplicationHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(id: string) {
// ensure exists
await this.appRepo.findById(id);
await this.appRepo.delete(id);
}
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class GetApplicationHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(id: string) {
const app = await this.appRepo.findById(id);
return app;
}
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class GetApplicationsHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(query: { page?: number; limit?: number; search?: string }) {
const res = await this.appRepo.find({ page: query.page, limit: query.limit, search: query.search || null });
return res;
}
}
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class GetMeApplicationsHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(applicationsClaimList: string[] | null | undefined) {
if (!applicationsClaimList || applicationsClaimList.length === 0) return { data: [], total: 0 };
// Only return active applications whose applicationsClaim exists in provided list
const res = await this.appRepo.find({ page: 1, limit: 1000, isActive: true, applicationsClaimIn: applicationsClaimList });
// sort by displayOrder asc
res.data.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
return res;
}
}
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
import { ApplicationValidator } from '../../application/validators/application.validator';
@Injectable()
export class UpdateApplicationHandler {
constructor(private readonly appRepo: IApplication, private readonly validator: ApplicationValidator) {}
async execute(id: string, payload: any) {
const existing = await this.appRepo.findById(id);
if (!existing) throw new Error('Application not found');
await this.validator.validateUpdate(id, payload);
existing.update(payload);
const updated = await this.appRepo.update(existing);
return updated;
}
}
@@ -0,0 +1,64 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class ApplicationValidator {
constructor(private readonly appRepo: IApplication) {}
async validateCreate(payload: any) {
if (!payload || !payload.code) throw new BadRequestException('code is required');
if (!payload.name) throw new BadRequestException('name is required');
if (!payload.applicationsClaim) throw new BadRequestException('applicationsClaim is required');
// url validation if provided
if (payload.url) {
try {
// allow relative urls
if (!payload.url.startsWith('/') && !payload.url.startsWith('http')) {
throw new Error('invalid');
}
// new URL(payload.url) // avoid throwing for relative
} catch (e) {
throw new BadRequestException('invalid url');
}
}
// displayOrder
if (payload.displayOrder !== undefined && typeof payload.displayOrder !== 'number') {
throw new BadRequestException('invalid displayOrder');
}
// unique code
const byCode = await this.appRepo.findByCode(payload.code);
if (byCode) throw new BadRequestException('duplicate code');
const byClaim = await this.appRepo.findByApplicationsClaim(payload.applicationsClaim);
if (byClaim) throw new BadRequestException('duplicate applicationsClaim');
}
async validateUpdate(id: string, payload: any) {
if (!payload) return;
if (payload.url) {
try {
if (!payload.url.startsWith('/') && !payload.url.startsWith('http')) throw new Error('invalid');
} catch (e) {
throw new BadRequestException('invalid url');
}
}
if (payload.displayOrder !== undefined && typeof payload.displayOrder !== 'number') {
throw new BadRequestException('invalid displayOrder');
}
if (payload.code) {
const existing = await this.appRepo.findByCode(payload.code);
if (existing && existing.id !== id) throw new BadRequestException('duplicate code');
}
if (payload.applicationsClaim) {
const existing = await this.appRepo.findByApplicationsClaim(payload.applicationsClaim);
if (existing && existing.id !== id) throw new BadRequestException('duplicate applicationsClaim');
}
}
}
@@ -0,0 +1,100 @@
export class ApplicationData {
private constructor(
public readonly id: string,
public code: string,
public name: string,
public description: string | null,
public icon: string | null,
public url: string | null,
public applicationsClaim: string,
public isActive: boolean,
public displayOrder: number,
public createdAt: Date | null,
public updatedAt: Date | null,
) {}
static create(data: {
code: string;
name: string;
description?: string | null;
icon?: string | null;
url?: string | null;
applicationsClaim: string;
displayOrder?: number;
}) {
return new ApplicationData(
crypto.randomUUID(),
data.code,
data.name,
data.description ?? null,
data.icon ?? null,
data.url ?? null,
data.applicationsClaim,
true,
data.displayOrder ?? 0,
new Date(),
new Date(),
);
}
static restore(props: {
id: string;
code: string;
name: string;
description?: string | null;
icon?: string | null;
url?: string | null;
applicationsClaim: string;
isActive?: boolean;
displayOrder?: number;
createdAt?: Date | null;
updatedAt?: Date | null;
}) {
return new ApplicationData(
props.id,
props.code,
props.name,
props.description ?? null,
props.icon ?? null,
props.url ?? null,
props.applicationsClaim,
props.isActive !== undefined ? props.isActive : true,
props.displayOrder ?? 0,
props.createdAt ?? null,
props.updatedAt ?? null,
);
}
update(data: {
code?: string;
name?: string;
description?: string | null;
icon?: string | null;
url?: string | null;
applicationsClaim?: string;
isActive?: boolean;
displayOrder?: number;
}) {
if (data.code !== undefined) this.code = data.code;
if (data.name !== undefined) this.name = data.name;
if (data.description !== undefined) this.description = data.description;
if (data.icon !== undefined) this.icon = data.icon;
if (data.url !== undefined) this.url = data.url;
if (data.applicationsClaim !== undefined) this.applicationsClaim = data.applicationsClaim;
if (data.isActive !== undefined) this.isActive = data.isActive;
if (data.displayOrder !== undefined) this.displayOrder = data.displayOrder;
this.updatedAt = new Date();
}
toResponse() {
return {
id: this.id,
code: this.code,
name: this.name,
description: this.description,
icon: this.icon,
url: this.url,
displayOrder: this.displayOrder,
};
}
}
@@ -0,0 +1,12 @@
import { ApplicationData } from '../entities/application.entity';
export abstract class IApplication {
abstract find(params: { page?: number; limit?: number; search?: string | null; isActive?: boolean | null; applicationsClaimIn?: string[] | null }): Promise<{ data: ApplicationData[]; total: number }>;
abstract findById(id: string): Promise<ApplicationData>;
abstract findByCode(code: string): Promise<ApplicationData | null>;
abstract findByApplicationsClaim(claim: string): Promise<ApplicationData | null>;
abstract create(app: ApplicationData): Promise<ApplicationData>;
abstract update(app: ApplicationData): Promise<ApplicationData>;
abstract delete(appId: string): Promise<void>;
}
@@ -0,0 +1,19 @@
import { ApplicationData } from '../../domain/entities/application.entity';
export class PrismaApplicationMapper {
static toDomain(model: any): ApplicationData {
return ApplicationData.restore({
id: model.id,
code: model.code,
name: model.name,
description: model.description,
icon: model.icon,
url: model.url,
applicationsClaim: model.applicationsClaim,
isActive: model.isActive,
displayOrder: model.displayOrder,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
});
}
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { IApplication } from '../../domain/repositories/application.interface';
import { PrismaApplicationMapper } from '../mappers/prisma-application.mapper';
import { ApplicationData } from '../../domain/entities/application.entity';
@Injectable()
export class PrismaApplicationRepository implements IApplication {
constructor(private readonly prisma: PrismaService) {}
async find(params: { page?: number; limit?: number; search?: string | null; isActive?: boolean | null; applicationsClaimIn?: string[] | null }) {
const page = params.page && params.page > 0 ? params.page : 1;
const limit = params.limit && params.limit > 0 ? params.limit : 25;
const where: any = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ code: { contains: params.search, mode: 'insensitive' } },
{ description: { contains: params.search, mode: 'insensitive' } },
];
}
if (params.isActive !== undefined && params.isActive !== null) {
where.isActive = params.isActive;
}
if (params.applicationsClaimIn && params.applicationsClaimIn.length > 0) {
where.applicationsClaim = { in: params.applicationsClaimIn };
}
const [total, items] = await Promise.all([
this.prisma.application.count({ where }),
this.prisma.application.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { displayOrder: 'asc', createdAt: 'asc' } }),
]);
return { data: items.map(i => PrismaApplicationMapper.toDomain(i)), total };
}
async findById(id: string) {
const row = await this.prisma.application.findUnique({ where: { id } });
if (!row) throw new Error('Application not found');
return PrismaApplicationMapper.toDomain(row);
}
async findByCode(code: string) {
const row = await this.prisma.application.findUnique({ where: { code } });
if (!row) return null;
return PrismaApplicationMapper.toDomain(row);
}
async findByApplicationsClaim(claim: string) {
const row = await this.prisma.application.findUnique({ where: { applicationsClaim: claim } });
if (!row) return null;
return PrismaApplicationMapper.toDomain(row);
}
async create(app: ApplicationData) {
const created = await this.prisma.application.create({ data: {
id: app.id,
code: app.code,
name: app.name,
description: app.description,
icon: app.icon,
url: app.url,
applicationsClaim: app.applicationsClaim,
isActive: app.isActive,
displayOrder: app.displayOrder,
} });
return PrismaApplicationMapper.toDomain(created);
}
async update(app: ApplicationData) {
const updated = await this.prisma.application.update({ where: { id: app.id }, data: {
code: app.code,
name: app.name,
description: app.description,
icon: app.icon,
url: app.url,
applicationsClaim: app.applicationsClaim,
isActive: app.isActive,
displayOrder: app.displayOrder,
} });
return PrismaApplicationMapper.toDomain(updated);
}
async delete(appId: string) {
await this.prisma.application.delete({ where: { id: appId } });
}
}
@@ -0,0 +1,76 @@
import { Controller, Get, Param, UseGuards, Query, Patch, Delete, Post, Body, Req } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
import { CurrentUserGuard } from '../../../identity/presentation/guards/current-user.guard';
import { GetApplicationsHandler } from '../../application/handlers/get-applications.handler';
import { GetApplicationHandler } from '../../application/handlers/get-application.handler';
import { CreateApplicationHandler } from '../../application/handlers/create-application.handler';
import { UpdateApplicationHandler } from '../../application/handlers/update-application.handler';
import { DeleteApplicationHandler } from '../../application/handlers/delete-application.handler';
import { GetMeApplicationsHandler } from '../../application/handlers/get-me-applications.handler';
@ApiTags('Applications')
@Controller()
export class ApplicationsController {
constructor(
private readonly getApplicationsHandler: GetApplicationsHandler,
private readonly getApplicationHandler: GetApplicationHandler,
private readonly createApplicationHandler: CreateApplicationHandler,
private readonly updateApplicationHandler: UpdateApplicationHandler,
private readonly deleteApplicationHandler: DeleteApplicationHandler,
private readonly getMeApplicationsHandler: GetMeApplicationsHandler,
) {}
@Get('applications')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'List applications' })
async findAll(@Query() query: any) {
const res = await this.getApplicationsHandler.execute({ page: query.page, limit: query.limit, search: query.search });
return { success: true, data: res.data.map(a => a.toResponse ? a.toResponse() : a), meta: { total: res.total } };
}
@Get('applications/:id')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Get application by id' })
async findOne(@Param('id') id: string) {
const app = await this.getApplicationHandler.execute(id);
return { success: true, data: app.toResponse(), meta: {} };
}
@Post('applications')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Create application' })
async create(@Body() body: any) {
const created = await this.createApplicationHandler.execute(body);
return { success: true, data: created.toResponse(), meta: {} };
}
@Patch('applications/:id')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Update application' })
async update(@Param('id') id: string, @Body() body: any) {
const updated = await this.updateApplicationHandler.execute(id, body);
return { success: true, data: updated.toResponse(), meta: {} };
}
@Delete('applications/:id')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Delete application' })
async remove(@Param('id') id: string) {
await this.deleteApplicationHandler.execute(id);
return { success: true, data: null, meta: {} };
}
// Dashboard endpoint
@Get('me/applications')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: "Get current user's applications (filtered by Authentik claims)" })
async me(@Req() req: any) {
const ctx = req.raylabContext;
const identity = ctx && ctx.identity ? ctx.identity : {};
const applicationsClaimList = identity.applications || [];
const res = await this.getMeApplicationsHandler.execute(applicationsClaimList);
return { success: true, data: res.data.map(a => a.toResponse ? a.toResponse() : a), meta: { total: res.total } };
}
}
+46
View File
@@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { EventEnvelope } from '../../core/event-bus/event.interface';
import { AuditService } from './audit.service';
@Injectable()
export class AuditEventHandler {
private readonly logger = new Logger(AuditEventHandler.name);
constructor(private readonly events: EventBus, private readonly auditService: AuditService) {
this.events.subscribe('RolesSynchronized', (e) => this.handleRolesSynchronized(e));
this.events.subscribe('UserAuthenticated', (e) => this.handleUserAuthenticated(e));
this.events.subscribe('RoleCreated', (e) => this.handleGeneric(e));
this.events.subscribe('RoleUpdated', (e) => this.handleGeneric(e));
this.events.subscribe('RoleDeleted', (e) => this.handleGeneric(e));
this.events.subscribe('PermissionCreated', (e) => this.handleGeneric(e));
this.events.subscribe('PermissionUpdated', (e) => this.handleGeneric(e));
this.events.subscribe('PermissionDeleted', (e) => this.handleGeneric(e));
this.events.subscribe('UserActivated', (e) => this.handleGeneric(e));
this.events.subscribe('UserDeactivated', (e) => this.handleGeneric(e));
}
async handleRolesSynchronized(event: EventEnvelope<any>) {
try {
await this.auditService.createFromEvent(event);
} catch (e) {
this.logger.error('Audit handler failed', e as any);
}
}
async handleUserAuthenticated(event: EventEnvelope<any>) {
try {
await this.auditService.createFromEvent(event);
} catch (e) {
this.logger.error('Audit handler failed', e as any);
}
}
async handleGeneric(event: EventEnvelope<any>) {
try {
await this.auditService.createFromEvent(event);
} catch (e) {
this.logger.error('Audit handler failed', e as any);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditEventHandler } from './audit.event-handler';
import { PrismaService } from '../../shared/prisma.service';
import { EventBus } from '../../core/event-bus/event-bus.service';
@Module({
providers: [AuditService, AuditEventHandler, PrismaService, EventBus],
exports: [AuditService],
})
export class AuditModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { EventEnvelope } from '../../core/event-bus/event.interface';
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(private readonly prisma: PrismaService) {}
async createFromEvent(event: EventEnvelope<any>) {
try {
// Map standard events to AuditLog entries
await this.prisma.auditLog.create({
data: {
userId: event.payload?.userId || null,
action: event.type,
resource: event.payload?.resource || null,
resourceId: event.payload?.resourceId || null,
details: event.payload,
createdAt: new Date(event.timestamp),
},
});
} catch (e) {
this.logger.error('Failed to write audit log', e as any);
}
}
}
+20 -1
View File
@@ -13,10 +13,20 @@ import { IAuthConfig } from '../identity/application/config/i-auth-config';
import { EnvAuthConfig } from '../identity/application/config/env-auth-config';
import { RedisPkceStore } from './pkce/redis-pkce.store';
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
import { OidcService } from './oidc.service';
import { RoleSyncService } from './role-sync.service';
import { RedisService } from '../../shared/redis.service';
import { GroupHashService } from './group-hash.service';
import { AuthenticationService } from './authentication.service';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { AuditService } from '../audit/audit.service';
import { AuthorizationModule } from '../authorization/authorization.module';
@Module({
imports: [
ConfigModule,
AuthorizationModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
@@ -40,7 +50,16 @@ import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
// In-memory PKCE and Refresh stores (Redis removed)
RedisPkceStore,
InMemoryRefreshStore,
// OIDC & Role Sync
OidcService,
RoleSyncService,
GroupHashService,
AuthenticationService,
EventBus,
AuditService,
RedisService,
],
exports: [AuthService],
exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus],
})
export class AuthModule {}
@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { OidcService } from './oidc.service';
import { PrismaService } from '../../shared/prisma.service';
import { RoleSyncService } from './role-sync.service';
import { AuthorizationService } from '../authorization/authorization.service';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { RequestContext } from '../../shared/types/request-context';
@Injectable()
export class AuthenticationService {
private readonly logger = new Logger(AuthenticationService.name);
constructor(
private readonly oidc: OidcService,
private readonly prisma: PrismaService,
private readonly roleSync: RoleSyncService,
private readonly authorization: AuthorizationService,
private readonly events: EventBus,
) {}
async authenticate(bearerToken: string): Promise<RequestContext> {
// Validate token (signature/iss/aud/exp)
const claims = await this.oidc.verifyToken(bearerToken);
const sub = claims.sub;
if (!sub) throw new Error('Invalid token: missing sub');
// Resolve identity
const identity = { sub, email: claims.email, preferred_username: claims.preferred_username, raw: claims };
// Find or create user (materialize)
let user = await (this.prisma as any).user.findUnique({ where: { authentikId: sub } });
if (!user) {
user = await (this.prisma as any).user.create({ data: { authentikId: sub, username: identity.preferred_username || identity.email || sub, email: identity.email || null } });
}
// Synchronize roles
const groups: string[] = Array.isArray(claims.groups) ? claims.groups : [];
await this.roleSync.syncUserRolesFromAuthentik(user.id, groups);
// Load permissions
const permissions = await this.authorization.getUserPermissions(user.id);
// Build request context
const rolesRows = await (this.prisma as any).userRole.findMany({ where: { userId: user.id } });
const roles = rolesRows.map((r: any) => r.roleId);
const ctx: RequestContext = { user, identity, roles, permissions };
// Publish domain event
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'UserAuthenticated', payload: { userId: user.id, identity } });
return ctx;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
@Injectable()
export class GroupHashService {
compute(groups: string[]): string {
const sorted = (groups || []).slice().sort();
const data = sorted.join(',');
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
}
+26
View File
@@ -0,0 +1,26 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Logger, Inject } from '@nestjs/common';
import { OidcService } from '../oidc.service';
import { AuthenticationService } from '../authentication.service';
@Injectable()
export class OidcGuard implements CanActivate {
private readonly logger = new Logger(OidcGuard.name);
constructor(private readonly oidc: OidcService, private readonly authn: AuthenticationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const auth = req.headers['authorization'] || req.headers['Authorization'];
if (!auth || typeof auth !== 'string' || !auth.startsWith('Bearer ')) throw new UnauthorizedException('Missing bearer token');
const token = auth.substring(7).trim();
try {
const ctx = await this.authn.authenticate(token);
// attach context to request under a structured key
req.raylabContext = ctx;
return true;
} catch (e) {
this.logger.debug('Authentication failed', (e as any).message);
throw new UnauthorizedException('Invalid token or authentication failed');
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class OidcService {
private jwksUri: string | null = null;
private issuer: string;
private audience: string | string[] | undefined;
private logger = new Logger(OidcService.name);
constructor(private readonly config: ConfigService) {
this.issuer = this.config.get<string>('AUTHENTIK_ISSUER') || '';
this.audience = this.config.get<string>('AUTHENTIK_AUDIENCE') || undefined;
const jwksUri = this.config.get<string>('AUTHENTIK_JWKS_URI');
if (jwksUri) this.jwksUri = jwksUri;
else if (this.issuer) this.jwksUri = `${this.issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
}
async verifyToken(token: string) {
if (!this.jwksUri) throw new Error('JWKS not configured');
try {
// dynamic import to avoid ESM loading issues in test environment
const jose = await import('jose');
const jwks = jose.createRemoteJWKSet(new URL(this.jwksUri));
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: this.issuer || undefined,
audience: this.audience,
} as any);
return payload as Record<string, any>;
} catch (e) {
this.logger.debug('Token verification failed', (e as Error).message);
throw e;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { PermissionCache } from '../authorization/cache/permission-cache.interface';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { GroupHashService } from './group-hash.service';
import { PERMISSION_CACHE } from '../authorization/authorization.service';
@Injectable()
export class RoleSyncService {
private readonly logger = new Logger(RoleSyncService.name);
constructor(
private readonly prisma: PrismaService,
private readonly groupHash: GroupHashService,
@Inject(PERMISSION_CACHE) private readonly permissionCache: PermissionCache,
private readonly events: EventBus,
) {}
computeGroupHash(groups: string[]): string {
return this.groupHash.compute(groups || []);
}
async mapGroupsToRoleIds(groups: string[]): Promise<string[]> {
if (!groups || groups.length === 0) return [];
const mappings = await (this.prisma as any).authGroupRoleMapping.findMany({ where: { authGroup: { in: groups } } });
const roleIds = mappings.map((m: any) => m.roleId);
return Array.from(new Set(roleIds));
}
async syncUserRolesFromAuthentik(userId: string, groups: string[]) {
const groupHash = this.computeGroupHash(groups || []);
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
if (!user) throw new Error('User not found');
if (user.lastGroupHash === groupHash) {
this.logger.debug('Group hash unchanged, skipping sync');
return { skipped: true };
}
const roleIds = await this.mapGroupsToRoleIds(groups || []);
const previousRoleRows = await (this.prisma as any).userRole.findMany({ where: { userId, source: 'AUTHENTIK' }, select: { roleId: true } });
const previousRoles = previousRoleRows.map((r: any) => r.roleId);
await (this.prisma as any).$transaction(async (tx: any) => {
await tx.userRole.deleteMany({ where: { userId: userId, source: 'AUTHENTIK' } });
if (roleIds.length > 0) {
const createData = roleIds.map((rid: string) => ({ userId, roleId: rid, source: 'AUTHENTIK' }));
await tx.userRole.createMany({ data: createData, skipDuplicates: true } as any);
}
await tx.user.update({ where: { id: userId }, data: { lastGroupHash: groupHash } });
});
// Invalidate permission cache through abstraction
try {
await this.permissionCache.invalidate(userId);
} catch (e) {
this.logger.error('Failed to invalidate permission cache', e as any);
}
// Publish event for audit and other subscribers
this.events.publish({
id: require('crypto').randomUUID(),
timestamp: new Date().toISOString(),
type: 'RolesSynchronized',
payload: { userId, groups, assignedRoleIds: roleIds, previousRoles },
});
return { skipped: false, assignedRoleIds: roleIds };
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthorizationService, PERMISSION_CACHE } from './authorization.service';
import { PrismaService } from '../../shared/prisma.service';
import { RedisService } from '../../shared/redis.service';
import { RedisPermissionCache } from './cache/redis-permission-cache.service';
@Module({
providers: [
AuthorizationService,
PrismaService,
RedisService,
{ provide: PERMISSION_CACHE, useClass: RedisPermissionCache },
],
exports: [AuthorizationService, PERMISSION_CACHE],
})
export class AuthorizationModule {}
@@ -0,0 +1,51 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { PermissionCache } from './cache/permission-cache.interface';
export const PERMISSION_CACHE = 'PERMISSION_CACHE';
@Injectable()
export class AuthorizationService {
private readonly logger = new Logger(AuthorizationService.name);
constructor(private readonly prisma: PrismaService, @Inject(PERMISSION_CACHE) private readonly cache: PermissionCache) {}
async getUserPermissions(userId: string): Promise<string[]> {
try {
const cached = await this.cache.get(userId);
if (cached) return cached;
} catch (e) {
this.logger.debug('PermissionCache get failed', e as any);
}
const rows = await this.prisma.$queryRaw`
SELECT p.code as code
FROM "UserRole" ur
JOIN "RolePermission" rp ON rp.role_id = ur.role_id
JOIN "Permission" p ON p.id = rp.permission_id
WHERE ur.user_id = ${userId}`;
const perms = Array.isArray(rows) ? rows.map((r: any) => r.code) : [];
try {
await this.cache.set(userId, perms);
} catch (e) {
this.logger.debug('PermissionCache set failed', e as any);
}
return perms;
}
async hasPermission(userId: string, permissionCode: string): Promise<boolean> {
const perms = await this.getUserPermissions(userId);
return perms.includes(permissionCode);
}
async invalidateUserPermissions(userId: string) {
try {
await this.cache.invalidate(userId);
} catch (e) {
this.logger.debug('PermissionCache invalidate failed', e as any);
}
}
}
@@ -0,0 +1,5 @@
export interface PermissionCache {
get(userId: string): Promise<string[] | null>;
set(userId: string, permissions: string[], ttlSeconds?: number): Promise<void>;
invalidate(userId: string): Promise<void>;
}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PermissionCache } from './permission-cache.interface';
import { RedisService } from '../../../shared/redis.service';
@Injectable()
export class RedisPermissionCache implements PermissionCache {
private readonly TTL = 60 * 5;
constructor(private readonly redis: RedisService) {}
private key(userId: string) {
const { CacheKeys } = require('../../../shared/cache-keys');
return CacheKeys.permission(userId);
}
async get(userId: string): Promise<string[] | null> {
const data = await this.redis.get(this.key(userId));
if (!data) return null;
try {
return JSON.parse(data) as string[];
} catch {
return null;
}
}
async set(userId: string, permissions: string[], ttlSeconds?: number): Promise<void> {
await this.redis.set(this.key(userId), JSON.stringify(permissions), ttlSeconds ?? this.TTL);
}
async invalidate(userId: string): Promise<void> {
await this.redis.del(this.key(userId));
}
}
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { AuthorizationService } from '../authorization.service';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly authz: AuthorizationService, private readonly permission: string) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.raylab?.user;
if (!user) throw new ForbiddenException('Missing user');
const allowed = await this.authz.hasPermission(user.id, this.permission);
if (!allowed) throw new ForbiddenException('Forbidden');
return true;
}
}
// Factory to create guard instances with permission string (used in decorators)
export const createPermissionGuard = (permission: string) => {
@Injectable()
class _Guard extends PermissionGuard {
constructor(authz: AuthorizationService) {
super(authz, permission);
}
}
return _Guard;
};
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './health.service';
@Controller('api/health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
async get() {
return this.healthService.getHealth();
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
exports: [HealthService],
})
export class HealthModule {}
+12
View File
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class HealthService {
async getHealth() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'raylab-core',
};
}
}
@@ -1,21 +1,29 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { PermissionData } from '../../../domain/entities/permission.entity';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
const crypto = require('crypto');
@Injectable()
export class CreatePermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
constructor(private readonly permissionRepository: IPermission, private readonly events: EventBus) {}
async execute(dto: any) {
if (!dto.name || !dto.code) throw new ConflictException('Missing required fields');
const perm = PermissionData.restore({
id: crypto.randomUUID(),
code: dto.code || dto.name,
name: dto.name,
displayName: dto.displayName || dto.name,
description: dto.description || '',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
return this.permissionRepository.create(perm);
const created = await this.permissionRepository.create(perm);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionCreated', payload: { permissionId: created.id } });
return created;
}
}
@@ -1,14 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class DeletePermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string) {
const p = await this.permissionRepository.getById(id);
if (!p) throw new NotFoundException('Permission not found.');
// find roles that reference this permission
const rolesWithPerm = await (this.permissionRepository as any).findRoleIdsByPermission(id);
// delete permission
await this.permissionRepository.delete(id);
// invalidate caches for users who have affected roles
const userSet = new Set<string>();
for (const rid of rolesWithPerm) {
const uids = await this.roleRepository.getAssignedUserIds(rid);
uids.forEach(u => userSet.add(u));
}
for (const uid of Array.from(userSet)) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionDeleted', payload: { permissionId: id, affectedRoles: rolesWithPerm } });
return;
}
}
@@ -1,9 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class UpdatePermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string, dto: any) {
const perm = await this.permissionRepository.getById(id);
@@ -13,6 +16,21 @@ export class UpdatePermissionHandler {
if (dto.description !== undefined) perm.changeDescription(dto.description);
if (dto.code) perm.changeCode(dto.code);
return this.permissionRepository.update(perm);
const updated = await this.permissionRepository.update(perm);
// invalidate caches for users who belong to roles that reference this permission
const roleIds = await (this.permissionRepository as any).findRoleIdsByPermission(id);
const userSet = new Set<string>();
for (const rid of roleIds) {
const uids = await this.roleRepository.getAssignedUserIds(rid);
uids.forEach(u => userSet.add(u));
}
for (const uid of Array.from(userSet)) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionUpdated', payload: { permissionId: id } });
return updated;
}
}
@@ -1,12 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class RoleAssignPermissionHandler {
constructor(
private readonly roleRepository: IRole,
private readonly permissionRepository: IPermission,
private readonly authorizationService: AuthorizationService,
private readonly events: EventBus,
) {}
async execute(roleId: string, permissionId: string) {
@@ -18,6 +22,17 @@ export class RoleAssignPermissionHandler {
role.assignPermission(perm);
return this.roleRepository.update(role);
const updated = await this.roleRepository.update(role);
// Invalidate permissions cache for users who have this role
const userIds = await this.roleRepository.getAssignedUserIds(roleId);
for (const uid of userIds) {
await this.authorizationService.invalidateUserPermissions(uid);
}
// Publish role.updated event
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId, permissionId } });
return updated;
}
}
@@ -1,16 +1,19 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { RoleData } from '../../../domain/entities/role.entity';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
const crypto = require('crypto');
@Injectable()
export class CreateRoleHandler {
constructor(private readonly roleRepository: IRole) {}
constructor(private readonly roleRepository: IRole, private readonly events: EventBus) {}
async execute(dto: any) {
// check uniqueness by code
// simple check
// basic validation
if (!dto.code || !dto.name) throw new ConflictException('Missing required fields');
try {
// attempt to create; repository may enforce uniqueness
const role = RoleData.restore({
id: crypto.randomUUID(),
code: dto.code,
@@ -22,7 +25,11 @@ export class CreateRoleHandler {
updatedAt: new Date(),
} as any);
return this.roleRepository.create(role);
const created = await this.roleRepository.create(role);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleCreated', payload: { roleId: created.id } });
return created;
} catch (e) {
throw new ConflictException('Role creation failed.');
}
@@ -1,14 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class DeleteRoleHandler {
constructor(private readonly roleRepository: IRole) {}
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string) {
const role = await this.roleRepository.findById(id);
if (!role) throw new NotFoundException('Role not found.');
// get affected users before delete
const userIds = await this.roleRepository.getAssignedUserIds(id);
await this.roleRepository.delete(id);
// invalidate caches
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleDeleted', payload: { roleId: id } });
return;
}
}
@@ -1,9 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class RoleRemovePermissionHandler {
constructor(private readonly roleRepository: IRole) {}
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(roleId: string, permissionId: string) {
const role = await this.roleRepository.findById(roleId);
@@ -11,6 +13,15 @@ export class RoleRemovePermissionHandler {
role.removePermission(permissionId);
return this.roleRepository.update(role);
const updated = await this.roleRepository.update(role);
// Invalidate caches for users with the role
const userIds = await this.roleRepository.getAssignedUserIds(roleId);
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
// Publish event
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId, removedPermissionId: permissionId } });
return updated;
}
}
@@ -1,18 +1,31 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class UpdateRoleHandler {
constructor(private readonly roleRepository: IRole) {}
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string, dto: any) {
const role = await this.roleRepository.findById(id);
if (!role) throw new NotFoundException('Role not found.');
// validation
if (dto.name && dto.name.length < 2) throw new BadRequestException('Name too short');
if (dto.name) role.changeName(dto.name);
if (dto.description !== undefined) role.changeDescription(dto.description);
if (dto.isDefault !== undefined) role.setDefault(!!dto.isDefault);
return this.roleRepository.update(role);
const updated = await this.roleRepository.update(role);
// invalidate caches for users with this role
const userIds = await this.roleRepository.getAssignedUserIds(id);
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId: id } });
return updated;
}
}
@@ -0,0 +1,25 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
import { UpdateUserDto } from '../../../presentation/dto/update-user.dto';
@Injectable()
export class UpdateUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string, dto: UpdateUserDto) {
// Password management is not allowed in RayLab Core
if ((dto as any).password) throw new BadRequestException('Password management is not allowed.');
const user = await this.userRepository.getById(id);
if (!user) throw new NotFoundException('User not found.');
// Only allow updating profile fields: name, email, metadata, storage settings
if (dto.name !== undefined) user.changeUsername(dto.name);
if (dto.email !== undefined) user.changeEmail(dto.email);
// metadata field not handled at domain level yet
if ((dto as any).storageQuota !== undefined) user.setStorageQuota((dto as any).storageQuota);
if ((dto as any).storageUsed !== undefined) user.setStorageUsed((dto as any).storageUsed);
return this.userRepository.update(user);
}
}
@@ -6,4 +6,7 @@ export abstract class IPermission {
abstract create(permission: PermissionData): Promise<PermissionData>;
abstract update(permission: PermissionData): Promise<PermissionData>;
abstract delete(id: string): Promise<void>;
// Returns role ids that reference this permission
abstract findRoleIdsByPermission(permissionId: string): Promise<string[]>;
}
@@ -9,4 +9,7 @@ export abstract class IRole {
abstract create(role: RoleData): Promise<RoleData>;
abstract update(role: RoleData): Promise<RoleData>;
abstract delete(roleId: string): Promise<void>;
// Returns user ids assigned to a role (preserves layering)
abstract getAssignedUserIds(roleId: string): Promise<string[]>;
}
+13 -9
View File
@@ -20,6 +20,9 @@ import { IRole } from './domain/repositories/role.interface';
import { IPermission } from './domain/repositories/permission.interface';
import { IAuthConfig } from './application/config/i-auth-config';
import { EnvAuthConfig } from './application/config/env-auth-config';
import { AuthModule } from '../auth/auth.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { GetUsersHandler } from './application/handlers/user/get-users.handler';
import { GetUserHandler } from './application/handlers/user/get-user.handler';
@@ -28,10 +31,9 @@ import { EnableUserHandler } from './application/handlers/user/enable-user.handl
import { DisableUserHandler } from './application/handlers/user/disable-user.handler';
import { DeleteUserHandler } from './application/handlers/user/delete-user.handler';
import { RestoreUserHandler } from './application/handlers/user/restore-user.handler';
import { AssignRoleHandler } from './application/handlers/user/assign-role.handler';
import { RemoveRoleHandler } from './application/handlers/user/remove-role.handler';
import { AssignPermissionHandler } from './application/handlers/user/assign-permission.handler';
import { RemovePermissionHandler } from './application/handlers/user/remove-permission.handler';
import { UpdateUserHandler } from './application/handlers/user/update-user.handler';
import { GetRolesHandler } from './application/handlers/role/get-roles.handler';
import { GetRoleHandler } from './application/handlers/role/get-role.handler';
@@ -48,7 +50,9 @@ import { UpdatePermissionHandler } from './application/handlers/permission/updat
import { DeletePermissionHandler } from './application/handlers/permission/delete-permission.handler';
@Module({
imports: [AuthModule, AuthorizationModule],
providers: [
//#region User
// CreateUserHandler has been removed: provisioning disabled; users must be created in Authentik.
SyncIdentityHandler,
@@ -60,10 +64,8 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
DisableUserHandler,
DeleteUserHandler,
RestoreUserHandler,
AssignRoleHandler,
RemoveRoleHandler,
AssignPermissionHandler,
RemovePermissionHandler,
UpdateUserHandler,
//#endregion
// role & permission handlers
@@ -82,6 +84,7 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
DeletePermissionHandler,
JwtAuthGuard,
CurrentUserGuard,
PermissionGuard,
Reflector,
@@ -90,6 +93,8 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
PrismaRoleRepository,
PrismaPermissionRepository,
UserService,
UpdateUserHandler,
{
provide: IUser,
useClass: PrismaUserRepository,
@@ -107,7 +112,6 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
useClass: EnvAuthConfig,
},
],
imports: [],
controllers: [UsersController, RolesController, PermissionsController],
})
export class IdentityModule {}
@@ -50,4 +50,10 @@ export class PrismaPermissionRepository implements IPermission {
async delete(id: string) {
await this.prisma.permission.delete({ where: { id } });
}
async findRoleIdsByPermission(permissionId: string): Promise<string[]> {
const rows = await this.prisma.rolePermission.findMany({ where: { permissionId }, select: { roleId: true } });
return rows.map(r => r.roleId);
}
}
@@ -106,4 +106,10 @@ export class PrismaRoleRepository implements IRole {
async delete(roleId: string) {
await this.prisma.role.delete({ where: { id: roleId } });
}
async getAssignedUserIds(roleId: string): Promise<string[]> {
const rows = await this.prisma.userRole.findMany({ where: { roleId }, select: { userId: true } });
return rows.map(r => r.userId);
}
}
@@ -14,10 +14,8 @@ import { EnableUserHandler } from '../../application/handlers/user/enable-user.h
import { DisableUserHandler } from '../../application/handlers/user/disable-user.handler';
import { DeleteUserHandler } from '../../application/handlers/user/delete-user.handler';
import { RestoreUserHandler } from '../../application/handlers/user/restore-user.handler';
import { AssignRoleHandler } from '../../application/handlers/user/assign-role.handler';
import { RemoveRoleHandler } from '../../application/handlers/user/remove-role.handler';
import { AssignPermissionHandler } from '../../application/handlers/user/assign-permission.handler';
import { RemovePermissionHandler } from '../../application/handlers/user/remove-permission.handler';
import { UpdateUserHandler } from '../../application/handlers/user/update-user.handler';
@ApiTags('Users')
@Controller('users')
@@ -31,10 +29,7 @@ export class UsersController {
private readonly disableUserHandler: DisableUserHandler,
private readonly deleteUserHandler: DeleteUserHandler,
private readonly restoreUserHandler: RestoreUserHandler,
private readonly assignRoleHandler: AssignRoleHandler,
private readonly removeRoleHandler: RemoveRoleHandler,
private readonly assignPermissionHandler: AssignPermissionHandler,
private readonly removePermissionHandler: RemovePermissionHandler,
private readonly updateUserHandler: UpdateUserHandler,
) {}
@Get()
@@ -82,6 +77,14 @@ export class UsersController {
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Patch(':id')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Update user profile' })
async update(@Param('id') id: string, @Body() body: any) {
const updated = await this.updateUserHandler.execute(id, body);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Delete(':id')
@Permissions(PermissionType.USER_DELETE)
@ApiOperation({ summary: 'Soft delete user' })
@@ -98,37 +101,5 @@ export class UsersController {
return { success: true, data: (restored as any).toResponse ? (restored as any).toResponse() : restored, meta: {} };
}
@Post(':id/roles')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Assign role to user' })
async assignRole(@Param('id') id: string, @Body() body: any) {
const roleId = body.roleId;
const updated = await this.assignRoleHandler.execute(id, roleId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Delete(':id/roles/:roleId')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Remove role from user' })
async removeRole(@Param('id') id: string, @Param('roleId') roleId: string) {
const updated = await this.removeRoleHandler.execute(id, roleId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Post(':id/permissions')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Assign permission to user' })
async assignPermission(@Param('id') id: string, @Body() body: any) {
const permissionId = body.permissionId;
const updated = await this.assignPermissionHandler.execute(id, permissionId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Delete(':id/permissions/:permissionId')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Remove permission from user' })
async removePermission(@Param('id') id: string, @Param('permissionId') permissionId: string) {
const updated = await this.removePermissionHandler.execute(id, permissionId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
}
@@ -11,6 +11,14 @@ export class CurrentUserGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest() as any;
// Prefer RequestContext produced by AuthenticationService
const ctx = request.raylabContext as any;
if (ctx && ctx.user) {
request.currentUser = ctx.user;
return true;
}
// Fallback to legacy identity if present
const identity = request.identity as IdentityData | undefined;
if (!identity) {
+3
View File
@@ -0,0 +1,3 @@
export const CacheKeys = {
permission: (userId: string) => `permissions:${userId}`,
};
+109
View File
@@ -0,0 +1,109 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
import IORedis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
// Keep client typed as any to avoid tight coupling to ioredis types in tests
private client: any = null;
private readonly logger = new Logger(RedisService.name);
private lastLogAt = 0;
private readonly LOG_THROTTLE_MS = 5000; // throttle repeated error logs
onModuleInit() {
const enabled = (process.env.REDIS_ENABLED || 'true').toLowerCase() === 'true';
if (!enabled) {
this.logger.log('Redis disabled via REDIS_ENABLED=false');
return;
}
const url = process.env.REDIS_URL || 'redis://localhost:6379';
// Use lazyConnect so application can start even if Redis is unavailable temporarily
this.client = new IORedis(url, {
lazyConnect: true,
// limit retries to avoid infinite reconnect storms
maxRetriesPerRequest: 5,
// automatic reconnection strategy
reconnectOnError: (err: any) => {
return true;
},
enableOfflineQueue: true,
// optional reconnect strategy
retryStrategy: (times: number) => {
// exponential backoff capped at 5s
const delay = Math.min(50 * Math.pow(2, times), 5000);
return delay;
},
});
this.client.on('connect', () => this.logger.log('Connected to Redis'));
this.client.on('ready', () => this.logger.log('Redis ready'));
this.client.on('error', (err: any) => this.handleError(err));
this.client.on('close', () => this.logger.warn('Redis connection closed'));
this.client.on('reconnecting', () => this.logger.log('Redis reconnecting'));
// attempt to connect but do not throw if it fails
this.client.connect().catch((err: any) => {
this.handleError(err);
});
}
private handleError(err: any) {
const now = Date.now();
if (now - this.lastLogAt > this.LOG_THROTTLE_MS) {
this.logger.error('Redis error', err instanceof Error ? err.message : err);
this.lastLogAt = now;
}
}
onModuleDestroy() {
if (this.client) {
try {
this.client.disconnect();
} catch (e) {
// ignore
}
}
}
getClient(): any {
return this.client;
}
private ensureClient() {
// returns true if client is connected/usable
return this.client && this.client.status && this.client.status !== 'end' && this.client.status !== 'close';
}
async get(key: string): Promise<string | null> {
if (!this.ensureClient()) return null;
try {
return await this.client.get(key);
} catch (e) {
this.handleError(e);
return null;
}
}
async set(key: string, value: string, ttlSeconds?: number) {
if (!this.ensureClient()) return;
try {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
} catch (e) {
this.handleError(e);
}
}
async del(key: string) {
if (!this.ensureClient()) return;
try {
await this.client.del(key);
} catch (e) {
this.handleError(e);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface RequestContext {
user: any;
identity: Record<string, any>;
roles: string[]; // role ids
permissions: string[]; // permission codes
}
+38
View File
@@ -0,0 +1,38 @@
import { AuthenticationService } from '../src/modules/auth/authentication.service';
describe('AuthenticationService', () => {
let service: AuthenticationService;
const mockOidc: any = { verifyToken: jest.fn() };
const mockPrisma: any = {};
const mockRoleSync: any = { syncUserRolesFromAuthentik: jest.fn() };
const mockAuthz: any = { getUserPermissions: jest.fn() };
const mockEvents: any = { publish: jest.fn() };
beforeEach(() => {
mockPrisma.user = { findUnique: jest.fn(), create: jest.fn() };
mockPrisma.userRole = { findMany: jest.fn() };
service = new AuthenticationService(mockOidc as any, mockPrisma as any, mockRoleSync as any, mockAuthz as any, mockEvents as any);
});
test('authenticate creates context on valid token', async () => {
const token = 'valid';
mockOidc.verifyToken.mockResolvedValue({ sub: 'sub1', email: 'u@example.com', groups: ['RL-Owner'] });
mockPrisma.user.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({ id: 'uid', authentikId: 'sub1', email: 'u@example.com' });
mockRoleSync.syncUserRolesFromAuthentik.mockResolvedValue({ skipped: false, assignedRoleIds: ['r1'] });
mockAuthz.getUserPermissions.mockResolvedValue(['users.read']);
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r1' }]);
const ctx = await service.authenticate(token);
expect(ctx.user.id).toBe('uid');
expect(ctx.permissions).toContain('users.read');
expect(mockEvents.publish).toHaveBeenCalledWith('user.authenticated', expect.any(Object));
});
test('authenticate throws for missing sub', async () => {
mockOidc.verifyToken.mockResolvedValue({ email: 'u@example.com' });
await expect(service.authenticate('bad')).rejects.toThrow('Invalid token: missing sub');
});
});
+34
View File
@@ -0,0 +1,34 @@
import { AuthorizationService, PERMISSION_CACHE } from '../src/modules/authorization/authorization.service';
describe('AuthorizationService', () => {
let service: AuthorizationService;
const mockPrisma: any = { $queryRaw: jest.fn(), $queryRawUnsafe: jest.fn() };
const mockCache: any = { get: jest.fn(), set: jest.fn(), invalidate: jest.fn() };
beforeEach(() => {
service = new AuthorizationService(mockPrisma as any, mockCache as any);
});
test('getUserPermissions uses cache when available', async () => {
mockCache.get.mockResolvedValue(['users.read']);
const perms = await service.getUserPermissions('uid');
expect(perms).toEqual(['users.read']);
expect(mockCache.get).toHaveBeenCalledWith('uid');
});
test('getUserPermissions queries DB and sets cache when missing', async () => {
mockCache.get.mockResolvedValue(null);
mockPrisma.$queryRaw = jest.fn().mockResolvedValue([{ code: 'users.read' }]);
const perms = await service.getUserPermissions('uid');
expect(perms).toEqual(['users.read']);
expect(mockCache.set).toHaveBeenCalledWith('uid', ['users.read']);
});
test('hasPermission returns correct boolean', async () => {
jest.spyOn(service, 'getUserPermissions' as any).mockResolvedValue(['users.read']);
const ok = await service.hasPermission('uid', 'users.read');
expect(ok).toBe(true);
const nok = await service.hasPermission('uid', 'users.delete');
expect(nok).toBe(false);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
describe('RoleSyncService - transaction rollback', () => {
let service: RoleSyncService;
const mockPrisma: any = {};
const mockPermissionCache: any = { invalidate: jest.fn() };
const mockEvents: any = { publish: jest.fn() };
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
beforeEach(() => {
mockPrisma.user = { findUnique: jest.fn() };
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
mockPrisma.userRole = { findMany: jest.fn().mockResolvedValue([]) };
mockPrisma.user = mockPrisma.user;
// simulate transaction throwing
mockPrisma.$transaction = jest.fn(async (cb: any) => { throw new Error('tx failed'); });
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
});
test('does not invalidate cache or publish event when transaction fails', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
await expect(service.syncUserRolesFromAuthentik('uid', ['RL-Owner'])).rejects.toThrow('tx failed');
expect(mockPermissionCache.invalidate).not.toHaveBeenCalled();
expect(mockEvents.publish).not.toHaveBeenCalled();
});
});
+65
View File
@@ -0,0 +1,65 @@
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
describe('RoleSyncService', () => {
const mockPrisma: any = {};
const mockPermissionCache: any = { invalidate: jest.fn() };
const mockEvents: any = { publish: jest.fn() };
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
let service: RoleSyncService;
beforeEach(() => {
mockPrisma.user = { findUnique: jest.fn() };
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
mockPrisma.userRole = { findMany: jest.fn() };
mockPrisma.auditLog = { create: jest.fn() };
mockPrisma.user = mockPrisma.user;
mockPrisma.$transaction = jest.fn(async (cb: any) => {
// simulate transaction by calling provided callback with tx = mockPrisma
await cb(mockPrisma);
});
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
});
test('computeGroupHash consistent and order independent', () => {
const a = ['b', 'a', 'c'];
const h1 = service.computeGroupHash(a);
const h2 = service.computeGroupHash(['a', 'b', 'c']);
expect(h1).toBe(h2);
});
test('skips sync when group hash unchanged', async () => {
const groups = ['RL-Owner'];
const hash = service.computeGroupHash(groups);
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: hash });
const res = await service.syncUserRolesFromAuthentik('uid', groups);
expect(res.skipped).toBe(true);
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
test('performs transaction and writes audit when changed', async () => {
const groups = ['RL-Owner'];
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r_old' }]);
// spy on tx ops
mockPrisma.userRole.deleteMany = jest.fn();
mockPrisma.userRole.createMany = jest.fn();
mockPrisma.user.update = jest.fn();
mockPrisma.auditLog.create = jest.fn();
const res = await service.syncUserRolesFromAuthentik('uid', groups);
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(mockPrisma.userRole.deleteMany).toHaveBeenCalledWith({ where: { userId: 'uid', source: 'AUTHENTIK' } });
expect(mockPrisma.userRole.createMany).toHaveBeenCalled();
expect(mockPrisma.user.update).toHaveBeenCalled();
expect(mockPermissionCache.invalidate).toHaveBeenCalledWith('uid');
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RolesSynchronized' }));
expect(res.skipped).toBe(false);
expect(res.assignedRoleIds).toEqual(['r1']);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { RoleAssignPermissionHandler } from '../src/modules/identity/application/handlers/role/assign-permission.handler';
import { RoleRemovePermissionHandler } from '../src/modules/identity/application/handlers/role/remove-permission.handler';
describe('Role permission handlers', () => {
test('assign permission invalidates cache and publishes event', async () => {
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1','u2']) };
const mockPermRepo: any = { getById: jest.fn() };
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
const mockEvents: any = { publish: jest.fn() };
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', assignPermission: jest.fn() });
mockPermRepo.getById.mockResolvedValue({ id: 'p1' });
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
const handler = new RoleAssignPermissionHandler(mockRoleRepo, mockPermRepo, mockAuthz as any, mockEvents as any);
const res = await handler.execute('r1', 'p1');
expect(mockRoleRepo.update).toHaveBeenCalled();
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u2');
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
});
test('remove permission invalidates cache and publishes event', async () => {
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1']) };
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
const mockEvents: any = { publish: jest.fn() };
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', removePermission: jest.fn() });
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
const handler = new RoleRemovePermissionHandler(mockRoleRepo as any, mockAuthz as any, mockEvents as any);
const res = await handler.execute('r1', 'p1');
expect(mockRoleRepo.update).toHaveBeenCalled();
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
});
});
+29
View File
@@ -0,0 +1,29 @@
import { UpdateUserHandler } from '../src/modules/identity/application/handlers/user/update-user.handler';
describe('UpdateUserHandler', () => {
test('rejects password in DTO', async () => {
const mockRepo: any = { getById: jest.fn(), update: jest.fn() };
mockRepo.getById.mockResolvedValue({ id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() });
const handler = new UpdateUserHandler(mockRepo as any);
await expect(handler.execute('u1', { password: 'secret' } as any)).rejects.toThrow('Password management is not allowed.');
});
test('updates allowed fields', async () => {
const userObj: any = { id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() };
const mockRepo: any = { getById: jest.fn().mockResolvedValue(userObj), update: jest.fn().mockResolvedValue(userObj) };
const handler = new UpdateUserHandler(mockRepo as any);
const dto = { name: 'New Name', email: 'a@b.com', storageQuota: 1000, storageUsed: 10 } as any;
const res = await handler.execute('u1', dto);
expect(userObj.changeUsername).toHaveBeenCalledWith('New Name');
expect(userObj.changeEmail).toHaveBeenCalledWith('a@b.com');
expect(userObj.setStorageQuota).toHaveBeenCalledWith(1000);
expect(userObj.setStorageUsed).toHaveBeenCalledWith(10);
expect(mockRepo.update).toHaveBeenCalledWith(userObj);
});
});