From c66c830463f52b7be6e42ba04acbc50d6788d621 Mon Sep 17 00:00:00 2001 From: Rafael Gomides Date: Thu, 26 Mar 2026 22:24:32 -0300 Subject: [PATCH] Implement Phase 5 quality and observability hardening --- ...ror-contract-and-observability-boundary.md | 45 +++ CHANGELOG.md | 6 + README.md | 159 ++++++---- docs/architecture.md | 76 +++-- docs/commands.md | 56 +++- docs/handoff.md | 57 ++-- docs/phase-status.md | 23 +- ...0005_quality_observability_engineering.sql | 11 + package.json | 9 +- src/app.module.ts | 2 + src/bootstrap/application.factory.ts | 72 ++++- src/bootstrap/errors/http-exception.filter.ts | 164 +++++++--- .../errors/validation-exception.factory.ts | 40 +++ src/bootstrap/logging/logging.module.ts | 1 + src/bootstrap/logging/pino-logger.config.ts | 120 +++++++- .../persistence/database.executor.ts | 72 ++++- .../telemetry/application-metrics.service.ts | 289 ++++++++++++++++++ .../application-telemetry.service.ts | 74 +++++ src/bootstrap/telemetry/metrics.controller.ts | 14 + src/bootstrap/telemetry/telemetry.module.ts | 13 + .../use-cases/assign-role-to-user.use-case.ts | 115 ++++--- .../use-cases/authorize-action.use-case.ts | 51 +++- .../use-cases/create-role.use-case.ts | 77 +++-- .../grant-permission-to-role.use-case.ts | 105 ++++--- .../use-cases/append-audit-log.use-case.ts | 71 +++-- .../use-cases/list-audit-logs.use-case.ts | 64 +++- .../repositories/audit-log.repository.ts | 2 + .../http/audit-logs.controller.ts | 2 + .../http/list-audit-logs.request.ts | 17 +- .../persistence/pg-audit-log.repository.ts | 7 + .../use-cases/create-user-account.use-case.ts | 121 ++++---- .../use-cases/invalidate-session.use-case.ts | 84 ++--- .../use-cases/login-with-password.use-case.ts | 224 +++++++------- .../use-cases/create-organization.use-case.ts | 91 +++--- .../list-organization-memberships.use-case.ts | 9 +- .../list-organization-memberships.request.ts | 18 ++ .../http/organizations.controller.ts | 22 +- .../contracts/users-tenancy.contract.ts | 10 +- .../use-cases/create-membership.use-case.ts | 96 +++--- ...st-memberships-by-organization.use-case.ts | 11 +- .../repositories/membership.repository.ts | 10 +- .../persistence/pg-membership.repository.ts | 15 +- src/modules/users/users.module.ts | 6 +- src/shared/domain/nexus.errors.ts | 6 + src/shared/domain/read-error-code.ts | 14 + src/shared/events/internal-event-bus.ts | 40 ++- .../access-control/access-control.e2e-spec.ts | 19 +- .../audit-logs/audit-logs.e2e-spec.ts | 58 +++- test/functional/identity/identity.e2e-spec.ts | 38 ++- .../organizations/organizations.e2e-spec.ts | 48 ++- .../audit-logs/audit-logs.integration.spec.ts | 51 +++- test/support/unit-test-doubles.ts | 28 ++ .../errors/http-exception.filter.spec.ts | 118 +++++++ .../validation-exception.factory.spec.ts | 41 +++ .../logging/pino-logger.config.spec.ts | 32 +- .../application-metrics.service.spec.ts | 32 ++ .../authorize-action.use-case.spec.ts | 20 ++ .../list-audit-logs.use-case.spec.ts | 6 + .../login-with-password.use-case.spec.ts | 18 ++ .../shared/auth/authorization.guard.spec.ts | 87 ++++++ .../tenant-context-resolver.service.spec.ts | 100 ++++++ 61 files changed, 2590 insertions(+), 697 deletions(-) create mode 100644 .agents/decisions/0008-http-error-contract-and-observability-boundary.md create mode 100644 migrations/0005_quality_observability_engineering.sql create mode 100644 src/bootstrap/errors/validation-exception.factory.ts create mode 100644 src/bootstrap/telemetry/application-metrics.service.ts create mode 100644 src/bootstrap/telemetry/application-telemetry.service.ts create mode 100644 src/bootstrap/telemetry/metrics.controller.ts create mode 100644 src/bootstrap/telemetry/telemetry.module.ts create mode 100644 src/modules/organizations/infrastructure/http/list-organization-memberships.request.ts create mode 100644 src/shared/domain/read-error-code.ts create mode 100644 test/unit/bootstrap/errors/http-exception.filter.spec.ts create mode 100644 test/unit/bootstrap/errors/validation-exception.factory.spec.ts create mode 100644 test/unit/bootstrap/telemetry/application-metrics.service.spec.ts create mode 100644 test/unit/shared/auth/authorization.guard.spec.ts create mode 100644 test/unit/shared/tenancy/tenant-context-resolver.service.spec.ts diff --git a/.agents/decisions/0008-http-error-contract-and-observability-boundary.md b/.agents/decisions/0008-http-error-contract-and-observability-boundary.md new file mode 100644 index 0000000..a6d92e0 --- /dev/null +++ b/.agents/decisions/0008-http-error-contract-and-observability-boundary.md @@ -0,0 +1,45 @@ +# ADR 0008 — Contrato HTTP de erro e boundary de observabilidade + +## Status +Aceita + +## Contexto + +A Phase 5 precisa tornar a API previsível para consumidores e, ao mesmo tempo, elevar a rastreabilidade operacional sem introduzir novos módulos de negócio nem mensageria externa. A base já possuía validação por DTO, logger estruturado, contexto de correlação e bootstrap mínimo de OpenTelemetry, mas faltava um contrato público único para erros, métricas mínimas e instrumentação consistente dos fluxos críticos. + +## Decisão + +Adotar as seguintes regras no boundary da aplicação: + +- todo erro HTTP não bem-sucedido retorna `{ error, message, correlation_id }` +- `correlation_id` no payload deve coincidir com o header `x-correlation-id` +- validações de request passam por uma `exceptionFactory` central e retornam `invalid_request` +- falhas técnicas retornam apenas `internal_error` com mensagem genérica +- `GET /metrics` expõe métricas Prometheus sem autenticação na aplicação +- spans manuais cobrem HTTP, banco, bus interno, login, RBAC, autorização e audit logs +- logs do boundary são estruturados, module-aware e sanitizam segredos, cookies e stack traces + +## Alternativas consideradas + +- manter múltiplos formatos de erro por origem (`HttpException`, `NexusError`, validação) +- introduzir coletor externo de métricas ou tracing já nesta fase +- proteger `GET /metrics` com autenticação da própria aplicação + +## Consequências + +Positivas: +- contrato de erro fica estável, simples e fácil de testar +- investigações operacionais passam a correlacionar request, log, trace, métrica e audit row +- a aplicação ganha sinais mínimos de engenharia sem elevar muito a complexidade + +Negativas: +- o contrato antigo de erro deixa de ser compatível imediatamente +- `GET /metrics` exige disciplina operacional na borda da infraestrutura +- a telemetria continua básica e local, sem exporter distribuído definido nesta fase + +## Regras + +- não vazar senha, token, `Authorization`, cookies nem stack interna no boundary HTTP +- manter autorização deny-by-default e tenant context explícito em rotas protegidas +- qualquer mudança futura no contrato público de erro deve passar por ADR nova +- qualquer evolução para exporter externo, sampling avançado ou storage dedicado de métricas/traces deve ser reavaliada em ADR futura diff --git a/CHANGELOG.md b/CHANGELOG.md index fe6e361..f25a5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2026-03-26] +- feature: implementação da Phase 5 — Quality, Observability & Engineering com contrato de erro padronizado, `GET /metrics`, spans adicionais e logs estruturados/sanitizados. +- feature: paginação pública adicionada a `GET /audit-logs` e `GET /organizations/:id/memberships`, com novos índices compostos na migration `0005_quality_observability_engineering.sql`. +- test: cobertura unitária, de integração e funcional ampliada para validação, `correlation_id`, métricas, autorização deny-by-default e paginação tenant-aware. +- docs: README, comandos, arquitetura, status de fase, handoff e ADR 0008 atualizados para refletir a Phase 5. + ## [2026-03-26] - feature: implementação da Phase 4 — Auditability com módulo `audit-logs`, endpoint `GET /audit-logs` e migration `0004_audit_logs.sql`. - feature: bus interno síncrono e `RequestCorrelationContext` adicionados para desacoplar o append de auditoria e persistir `correlation_id`. diff --git a/README.md b/README.md index 3382137..ba08bdd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Nexus Platform -Multi-tenant backend platform for identity, organizations, users, tenant-scoped RBAC and immutable auditability. The codebase remains a modular monolith with DDD, Clean Architecture and explicit internal boundaries. +Multi-tenant backend platform for identity, organizations, users, tenant-scoped RBAC, immutable auditability and operational observability. The codebase remains a modular monolith with DDD, Clean Architecture and explicit internal boundaries. ## 🚧 Project Status -Current Phase: **Phase 4 — Auditability** +Current Phase: **Phase 5 — Quality, Observability & Engineering** Notion reference: [Nexus Platform](https://www.notion.so/mrgomides/Nexus-Platform-32fe01f2262680cd9e32db2b5cdd8f7b?source=copy_link) @@ -22,18 +22,21 @@ Notion reference: [Nexus Platform](https://www.notion.so/mrgomides/Nexus-Platfor - Docker - GitHub Actions -## What Phase 4 Delivers +## What Phase 5 Delivers -- append-only `audit_logs` storage with immutable PostgreSQL protection against `UPDATE` and `DELETE` -- minimal in-process internal event bus so audited side effects stay decoupled from the main modules -- audit coverage for login, logout, organization lifecycle, membership assignment, RBAC changes and authorization denials -- protected `GET /audit-logs` endpoint with tenant scoping and `audit:view` enforcement -- correlation id propagation from the HTTP request boundary into persisted audit rows -- unit, integration and functional coverage for audit domain rules, event persistence and tenant-scoped queries +- production-oriented validation at the HTTP boundary with DTOs, `ValidationPipe` normalization and a stable error contract +- a single API error format with semantic snake_case codes and `correlation_id` mirrored in `x-correlation-id` +- structured logs with `timestamp`, `level`, `message`, `module`, `correlation_id`, `tenant_id` and `user_id` +- request-level correlation propagation across HTTP, use cases, database access, internal events and audit rows +- manual telemetry instrumentation for HTTP entrypoints, critical use cases, `DatabaseExecutor`, `InternalEventBus` and authorization flow +- scrape-friendly Prometheus metrics at `GET /metrics` +- explicit pagination and reasonable limits for `GET /audit-logs` and `GET /organizations/:id/memberships` +- broader unit, integration and functional coverage for identity, organizations, users, access-control, audit logs, validation and observability ## Available Endpoints - `GET /health` +- `GET /metrics` - `POST /identity/accounts` - `POST /identity/login` - `POST /identity/logout` @@ -41,13 +44,13 @@ Notion reference: [Nexus Platform](https://www.notion.so/mrgomides/Nexus-Platfor - `GET /organizations/:id` - `PATCH /organizations/:id/inactive` - `POST /organizations/:id/memberships` -- `GET /organizations/:id/memberships` +- `GET /organizations/:id/memberships?limit=<1-100>&offset=<0-1000>` - `POST /roles` - `GET /roles` - `POST /roles/:id/permissions` - `GET /permissions` - `POST /users/:id/roles` -- `GET /audit-logs` +- `GET /audit-logs?tenantId=&limit=<1-100>&offset=<0-1000>` ## Authorization Model @@ -64,19 +67,56 @@ Authenticated User ### Default bootstrap and backfill - Every tenant receives a default `organization_admin` role. -- The role is granted the full default permission catalog for this phase. +- The role is granted the full default permission catalog for the current phase. - Existing active memberships were backfilled with `organization_admin` in the Phase 3 migration. - New organizations bootstrap the creator membership and the `organization_admin` assignment in the same transaction flow. -## Auditability Model +### Default permission catalog + +- `organization:view` +- `organization:deactivate` +- `membership:create` +- `membership:view` +- `role:create` +- `role:view` +- `permission:view` +- `role:grant-permission` +- `role:assign` +- `user:create` +- `user:update` +- `audit:view` + +## Error Contract + +All non-success HTTP responses now follow the same payload shape: + +```json +{ + "error": "permission_denied", + "message": "Permission denied", + "correlation_id": "2d2dbbc8-b6dc-4c8f-a76e-b8578dd8d6d8" +} +``` + +Rules applied in Phase 5: + +- semantic codes are stable and snake_case +- request validation fails early with `invalid_request` +- functional errors stay explicit +- technical failures return generic `internal_error` +- `x-correlation-id` is always returned and matches `correlation_id` in error payloads +- authentication and logging never expose password, token or stack details at the HTTP boundary + +## Auditability And Querying ```text HTTP request -> correlation id resolved at the request boundary + -> guards resolve principal + tenant + permission -> module executes the main use case -> audited modules publish an internal event in-band -> audit-logs subscriber appends an immutable row - -> GET /audit-logs reads by tenant with RBAC protection + -> GET /audit-logs reads by tenant with RBAC protection and pagination ``` ### Audited actions @@ -93,42 +133,55 @@ HTTP request - `role_assigned` - `authorization_denied` -### Querying audit logs +### Audit log rules -- `GET /audit-logs?tenantId=` -- optional filters: `userId`, `action`, `from`, `to` -- response fields: `id`, `timestamp`, `userId`, `tenantId`, `action`, `resource`, `metadata`, `correlationId` +- `GET /audit-logs` requires `audit:view` - `tenantId` in the query must match the active tenant from the authenticated session +- optional filters: `userId`, `action`, `from`, `to` +- pagination defaults to `limit=50` and `offset=0` +- public limits are `limit <= 100` and `offset <= 1000` +- ordering stays `timestamp DESC, id DESC` - bootstrap-session or failed-login rows may persist `tenantId = null` when no tenant context exists -### Default permission catalog +## Observability -- `organization:view` -- `organization:deactivate` -- `membership:create` -- `membership:view` -- `role:create` -- `role:view` -- `permission:view` -- `role:grant-permission` -- `role:assign` -- `user:create` -- `user:update` -- `audit:view` +### Structured logs + +Every HTTP request and main operational event emits structured logs aligned around: + +- `timestamp` +- `level` +- `message` +- `module` +- `correlation_id` +- `tenant_id` +- `user_id` + +Sensitive headers and values such as `Authorization`, cookies, tokens, passwords and error stacks are sanitized at the boundary. + +### Correlation and tracing -## Tenant and Authorization Notes +- incoming requests reuse `x-correlation-id` when present or generate a new UUID +- the correlation id flows into guards, use cases, internal events and persisted audit rows +- manual OpenTelemetry spans cover HTTP entrypoints, critical use cases, `DatabaseExecutor`, `InternalEventBus` and authorization decisions -- `POST /identity/login` still accepts `organizationId` for tenant-bound sessions. -- Users with active memberships must log in with `organizationId`. -- Users without active memberships can still obtain a bootstrap session with `organizationId = null`. -- `POST /organizations` remains authentication-only because it happens before tenant RBAC exists. -- Tenant-scoped organization routes require: - - authenticated principal - - tenant context resolved from the active session - - active organization - - active membership - - matching RBAC permission for the requested action -- Session-scoped RBAC routes (`/roles`, `/permissions`, `/users/:id/roles`) resolve the tenant directly from the active session and still validate organization + membership before authorization. +### Metrics + +`GET /metrics` exposes Prometheus-compatible metrics including: + +- `nexus_http_requests_total` +- `nexus_http_request_duration_ms` +- `nexus_module_failures_total` +- `nexus_identity_logins_total` +- `nexus_authorization_decisions_total` +- `nexus_audit_operations_total` +- `nexus_audit_operation_duration_ms` + +## Testing Strategy + +- Unit: domain rules, use cases, error mapping, validation mapping, logging and telemetry helpers +- Integration: real PostgreSQL flows through Testcontainers for login, tenant resolution, authorization, audit immutability, pagination and indexes +- Functional: end-to-end HTTP scenarios for authenticated tenant access, permission denial, audit correlation, metrics exposure and validation failures ## Project Structure @@ -198,21 +251,24 @@ NODE_ENV=development cp .env.example .env npm install npm run db:migrate -npm run start:dev +npm run dev ``` -The application also applies pending SQL migrations during bootstrap, so `npm run db:migrate` is the explicit operational option and app startup is the safety net. +The application also applies pending SQL migrations during bootstrap, so `npm run db:migrate` is the explicit operational command and app startup remains the safety net. -## Run with Docker +## Run With Docker ```bash cp .env.example .env -docker compose up -d --build +npm run docker:up curl http://localhost:3000/health -docker compose logs -f app postgres -docker compose down -v +curl http://localhost:3000/metrics +npm run docker:logs +npm run docker:down ``` +The existing `Makefile` mirrors these flows with `make up`, `make down`, `make run`, `make test`, `make test-unit`, `make test-integration` and `make lint`, but the package scripts remain the primary operational commands. + ## Test ```bash @@ -221,6 +277,7 @@ npm run build npm run test:unit npm run test:integration npm run test:functional +npm run ci ``` `integration` and `functional` suites use Testcontainers and require a running Docker daemon. When Docker is unavailable, those suites are skipped locally; CI runs them with Docker enabled. @@ -230,12 +287,12 @@ npm run test:functional - Modular Monolith remains the deployment model. - `identity` owns authentication, sessions and the authenticated principal. - `organizations` owns tenant lifecycle and tenant-scoped membership flows. -- `users` owns the global user record and `memberships`. +- `users` owns the global user record plus `memberships`. - `access-control` owns roles, permissions, user-role assignments and the final authorization decision. - `audit-logs` owns the append-only audit trail plus tenant-scoped query access. - internal events are synchronous and in-process; they exist to decouple audit persistence, not to hide primary business flow. - PostgreSQL access stays explicit through repositories and SQL, without ORM. -- append-only audit rows are now enforced in storage and correlated to the originating request when present. +- audit and membership queries are tenant-aware, paginated and backed by explicit composite indexes. ## Documentation diff --git a/docs/architecture.md b/docs/architecture.md index f934a40..0d4dcc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -Phase 4 adds append-only auditability on top of the tenant-scoped RBAC platform. The repository remains a modular monolith, and now `identity`, `organizations`, `users`, `access-control` and `audit-logs` collaborate through explicit contracts, shared guards and a minimal internal event bus. +Phase 5 hardens the tenant-scoped RBAC platform with consistent validation, standardized HTTP errors, broader test coverage and operational observability. The repository remains a modular monolith where `identity`, `organizations`, `users`, `access-control` and `audit-logs` collaborate through explicit contracts, shared guards and a synchronous internal event bus. ## C4-lite Diagram @@ -10,54 +10,54 @@ Phase 4 adds append-only auditability on top of the tenant-scoped RBAC platform. flowchart LR client["HTTP Client"] --> api["Nexus Platform API\nNestJS + TypeScript"] api --> health["Health Endpoint\nGET /health"] + api --> metrics["Metrics Endpoint\nGET /metrics"] api --> identity["Identity Module\naccounts, credentials, sessions"] api --> organizations["Organizations Module\ntenants, lifecycle, memberships"] api --> accessControl["Access Control Module\nroles, permissions, assignments, policy"] api --> auditLogs["Audit Logs Module\nappend-only storage and query"] - api --> eventBus["Internal Event Bus\nin-process and synchronous"] - api --> guards["Security Guards\nauthenticated principal + tenant + permission"] + api --> guards["Security Guards\nprincipal + tenant + permission"] + api --> errors["Validation + Error Contract\nsemantic codes + correlation"] + api --> logs["Structured Logs\nmodule-aware and sanitized"] + api --> telemetry["Telemetry Services\nspans + metrics"] identity --> users["Users Module\nusers, memberships"] organizations --> accessControl accessControl --> users - identity --> eventBus + identity --> eventBus["Internal Event Bus\nin-process and synchronous"] organizations --> eventBus accessControl --> eventBus users --> eventBus eventBus --> auditLogs - guards --> organizations - guards --> accessControl - api --> logs["Structured Logs\napp events plus audit query/append"] api --> database["PostgreSQL\nSQL migrations + explicit repositories"] - api --> telemetry["OpenTelemetry Bootstrap"] + telemetry --> database ``` ## Module Boundaries -- `src/bootstrap`: startup, validation pipe, global error mapping, config, logging, migrations and database lifecycle. +- `src/bootstrap`: startup, validation pipe, global error mapping, config, logging, metrics, telemetry, migrations and database lifecycle. - `src/modules/identity`: owns account creation, password hashing, login, session persistence, token issue and logout. - `src/modules/organizations`: owns tenant lifecycle and organization-scoped membership flows. - `src/modules/users`: owns the global user record plus `memberships`. - `src/modules/access-control`: owns `roles`, `permissions`, `role_permissions`, `user_role_assignments` and the authorization decision. - `src/modules/audit-logs`: owns append-only `audit_logs`, audit query use cases and internal event subscribers. -- `src/shared`: security, tenancy, request correlation and internal event primitives that are reused without collapsing module boundaries. +- `src/shared`: security, tenancy, request correlation and internal event primitives reused without collapsing module boundaries. -## Active Decisions in Phase 4 +## Active Decisions in Phase 5 - PostgreSQL still uses `pg` directly with explicit repository implementations. - SQL migrations remain versioned in `migrations/` and are applied automatically during bootstrap. -- Permissions are tenant-local, even when codes repeat across tenants. -- Existing active memberships were backfilled with `organization_admin` to preserve the current access baseline during the RBAC rollout. -- New organizations bootstrap the default permission catalog, `organization_admin` role and creator assignment inside the same transaction flow as tenant creation. -- Authorization is explicit and deny-by-default for all protected routes. -- Audit rows are appended in-band to the same transaction as the successful mutating action whenever the flow is state-changing. -- Failed login and authorization denial events preserve the original denial response even if audit append fails; the failure is logged operationally. -- The internal event bus is intentionally synchronous and in-process because it exists only to decouple audit persistence from the publishing modules. +- Authorization stays explicit and deny-by-default for all protected routes. +- Audit rows remain append-only and correlated to the originating request when available. +- Validation is centralized at the HTTP boundary with DTOs plus a custom exception factory. +- The public error payload is standardized as `{ error, message, correlation_id }`. +- `GET /metrics` is exposed by the application and expected to be protected operationally at the infrastructure edge. +- HTTP, authorization and audit queries are instrumented through manual OpenTelemetry spans and in-memory Prometheus-style counters/histograms. +- Audit and membership list endpoints use explicit pagination and tenant-aware SQL plus composite indexes. ## Security Flow ```text Authenticated request - -> resolve correlation id + -> resolve or generate correlation id -> resolve authenticated principal from session/token -> resolve active tenant from session or route -> validate active organization @@ -65,6 +65,7 @@ Authenticated request -> resolve required permission metadata -> authorize allow / deny -> execute use case + -> publish internal audit event when applicable ``` ### Guard composition @@ -79,11 +80,38 @@ Authenticated request - All RBAC tables carry `organization_id`. - Cross-tenant links are blocked with composite foreign keys on `(organization_id, id)` pairs. - Authorization decisions always use the active organization from the request context. -- Tenant mismatch between route and session is denied before application code runs. -- Cross-tenant access remains denied even if the actor has valid roles in another tenant. +- Tenant mismatch between route or query and the session tenant is denied before application code runs. +- Reads and writes remain tenant-aware, including audit query and membership listing. + +## Observability Surface + +### Logs + +- `pino` emits structured logs with `module`, `correlation_id`, `tenant_id` and `user_id`. +- `Authorization`, cookies and stack traces are sanitized before emission. +- module failures are logged with semantic error codes and exported as metrics. + +### Tracing + +- the HTTP entrypoint starts and finishes a request span for every request +- `DatabaseExecutor` emits `database.query` and `database.transaction` spans +- `InternalEventBus` emits `internal_event.publish` and `internal_event.handle` spans +- critical use cases emit spans for login, account creation, organization creation, membership assignment, RBAC mutation, authorization and audit operations + +### Metrics + +- `nexus_http_requests_total` +- `nexus_http_request_duration_ms` +- `nexus_module_failures_total` +- `nexus_identity_logins_total` +- `nexus_authorization_decisions_total` +- `nexus_audit_operations_total` +- `nexus_audit_operation_duration_ms` ## Constraints Preserved -- No external message bus or ACL/ABAC model. -- Membership remains the prerequisite for login into a tenant; RBAC only governs what the authenticated member can do after login. -- `audit_logs` remains append-only and is queried only within the active tenant context in this phase. +- no external message bus or distributed workflow engine +- membership remains the prerequisite for tenant login +- no new business modules were introduced in Phase 5 +- controllers remain thin and business rules stay inside application/domain layers +- observability was expanded without breaking the modular monolith boundaries diff --git a/docs/commands.md b/docs/commands.md index 9254c42..08a9941 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,24 +1,39 @@ # Commands -## Core Commands +## Core Scripts ```bash cp .env.example .env npm install npm run db:migrate -npm run start:dev +npm run dev +npm run docker:up +npm run docker:logs +npm run docker:down npm run build npm run lint npm run test npm run test:unit npm run test:integration npm run test:functional -docker compose up -d --build -docker compose down -v -docker compose logs -f app postgres +npm run ci ``` -## Phase 4 API Smoke +## Make Wrappers + +```bash +make up +make down +make run +make test +make test-unit +make test-integration +make test-functional +make lint +make ci +``` + +## Phase 5 API Smoke ```bash curl -X POST http://localhost:3000/identity/accounts \ @@ -53,17 +68,30 @@ curl -X POST http://localhost:3000/organizations//memberships \ -H "Content-Type: application/json" \ -d '{"userId":""}' -curl -X POST http://localhost:3000/users//roles \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"roleId":""}' - -curl http://localhost:3000/organizations//memberships \ +curl "http://localhost:3000/organizations//memberships?limit=50&offset=0" \ -H "Authorization: Bearer " -curl "http://localhost:3000/audit-logs?tenantId=" \ +curl "http://localhost:3000/audit-logs?tenantId=&limit=50&offset=0" \ -H "Authorization: Bearer " -curl "http://localhost:3000/audit-logs?tenantId=&action=authorization_denied" \ +curl "http://localhost:3000/audit-logs?tenantId=&action=authorization_denied&limit=20&offset=0" \ -H "Authorization: Bearer " + +curl http://localhost:3000/metrics +``` + +## Standardized Error Example + +```bash +curl -X POST http://localhost:3000/identity/login \ + -H "Content-Type: application/json" \ + -d '{"email":"jane@example.com","password":"short"}' +``` + +```json +{ + "error": "invalid_request", + "message": "password must be longer than or equal to 8 characters", + "correlation_id": "7ef4e259-31d8-4b91-ae84-0f81f00c84d3" +} ``` diff --git a/docs/handoff.md b/docs/handoff.md index 086672b..93d45c2 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,50 +1,53 @@ # Handoff ## Contexto -- Objetivo da tarefa: implementar a Phase 4 — Auditability do Nexus Platform. +- Objetivo da tarefa: implementar a Phase 5 — Quality, Observability & Engineering do Nexus Platform. - Fase atual: concluída. -- Escopo atendido: módulo `audit-logs`, migration append-only, bus interno mínimo, endpoint de consulta, publicação de eventos nos fluxos críticos, testes e atualização documental. +- Escopo atendido: contrato de erro unificado, validação endurecida, métricas e tracing ampliados, logs estruturados/sanitizados, paginação/índices e atualização documental. ## O que foi feito -- módulo `audit-logs` implementado com entidade imutável, casos de uso de append/query, repositório PostgreSQL, controller HTTP e subscribers do bus interno -- migration `0004_audit_logs.sql` criada com tabela `audit_logs`, índices e triggers que bloqueiam `UPDATE` e `DELETE` -- `RequestCorrelationContext` e `InternalEventBus` adicionados para propagar `correlationId` e desacoplar a persistência de auditoria -- fluxos de identity, organizations, users e access-control passaram a publicar eventos auditáveis -- `AuthorizationGuard` passou a persistir `authorization_denied` sem alterar a resposta `403` -- suites unitárias ampliadas para domínio, append/query e mapeamento de subscribers; suites de integração e functional receberam cobertura nova para auditabilidade +- contrato HTTP de erro padronizado para `{ error, message, correlation_id }` com `x-correlation-id` em toda resposta +- `ValidationPipe` passou a usar `exceptionFactory` central para falha precoce e mensagem previsível +- `ApplicationTelemetryService` e `ApplicationMetricsService` passaram a instrumentar HTTP, banco, bus interno, login, RBAC e audit logs +- endpoint `GET /metrics` adicionado com formato Prometheus scrape-friendly +- `GET /audit-logs` e `GET /organizations/:id/memberships` ganharam `limit` e `offset` +- migration `0005_quality_observability_engineering.sql` adicionou índices compostos para audit query e listagem de memberships +- suites unitárias, de integração e funcionais foram ampliadas para erro padronizado, correlation id, paginação, métricas e observabilidade ## Arquivos alterados -- `src/modules/audit-logs/**/*` -- `src/shared/events/*` -- `src/shared/request-correlation/*` +- `src/bootstrap/**/*` - `src/modules/identity/**/*` - `src/modules/organizations/**/*` - `src/modules/users/**/*` - `src/modules/access-control/**/*` -- `migrations/0004_audit_logs.sql` +- `src/modules/audit-logs/**/*` +- `src/shared/**/*` +- `migrations/0005_quality_observability_engineering.sql` - `test/**/*` -- `README.md`, `CHANGELOG.md`, `docs/*`, `.agents/decisions/0007-minimal-internal-event-bus-for-auditability.md` +- `package.json` +- `README.md`, `CHANGELOG.md`, `docs/*`, `.agents/decisions/0008-http-error-contract-and-observability-boundary.md` ## Decisões tomadas -- storage de auditoria é append-only no banco e não expõe nenhuma operação de update/delete em aplicação. -- `GET /audit-logs` exige `audit:view` e só aceita `tenantId` igual ao tenant ativo da sessão. -- falhas de audit append em ações mutáveis bem-sucedidas abortam a transação principal; falhas ao auditar `login_failed` e `authorization_denied` só geram log operacional. +- a API mudou agora para o novo contrato de erro, sem modo de compatibilidade +- métricas ficam expostas em `GET /metrics` sem autenticação no app e devem ser protegidas pela infraestrutura +- logs do boundary nunca incluem `Authorization`, cookies, senha ou stack interna +- paginação pública foi limitada a audit logs e memberships para reduzir churn de contrato ## Testes -- Unit: `npm run test:unit` executado com sucesso. -- Integration: `npm run test:integration` executado; suites ficaram puladas por indisponibilidade do daemon Docker, mas os novos testes compilaram e a aplicação buildou com sucesso. -- Functional: `npm run test:functional` executado; suites ficaram puladas por indisponibilidade do daemon Docker, mas os novos testes compilaram e a aplicação buildou com sucesso. +- Unit: `npm run test:unit` executado +- Integration: `npm run test:integration` executado; suites ficaram puladas por indisponibilidade do daemon Docker local +- Functional: `npm run test:functional` executado; suites ficaram puladas por indisponibilidade do daemon Docker local ## Impactos avaliados -- Tenant: queries e persistência de audit continuam tenant-aware; mismatch entre query e tenant ativo é negado. -- RBAC: endpoint de consulta protegido por `audit:view`; denials seguem deny-by-default e agora deixam trilha persistida. -- Audit logs: ações críticas existentes passam a gerar rows append-only com `correlationId`, `action`, `resource`, `metadata`, `userId` e `tenantId`. -- Observability: eventos `audit_log_appended` e `audit_log_query` adicionados, além do log operacional para falha de append em fluxos de negação. +- Tenant: leitura e escrita continuam tenant-aware; mismatch de tenant em rota ou query continua negado explicitamente +- RBAC: autorização segue deny-by-default e agora exporta métricas `allow/deny` +- Audit logs: consulta ficou paginada, observável e continua append-only com `correlationId` +- Observability: requests, falhas por módulo, login, autorização e operações de auditoria agora possuem métricas mínimas e spans explícitos ## Riscos e pendências -- validar localmente as suites de integração e functional com Docker ativo para evidência end-to-end completa da migration `0004` -- evoluir a matriz de ações para `user_updated` e `user_deactivated` quando os fluxos correspondentes existirem no produto +- validar localmente as suites de integração e functional com Docker ativo para evidência completa dos cenários ponta a ponta +- observar cardinalidade de labels em produção ao ampliar métricas ou rotas públicas no futuro ## Próximos passos recomendados -1. Executar suites de integração e functional com Docker disponível para evidência local completa da Phase 4. -2. Evoluir a modelagem de eventos internos se o projeto avançar para novos subscribers além de auditoria. +1. Executar as suites de integração e functional em ambiente local com Docker disponível para fechar a evidência end-to-end da Phase 5. +2. Evoluir a exportação de traces para backend dedicado quando houver stack operacional definida para observabilidade distribuída. diff --git a/docs/phase-status.md b/docs/phase-status.md index 54b765f..f99b295 100644 --- a/docs/phase-status.md +++ b/docs/phase-status.md @@ -1,30 +1,31 @@ # Phase Status ## Fase atual -- Nome: Phase 4 — Auditability +- Nome: Phase 5 — Quality, Observability & Engineering - Status: done - Responsável: Codex - Data: 2026-03-26 ## Objetivo da fase -- Introduzir trilha de auditoria append-only com contexto completo, query protegida por tenant/RBAC e desacoplamento por eventos internos síncronos. +- endurecer a borda HTTP, ampliar a cobertura de testes, reforçar observabilidade e manter a base multi-tenant/RBAC/auditável pronta para evolução segura ## Entradas - `AGENTS.md` e regras em `.agents/rules/*` -- contexto do produto, mapa de módulos e ADRs 0001, 0002, 0003, 0004, 0005 e 0006 +- contexto do produto, mapa de módulos e ADRs 0001, 0002, 0003, 0004, 0005, 0006, 0007 e 0008 - página `Nexus Platform` e documentação complementar no Notion ## Saídas esperadas -- módulo `audit-logs` funcional com entidade imutável, casos de uso, repositório PostgreSQL e endpoint `GET /audit-logs` -- migration `0004_audit_logs.sql` com proteção append-only em storage -- bus interno mínimo para publicação/assinatura de eventos auditáveis -- ações críticas existentes emitindo eventos e persistindo audit rows +- contrato de erro padronizado com `correlation_id` e validação consistente no boundary HTTP +- métricas Prometheus em `GET /metrics`, spans nos fluxos críticos e logs estruturados com contexto útil +- paginação e índices adicionais para `audit_logs` e `memberships` +- testes unitários, de integração e funcionais ampliados para qualidade, autorização, auditoria e observabilidade - documentação operacional atualizada para a nova fase ## Bloqueios -- daemon Docker indisponível no ambiente local durante a validação desta execução, então suites de integração e functional permaneceram puladas localmente após compilar com os novos testes +- daemon Docker indisponível no ambiente local durante a validação desta execução, então suites de integração e functional permaneceram puladas localmente após lint, build e execução das suites ## Decisões / observações -- `audit_logs.user_id` e `audit_logs.tenant_id` aceitam `null` para cenários que genuinamente não resolvem ator ou tenant, como login falho ou sessão bootstrap. -- o bus interno é síncrono e in-process para manter atomicidade e simplicidade; não há mensageria externa nesta fase. -- mutações bem-sucedidas auditadas persistem o log na mesma transação da ação principal quando a operação altera estado. +- o contrato público de erro mudou sem camada de compatibilidade e agora usa `error`, `message` e `correlation_id` +- `GET /metrics` permanece sem autenticação na aplicação; a contenção de acesso fica na borda operacional +- a paginação pública entra apenas em `GET /audit-logs` e `GET /organizations/:id/memberships`; roles e permissions continuam sem paginação nesta fase +- observabilidade foi ampliada sem introduzir mensageria externa nem quebrar o monólito modular diff --git a/migrations/0005_quality_observability_engineering.sql b/migrations/0005_quality_observability_engineering.sql new file mode 100644 index 0000000..0503f97 --- /dev/null +++ b/migrations/0005_quality_observability_engineering.sql @@ -0,0 +1,11 @@ +CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_timestamp_id + ON audit_logs (tenant_id, timestamp DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_action_timestamp + ON audit_logs (tenant_id, action, timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_user_timestamp + ON audit_logs (tenant_id, user_id, timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_memberships_organization_created_id + ON memberships (organization_id, created_at ASC, id ASC); diff --git a/package.json b/package.json index 30f3354..8e0f206 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,21 @@ "main": "dist/main.js", "scripts": { "build": "nest build", + "ci": "npm run lint && npm run build && npm run test", + "dev": "nest start --watch", "db:migrate": "ts-node src/bootstrap/persistence/migrations/run-migrations.ts", + "docker:down": "docker compose down --remove-orphans", + "docker:logs": "docker compose logs -f app postgres", + "docker:up": "docker compose up -d --build", "start": "node dist/main.js", - "start:dev": "nest start --watch", + "start:dev": "npm run dev", "start:prod": "node dist/main.js", "lint": "eslint .", "test": "npm run test:unit && npm run test:integration && npm run test:functional", "test:unit": "jest --selectProjects unit", "test:integration": "jest --selectProjects integration --runInBand", "test:functional": "jest --selectProjects functional --runInBand", - "test:ci": "npm run lint && npm run build && npm run test" + "test:ci": "npm run ci" }, "engines": { "node": ">=24.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index f4ab986..bfc5efb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { AppConfigModule } from "./bootstrap/config/app-config.module"; import { HealthModule } from "./bootstrap/http/health.module"; import { LoggingModule } from "./bootstrap/logging/logging.module"; import { DatabaseModule } from "./bootstrap/persistence/database.module"; +import { TelemetryModule } from "./bootstrap/telemetry/telemetry.module"; import { AccessControlModule } from "./modules/access-control/access-control.module"; import { AuditLogsModule } from "./modules/audit-logs/audit-logs.module"; import { IdentityModule } from "./modules/identity/identity.module"; @@ -16,6 +17,7 @@ import { SecurityModule } from "./shared/security.module"; AppConfigModule, LoggingModule, DatabaseModule, + TelemetryModule, HealthModule, SecurityModule, IdentityModule, diff --git a/src/bootstrap/application.factory.ts b/src/bootstrap/application.factory.ts index c050fa8..868b813 100644 --- a/src/bootstrap/application.factory.ts +++ b/src/bootstrap/application.factory.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { ValidationPipe, type INestApplication } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; +import { SpanStatusCode } from "@opentelemetry/api"; import { Logger } from "nestjs-pino"; import { PinoLogger } from "nestjs-pino"; import type { NextFunction, Request, Response } from "express"; @@ -9,6 +10,9 @@ import type { NextFunction, Request, Response } from "express"; import { AppModule } from "../app.module"; import { RequestCorrelationContext } from "../shared/request-correlation/request-correlation.context"; import { GlobalExceptionFilter } from "./errors/http-exception.filter"; +import { createValidationExceptionFactory } from "./errors/validation-exception.factory"; +import { ApplicationMetricsService } from "./telemetry/application-metrics.service"; +import { ApplicationTelemetryService } from "./telemetry/application-telemetry.service"; import { shutdownTelemetry } from "./telemetry/telemetry.sdk"; export async function createApplication(): Promise { @@ -16,6 +20,8 @@ export async function createApplication(): Promise { bufferLogs: true, }); const requestCorrelationContext = application.get(RequestCorrelationContext); + const applicationTelemetryService = application.get(ApplicationTelemetryService); + const applicationMetricsService = application.get(ApplicationMetricsService); application.useLogger(application.get(Logger)); application.enableShutdownHooks(); @@ -32,17 +38,65 @@ export async function createApplication(): Promise { : randomUUID(); request.id = correlationId; + _response.setHeader("x-correlation-id", correlationId); - requestCorrelationContext.run(correlationId, () => next()); + const requestStartedAt = performance.now(); + const span = applicationTelemetryService.startHttpServerSpan("http.request", { + "http.method": request.method, + "http.route": resolveHttpRoute(request), + "http.target": request.originalUrl ?? request.url, + "request.correlation_id": correlationId, + }); + let requestCompleted = false; + const finalizeRequest = () => { + if (requestCompleted) { + return; + } + + requestCompleted = true; + const route = resolveHttpRoute(request); + const statusCode = _response.statusCode; + + span.setAttribute("http.status_code", statusCode); + span.setAttribute("http.route", route); + span.setStatus({ + code: statusCode >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.OK, + }); + applicationTelemetryService.endHttpServerSpan( + span, + { + "http.route": route, + "http.status_code": statusCode, + "request.correlation_id": correlationId, + }, + statusCode, + ); + applicationMetricsService.recordHttpRequest({ + durationMs: performance.now() - requestStartedAt, + method: request.method, + route, + statusCode, + }); + }; + + _response.once("finish", finalizeRequest); + _response.once("close", finalizeRequest); + + applicationTelemetryService.bindSpan(span, () => { + requestCorrelationContext.run(correlationId, () => next()); + }); }); application.useGlobalPipes( new ValidationPipe({ + exceptionFactory: createValidationExceptionFactory(), forbidNonWhitelisted: true, transform: true, whitelist: true, }), ); - application.useGlobalFilters(new GlobalExceptionFilter(application.get(PinoLogger))); + application.useGlobalFilters( + new GlobalExceptionFilter(application.get(PinoLogger), applicationMetricsService), + ); return application; } @@ -51,3 +105,17 @@ export async function disposeApplication(application: INestApplication): Promise await application.close(); await shutdownTelemetry(); } + +function resolveHttpRoute(request: Request): string { + const route = request.route as { path?: unknown } | undefined; + const routePath = + route !== undefined && typeof route.path === "string" ? route.path : undefined; + + if (routePath !== undefined) { + const baseUrl = typeof request.baseUrl === "string" ? request.baseUrl : ""; + + return `${baseUrl}${routePath}`; + } + + return (request.path ?? request.url).split("?")[0] ?? "unknown"; +} diff --git a/src/bootstrap/errors/http-exception.filter.ts b/src/bootstrap/errors/http-exception.filter.ts index 9925f61..cfc0d76 100644 --- a/src/bootstrap/errors/http-exception.filter.ts +++ b/src/bootstrap/errors/http-exception.filter.ts @@ -2,6 +2,8 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from import { PinoLogger } from "nestjs-pino"; import type { Request, Response } from "express"; +import { ApplicationMetricsService } from "../telemetry/application-metrics.service"; +import type { RequestWithSecurityContext } from "../../shared/auth/request-context.types"; import { AuthenticationError, AuthorizationError, @@ -12,14 +14,17 @@ import { } from "../../shared/domain/nexus.errors"; interface ErrorResponseBody { - readonly statusCode: number; readonly error: string; readonly message: string | string[]; + readonly correlation_id: string; } @Catch() export class GlobalExceptionFilter implements ExceptionFilter { - public constructor(private readonly logger: PinoLogger) { + public constructor( + private readonly logger: PinoLogger, + private readonly applicationMetricsService: ApplicationMetricsService, + ) { this.logger.setContext(GlobalExceptionFilter.name); } @@ -27,53 +32,65 @@ export class GlobalExceptionFilter implements ExceptionFilter { const context = host.switchToHttp(); const request = context.getRequest(); const response = context.getResponse(); - const normalizedError = this.normalizeError(exception); + const correlationId = this.readCorrelationId(request); + const normalizedError = this.normalizeError(exception, correlationId); - this.logFailure(request, normalizedError, exception); + response.setHeader("x-correlation-id", correlationId); + this.logFailure(request, normalizedError); response.status(normalizedError.statusCode).json(normalizedError.body); } - private normalizeError(exception: unknown): { + private normalizeError( + exception: unknown, + correlationId: string, + ): { readonly body: ErrorResponseBody; readonly code: string; readonly statusCode: number; } { if (exception instanceof HttpException) { - return this.normalizeHttpException(exception); + return this.normalizeHttpException(exception, correlationId); } if (exception instanceof ValidationError) { - return this.buildNexusErrorResponse(exception, HttpStatus.BAD_REQUEST, "Bad Request"); + return this.buildNexusErrorResponse(exception, HttpStatus.BAD_REQUEST, correlationId); } if (exception instanceof ConflictError) { - return this.buildNexusErrorResponse(exception, HttpStatus.CONFLICT, "Conflict"); + return this.buildNexusErrorResponse(exception, HttpStatus.CONFLICT, correlationId); } if (exception instanceof AuthenticationError) { - return this.buildNexusErrorResponse(exception, HttpStatus.UNAUTHORIZED, "Unauthorized"); + return this.buildNexusErrorResponse( + exception, + HttpStatus.UNAUTHORIZED, + correlationId, + ); } if (exception instanceof AuthorizationError) { - return this.buildNexusErrorResponse(exception, HttpStatus.FORBIDDEN, "Forbidden"); + return this.buildNexusErrorResponse(exception, HttpStatus.FORBIDDEN, correlationId); } if (exception instanceof NotFoundError) { - return this.buildNexusErrorResponse(exception, HttpStatus.NOT_FOUND, "Not Found"); + return this.buildNexusErrorResponse(exception, HttpStatus.NOT_FOUND, correlationId); } return { body: { - error: "Internal Server Error", + correlation_id: correlationId, + error: "internal_error", message: "Internal server error", - statusCode: HttpStatus.INTERNAL_SERVER_ERROR, }, code: "internal_error", statusCode: HttpStatus.INTERNAL_SERVER_ERROR, }; } - private normalizeHttpException(exception: HttpException): { + private normalizeHttpException( + exception: HttpException, + correlationId: string, + ): { readonly body: ErrorResponseBody; readonly code: string; readonly statusCode: number; @@ -84,11 +101,11 @@ export class GlobalExceptionFilter implements ExceptionFilter { if (typeof response === "string") { return { body: { - error: this.resolveHttpStatusTitle(statusCode), + correlation_id: correlationId, + error: this.resolveHttpStatusCode(statusCode), message: response, - statusCode, }, - code: "http_exception", + code: this.resolveHttpStatusCode(statusCode), statusCode, }; } @@ -97,11 +114,13 @@ export class GlobalExceptionFilter implements ExceptionFilter { return { body: { - error: this.readString(responseBody.error) ?? this.resolveHttpStatusTitle(statusCode), + correlation_id: correlationId, + error: + this.readSemanticErrorCode(responseBody.error) ?? this.resolveHttpStatusCode(statusCode), message: this.readMessage(responseBody.message), - statusCode, }, - code: "http_exception", + code: + this.readSemanticErrorCode(responseBody.error) ?? this.resolveHttpStatusCode(statusCode), statusCode, }; } @@ -109,7 +128,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { private buildNexusErrorResponse( exception: NexusError, statusCode: number, - error: string, + correlationId: string, ): { readonly body: ErrorResponseBody; readonly code: string; @@ -117,9 +136,9 @@ export class GlobalExceptionFilter implements ExceptionFilter { } { return { body: { - error, + correlation_id: correlationId, + error: exception.code, message: exception.publicMessage, - statusCode, }, code: exception.code, statusCode, @@ -129,23 +148,46 @@ export class GlobalExceptionFilter implements ExceptionFilter { private logFailure( request: Request & { id?: string | number }, normalizedError: { readonly code: string; readonly statusCode: number }, - exception: unknown, ): void { const message = "HTTP request failed"; + const requestWithSecurityContext = request as RequestWithSecurityContext & { + id?: string | number; + }; + const authenticatedPrincipal = + typeof requestWithSecurityContext.authenticatedPrincipal === "object" + ? requestWithSecurityContext.authenticatedPrincipal + : undefined; + const tenantContext = + typeof requestWithSecurityContext.tenantContext === "object" + ? requestWithSecurityContext.tenantContext + : undefined; const logPayload = { - correlationId: - typeof request.id === "number" || typeof request.id === "string" - ? request.id.toString() - : "unknown", + correlationId: this.readCorrelationId(request), errorCode: normalizedError.code, event: normalizedError.statusCode >= 500 ? "error" : "alert", method: request.method, path: request.url, statusCode: normalizedError.statusCode, + ...(tenantContext === undefined + ? {} + : { + tenantId: tenantContext.organizationId, + }), + ...(authenticatedPrincipal === undefined + ? {} + : { + userId: authenticatedPrincipal.userId, + }), }; + this.applicationMetricsService.recordModuleFailure({ + errorCode: normalizedError.code, + module: this.resolveModuleName(request.url), + operation: "http_request", + }); + if (normalizedError.statusCode >= 500) { - this.logger.error({ ...logPayload, err: exception }, message); + this.logger.error(logPayload, message); return; } @@ -164,23 +206,69 @@ export class GlobalExceptionFilter implements ExceptionFilter { return "Unexpected request error"; } - private readString(value: unknown): string | undefined { + private readCorrelationId(request: Request & { id?: string | number }): string { + if (typeof request.id === "number" || typeof request.id === "string") { + return request.id.toString(); + } + + return "unknown"; + } + + private readSemanticErrorCode(value: unknown): string | undefined { if (typeof value !== "string" || value.length === 0) { return undefined; } - return value; + if (/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(value)) { + return value; + } + + return undefined; } - private resolveHttpStatusTitle(statusCode: number): string { + private resolveHttpStatusCode(statusCode: number): string { return ( { - [HttpStatus.BAD_REQUEST]: "Bad Request", - [HttpStatus.UNAUTHORIZED]: "Unauthorized", - [HttpStatus.FORBIDDEN]: "Forbidden", - [HttpStatus.NOT_FOUND]: "Not Found", - [HttpStatus.CONFLICT]: "Conflict", - }[statusCode] ?? "Error" + [HttpStatus.BAD_REQUEST]: "invalid_request", + [HttpStatus.UNAUTHORIZED]: "unauthorized", + [HttpStatus.FORBIDDEN]: "forbidden", + [HttpStatus.NOT_FOUND]: "not_found", + [HttpStatus.CONFLICT]: "conflict", + }[statusCode] ?? "internal_error" ); } + + private resolveModuleName(path: string): string { + const normalizedPath = path.split("?")[0] ?? ""; + + if (normalizedPath.startsWith("/identity")) { + return "identity"; + } + + if (normalizedPath.startsWith("/organizations")) { + return "organizations"; + } + + if ( + normalizedPath.startsWith("/roles") || + normalizedPath.startsWith("/permissions") || + /^\/users\/[^/]+\/roles$/.test(normalizedPath) + ) { + return "access-control"; + } + + if (normalizedPath.startsWith("/audit-logs")) { + return "audit-logs"; + } + + if (normalizedPath.startsWith("/metrics")) { + return "telemetry"; + } + + if (normalizedPath.startsWith("/health")) { + return "health"; + } + + return "bootstrap"; + } } diff --git a/src/bootstrap/errors/validation-exception.factory.ts b/src/bootstrap/errors/validation-exception.factory.ts new file mode 100644 index 0000000..1fce1de --- /dev/null +++ b/src/bootstrap/errors/validation-exception.factory.ts @@ -0,0 +1,40 @@ +import type { ValidationError as ClassValidationError } from "class-validator"; + +import { InvalidRequestError } from "../../shared/domain/nexus.errors"; + +export function createValidationExceptionFactory() { + return (errors: ClassValidationError[]): InvalidRequestError => + new InvalidRequestError(readValidationMessage(errors)); +} + +function readValidationMessage(errors: ClassValidationError[]): string { + for (const error of errors) { + const directMessage = readDirectConstraintMessage(error); + + if (directMessage !== undefined) { + return directMessage; + } + + if (error.children !== undefined && error.children.length > 0) { + const childMessage = readValidationMessage(error.children); + + if (childMessage.length > 0) { + return childMessage; + } + } + } + + return "Request validation failed"; +} + +function readDirectConstraintMessage( + error: ClassValidationError, +): string | undefined { + if (error.constraints === undefined) { + return undefined; + } + + return Object.values(error.constraints).find( + (message) => typeof message === "string" && message.length > 0, + ); +} diff --git a/src/bootstrap/logging/logging.module.ts b/src/bootstrap/logging/logging.module.ts index fdec785..9b800a2 100644 --- a/src/bootstrap/logging/logging.module.ts +++ b/src/bootstrap/logging/logging.module.ts @@ -14,6 +14,7 @@ import { buildPinoHttpConfiguration } from "./pino-logger.config"; inject: [APP_CONFIG], useFactory: (config: AppConfig) => ({ pinoHttp: buildPinoHttpConfiguration(config), + renameContext: "module", }), }), ], diff --git a/src/bootstrap/logging/pino-logger.config.ts b/src/bootstrap/logging/pino-logger.config.ts index a09b122..48b016a 100644 --- a/src/bootstrap/logging/pino-logger.config.ts +++ b/src/bootstrap/logging/pino-logger.config.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import pino from "pino"; import type { Options } from "pino-http"; import type { AppConfig } from "../config/app-config"; +import type { RequestWithSecurityContext } from "../../shared/auth/request-context.types"; export function buildPinoHttpConfiguration( config: AppConfig, @@ -11,12 +13,11 @@ export function buildPinoHttpConfiguration( return { autoLogging: true, base: null, - customProps: (request) => ({ - correlationId: - typeof request.id === "string" || typeof request.id === "number" - ? request.id.toString() - : "unknown", - }), + customProps: (request) => buildHttpLogProperties(request as RequestWithSecurityContext), + formatters: { + level: (label) => ({ level: label }), + log: (object) => renameStructuredLogFields(object), + }, genReqId: (request) => { const correlationHeader = request.headers["x-correlation-id"]; @@ -36,6 +37,113 @@ export function buildPinoHttpConfiguration( }, level: config.app.nodeEnv === "development" ? "debug" : "info", messageKey: "message", + serializers: { + err: (error) => sanitizeError(error), + req: (request: IncomingMessage) => sanitizeRequest(request), + }, timestamp: () => `,"timestamp":"${new Date().toISOString()}"`, }; } + +function buildHttpLogProperties( + request: RequestWithSecurityContext & { id?: unknown }, +): Record { + return { + correlationId: + typeof request.id === "string" || typeof request.id === "number" + ? request.id.toString() + : "unknown", + module: "http", + ...(request.tenantContext === undefined + ? {} + : { + tenantId: request.tenantContext.organizationId, + }), + ...(request.authenticatedPrincipal === undefined + ? {} + : { + userId: request.authenticatedPrincipal.userId, + }), + }; +} + +function renameStructuredLogFields( + object: Record, +): Record { + const renamedObject = { ...object }; + + moveStructuredLogField(renamedObject, "correlationId", "correlation_id"); + moveStructuredLogField(renamedObject, "organizationId", "tenant_id"); + moveStructuredLogField(renamedObject, "tenantId", "tenant_id"); + moveStructuredLogField(renamedObject, "userId", "user_id"); + moveStructuredLogField(renamedObject, "targetUserId", "target_user_id"); + moveStructuredLogField(renamedObject, "accountId", "account_id"); + moveStructuredLogField(renamedObject, "sessionId", "session_id"); + moveStructuredLogField(renamedObject, "permissionCode", "permission_code"); + moveStructuredLogField(renamedObject, "permissionId", "permission_id"); + moveStructuredLogField(renamedObject, "roleId", "role_id"); + moveStructuredLogField(renamedObject, "membershipId", "membership_id"); + moveStructuredLogField(renamedObject, "statusCode", "status_code"); + moveStructuredLogField(renamedObject, "responseTime", "response_time"); + moveStructuredLogField(renamedObject, "errorCode", "error_code"); + moveStructuredLogField(renamedObject, "failedAuditEvent", "failed_audit_event"); + + return renamedObject; +} + +function moveStructuredLogField( + object: Record, + currentKey: string, + targetKey: string, +): void { + if (!(currentKey in object) || currentKey === targetKey) { + return; + } + + if (!(targetKey in object)) { + object[targetKey] = object[currentKey]; + } + + delete object[currentKey]; +} + +function sanitizeRequest(request: IncomingMessage): Record { + const serializedRequest = pino.stdSerializers.req(request); + + if (serializedRequest === undefined || typeof serializedRequest !== "object") { + return {}; + } + + const sanitizedRequest = { + ...((serializedRequest as unknown) as Record), + }; + const headers = sanitizedRequest.headers; + + if (headers !== undefined && typeof headers === "object" && headers !== null) { + const sanitizedHeaders = { ...(headers as Record) }; + + delete sanitizedHeaders.authorization; + delete sanitizedHeaders.cookie; + delete sanitizedHeaders["set-cookie"]; + sanitizedRequest.headers = sanitizedHeaders; + } + + return sanitizedRequest; +} + +function sanitizeError(error: unknown): Record { + const serializedError = + error instanceof Error ? pino.stdSerializers.err(error) : undefined; + + if (serializedError === undefined || typeof serializedError !== "object") { + return {}; + } + + const sanitizedError = { + ...(serializedError as Record), + }; + + delete sanitizedError.stack; + + return sanitizedError; +} diff --git a/src/bootstrap/persistence/database.executor.ts b/src/bootstrap/persistence/database.executor.ts index 3aef816..4b4ae14 100644 --- a/src/bootstrap/persistence/database.executor.ts +++ b/src/bootstrap/persistence/database.executor.ts @@ -1,24 +1,44 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import type { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; +import { ApplicationTelemetryService } from "../telemetry/application-telemetry.service"; import { DATABASE_POOL } from "./database.constants"; @Injectable() export class DatabaseExecutor { private readonly transactionStorage = new AsyncLocalStorage(); - public constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {} + public constructor( + @Inject(DATABASE_POOL) private readonly pool: Pool, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, + ) {} public async query( statement: string, values: readonly unknown[] = [], ): Promise> { - const client = this.transactionStorage.getStore(); - const executor = client ?? this.pool; + const executeQuery = async (): Promise> => { + const client = this.transactionStorage.getStore(); + const executor = client ?? this.pool; - return executor.query(statement, [...values]); + return executor.query(statement, [...values]); + }; + + if (this.applicationTelemetryService === undefined) { + return executeQuery(); + } + + return this.applicationTelemetryService.runInSpan( + "database.query", + { + "db.operation": readStatementOperation(statement), + "db.system": "postgresql", + }, + executeQuery, + ); } public async withTransaction(operation: () => Promise): Promise { @@ -28,21 +48,41 @@ export class DatabaseExecutor { return operation(); } - const client = await this.pool.connect(); + const executeTransaction = async (): Promise => { + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); - try { - await client.query("BEGIN"); + const result = await this.transactionStorage.run(client, operation); - const result = await this.transactionStorage.run(client, operation); + await client.query("COMMIT"); - await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + }; - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); + if (this.applicationTelemetryService === undefined) { + return executeTransaction(); } + + return this.applicationTelemetryService.runInSpan( + "database.transaction", + { + "db.system": "postgresql", + }, + executeTransaction, + ); } } + +function readStatementOperation(statement: string): string { + const [operation = "unknown"] = statement.trim().split(/\s+/); + + return operation.toUpperCase(); +} diff --git a/src/bootstrap/telemetry/application-metrics.service.ts b/src/bootstrap/telemetry/application-metrics.service.ts new file mode 100644 index 0000000..609fde1 --- /dev/null +++ b/src/bootstrap/telemetry/application-metrics.service.ts @@ -0,0 +1,289 @@ +import { Injectable } from "@nestjs/common"; + +const DEFAULT_DURATION_BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]; + +interface CounterSeries { + readonly labels: Record; + value: number; +} + +interface CounterMetric { + readonly help: string; + readonly name: string; + readonly series: Map; + readonly type: "counter"; +} + +interface HistogramSeries { + readonly bucketCounts: number[]; + count: number; + readonly labels: Record; + sum: number; +} + +interface HistogramMetric { + readonly buckets: number[]; + readonly help: string; + readonly name: string; + readonly series: Map; + readonly type: "histogram"; +} + +type Metric = CounterMetric | HistogramMetric; + +@Injectable() +export class ApplicationMetricsService { + private readonly metrics = new Map(); + + public recordHttpRequest(input: { + readonly durationMs: number; + readonly method: string; + readonly route: string; + readonly statusCode: number; + }): void { + const labels = { + method: input.method, + route: input.route, + status_code: input.statusCode.toString(), + }; + + this.incrementCounter( + "nexus_http_requests_total", + "Total HTTP requests handled by the application.", + labels, + ); + this.observeHistogram( + "nexus_http_request_duration_ms", + "HTTP request duration in milliseconds.", + input.durationMs, + labels, + ); + } + + public recordModuleFailure(input: { + readonly errorCode: string; + readonly module: string; + readonly operation: string; + }): void { + this.incrementCounter( + "nexus_module_failures_total", + "Total failed operations grouped by module and operation.", + { + error_code: input.errorCode, + module: input.module, + operation: input.operation, + }, + ); + } + + public recordLoginResult(result: "success" | "failure"): void { + this.incrementCounter( + "nexus_identity_logins_total", + "Total login attempts grouped by result.", + { result }, + ); + } + + public recordAuthorizationDecision(result: "allow" | "deny"): void { + this.incrementCounter( + "nexus_authorization_decisions_total", + "Total authorization decisions grouped by result.", + { result }, + ); + } + + public recordAuditOperation(input: { + readonly durationMs: number; + readonly operation: "append" | "query"; + }): void { + this.incrementCounter( + "nexus_audit_operations_total", + "Total audit operations grouped by operation.", + { operation: input.operation }, + ); + this.observeHistogram( + "nexus_audit_operation_duration_ms", + "Audit operation duration in milliseconds.", + input.durationMs, + { operation: input.operation }, + ); + } + + public renderPrometheusMetrics(): string { + const lines: string[] = []; + const metrics = [...this.metrics.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); + + for (const metric of metrics) { + lines.push(`# HELP ${metric.name} ${metric.help}`); + lines.push(`# TYPE ${metric.name} ${metric.type}`); + + if (metric.type === "counter") { + for (const [seriesKey, series] of [...metric.series.entries()].sort()) { + lines.push( + `${metric.name}${formatLabels(series.labels)} ${series.value}`, + ); + void seriesKey; + } + + lines.push(""); + continue; + } + + for (const [seriesKey, series] of [...metric.series.entries()].sort()) { + let cumulativeCount = 0; + + metric.buckets.forEach((bucket, index) => { + cumulativeCount += series.bucketCounts[index] ?? 0; + lines.push( + `${metric.name}_bucket${formatLabels({ + ...series.labels, + le: bucket.toString(), + })} ${cumulativeCount}`, + ); + }); + lines.push( + `${metric.name}_bucket${formatLabels({ + ...series.labels, + le: "+Inf", + })} ${series.count}`, + ); + lines.push( + `${metric.name}_sum${formatLabels(series.labels)} ${series.sum}`, + ); + lines.push( + `${metric.name}_count${formatLabels(series.labels)} ${series.count}`, + ); + void seriesKey; + } + + lines.push(""); + } + + return `${lines.join("\n").trim()}\n`; + } + + private incrementCounter( + name: string, + help: string, + labels: Record, + value = 1, + ): void { + const metric = this.getOrCreateCounter(name, help); + const series = metric.series.get(buildSeriesKey(labels)); + + if (series === undefined) { + metric.series.set(buildSeriesKey(labels), { + labels, + value, + }); + return; + } + + series.value += value; + } + + private observeHistogram( + name: string, + help: string, + value: number, + labels: Record, + buckets = DEFAULT_DURATION_BUCKETS, + ): void { + const metric = this.getOrCreateHistogram(name, help, buckets); + const seriesKey = buildSeriesKey(labels); + const series = metric.series.get(seriesKey) ?? { + bucketCounts: buckets.map(() => 0), + count: 0, + labels, + sum: 0, + }; + + const bucketIndex = buckets.findIndex((bucket) => value <= bucket); + + if (bucketIndex >= 0) { + series.bucketCounts[bucketIndex] = (series.bucketCounts[bucketIndex] ?? 0) + 1; + } + + series.count += 1; + series.sum += value; + metric.series.set(seriesKey, series); + } + + private getOrCreateCounter(name: string, help: string): CounterMetric { + const existingMetric = this.metrics.get(name); + + if (existingMetric !== undefined) { + if (existingMetric.type !== "counter") { + throw new Error(`Metric ${name} already exists with a different type`); + } + + return existingMetric; + } + + const metric: CounterMetric = { + help, + name, + series: new Map(), + type: "counter", + }; + + this.metrics.set(name, metric); + + return metric; + } + + private getOrCreateHistogram( + name: string, + help: string, + buckets: number[], + ): HistogramMetric { + const existingMetric = this.metrics.get(name); + + if (existingMetric !== undefined) { + if (existingMetric.type !== "histogram") { + throw new Error(`Metric ${name} already exists with a different type`); + } + + return existingMetric; + } + + const metric: HistogramMetric = { + buckets, + help, + name, + series: new Map(), + type: "histogram", + }; + + this.metrics.set(name, metric); + + return metric; + } +} + +function buildSeriesKey(labels: Record): string { + return Object.entries(labels) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, value]) => `${key}:${value}`) + .join("|"); +} + +function formatLabels(labels: Record): string { + const entries = Object.entries(labels).sort(([leftKey], [rightKey]) => + leftKey.localeCompare(rightKey), + ); + + if (entries.length === 0) { + return ""; + } + + return `{${entries + .map(([key, value]) => `${key}="${escapeLabelValue(value)}"`) + .join(",")}}`; +} + +function escapeLabelValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"'); +} diff --git a/src/bootstrap/telemetry/application-telemetry.service.ts b/src/bootstrap/telemetry/application-telemetry.service.ts new file mode 100644 index 0000000..f9d7b9b --- /dev/null +++ b/src/bootstrap/telemetry/application-telemetry.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from "@nestjs/common"; +import { + SpanKind, + SpanStatusCode, + context, + trace, + type Attributes, + type Span, +} from "@opentelemetry/api"; + +@Injectable() +export class ApplicationTelemetryService { + private readonly tracer = trace.getTracer("nexus-platform"); + + public async runInSpan( + name: string, + attributes: Attributes, + operation: () => Promise | TResult, + ): Promise { + return await this.tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + const result = await operation(); + + span.setStatus({ code: SpanStatusCode.OK }); + + return result; + } catch (error) { + span.recordException(normalizeException(error)); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : "Unexpected error", + }); + + throw error; + } finally { + span.end(); + } + }); + } + + public startHttpServerSpan( + name: string, + attributes: Attributes, + ): Span { + return this.tracer.startSpan(name, { + attributes, + kind: SpanKind.SERVER, + }); + } + + public bindSpan(span: Span, operation: () => T): T { + return context.with(trace.setSpan(context.active(), span), operation); + } + + public endHttpServerSpan( + span: Span, + attributes: Attributes, + statusCode: number, + ): void { + span.setAttributes(attributes); + span.setStatus({ + code: statusCode >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.OK, + }); + span.end(); + } +} + +function normalizeException(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === "string" ? error : "Unexpected error"); +} diff --git a/src/bootstrap/telemetry/metrics.controller.ts b/src/bootstrap/telemetry/metrics.controller.ts new file mode 100644 index 0000000..3ee1bed --- /dev/null +++ b/src/bootstrap/telemetry/metrics.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Header } from "@nestjs/common"; + +import { ApplicationMetricsService } from "./application-metrics.service"; + +@Controller() +export class MetricsController { + public constructor(private readonly applicationMetricsService: ApplicationMetricsService) {} + + @Get("metrics") + @Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + public getMetrics(): string { + return this.applicationMetricsService.renderPrometheusMetrics(); + } +} diff --git a/src/bootstrap/telemetry/telemetry.module.ts b/src/bootstrap/telemetry/telemetry.module.ts new file mode 100644 index 0000000..a56105d --- /dev/null +++ b/src/bootstrap/telemetry/telemetry.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from "@nestjs/common"; + +import { ApplicationMetricsService } from "./application-metrics.service"; +import { ApplicationTelemetryService } from "./application-telemetry.service"; +import { MetricsController } from "./metrics.controller"; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [ApplicationTelemetryService, ApplicationMetricsService], + exports: [ApplicationTelemetryService, ApplicationMetricsService], +}) +export class TelemetryModule {} diff --git a/src/modules/access-control/application/use-cases/assign-role-to-user.use-case.ts b/src/modules/access-control/application/use-cases/assign-role-to-user.use-case.ts index a7f8ac2..b528522 100644 --- a/src/modules/access-control/application/use-cases/assign-role-to-user.use-case.ts +++ b/src/modules/access-control/application/use-cases/assign-role-to-user.use-case.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { USERS_IDENTITY_CONTRACT, @@ -47,70 +48,86 @@ export class AssignRoleToUserUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(AssignRoleToUserUseCase.name); } public async execute(input: AssignRoleToUserInput): Promise { - const user = await this.usersIdentityContract.getUserById(input.userId); + const executeOperation = async (): Promise => { + const user = await this.usersIdentityContract.getUserById(input.userId); - if (user === null) { - throw new UserNotFoundError(); - } + if (user === null) { + throw new UserNotFoundError(); + } - const membership = await this.usersTenancyContract.findActiveMembership( - input.userId, - input.organizationId, - ); + const membership = await this.usersTenancyContract.findActiveMembership( + input.userId, + input.organizationId, + ); - if (membership === null) { - throw new MembershipNotFoundError(); - } + if (membership === null) { + throw new MembershipNotFoundError(); + } - const role = await this.roleRepository.findById(input.roleId, input.organizationId); + const role = await this.roleRepository.findById(input.roleId, input.organizationId); - if (role === null) { - throw new RoleNotFoundError(); - } + if (role === null) { + throw new RoleNotFoundError(); + } - const assignment = UserRoleAssignment.create({ - id: randomUUID(), - now: new Date(), - organizationId: input.organizationId, - roleId: role.id, - userId: input.userId, - }); - - await this.databaseExecutor.withTransaction(async () => { - await this.userRoleAssignmentRepository.save(assignment); - await this.internalEventBus.publish({ - actorUserId: input.actorUserId, - occurredAt: assignment.createdAt, - organizationId: assignment.organizationId, + const assignment = UserRoleAssignment.create({ + id: randomUUID(), + now: new Date(), + organizationId: input.organizationId, roleId: role.id, - targetUserId: input.userId, - type: "access_control.role_assigned", + userId: input.userId, + }); + + await this.databaseExecutor.withTransaction(async () => { + await this.userRoleAssignmentRepository.save(assignment); + await this.internalEventBus.publish({ + actorUserId: input.actorUserId, + occurredAt: assignment.createdAt, + organizationId: assignment.organizationId, + roleId: role.id, + targetUserId: input.userId, + type: "access_control.role_assigned", + }); }); - }); - this.logger.info( + this.logger.info( + { + event: "role_assigned", + organizationId: input.organizationId, + roleId: role.id, + targetUserId: input.userId, + userId: input.actorUserId, + }, + "Role assigned to user", + ); + + return { + createdAt: assignment.createdAt.toISOString(), + organizationId: assignment.organizationId, + roleId: assignment.roleId, + updatedAt: assignment.updatedAt.toISOString(), + userId: assignment.userId, + userRoleAssignmentId: assignment.id, + }; + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } + + return this.applicationTelemetryService.runInSpan( + "access_control.assign_role_to_user", { - event: "role_assigned", - organizationId: input.organizationId, - roleId: role.id, - targetUserId: input.userId, - userId: input.actorUserId, + "tenant.id": input.organizationId, }, - "Role assigned to user", + executeOperation, ); - - return { - createdAt: assignment.createdAt.toISOString(), - organizationId: assignment.organizationId, - roleId: assignment.roleId, - updatedAt: assignment.updatedAt.toISOString(), - userId: assignment.userId, - userRoleAssignmentId: assignment.id, - }; } } diff --git a/src/modules/access-control/application/use-cases/authorize-action.use-case.ts b/src/modules/access-control/application/use-cases/authorize-action.use-case.ts index 8dcfaff..6036c39 100644 --- a/src/modules/access-control/application/use-cases/authorize-action.use-case.ts +++ b/src/modules/access-control/application/use-cases/authorize-action.use-case.ts @@ -1,6 +1,8 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; +import { ApplicationMetricsService } from "../../../../bootstrap/telemetry/application-metrics.service"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { USER_ROLE_ASSIGNMENT_REPOSITORY, type UserRoleAssignmentRepository, @@ -22,28 +24,49 @@ export class AuthorizeActionUseCase { @Inject(USER_ROLE_ASSIGNMENT_REPOSITORY) private readonly userRoleAssignmentRepository: UserRoleAssignmentRepository, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, + @Optional() + private readonly applicationMetricsService?: ApplicationMetricsService, ) { this.logger.setContext(AuthorizeActionUseCase.name); } public async execute(input: AuthorizeActionInput): Promise { - const permissionCodes = - await this.userRoleAssignmentRepository.listPermissionCodesByUserIdAndOrganizationId( - input.userId, - input.organizationId, + const executeOperation = async (): Promise => { + const permissionCodes = + await this.userRoleAssignmentRepository.listPermissionCodesByUserIdAndOrganizationId( + input.userId, + input.organizationId, + ); + const allowed = permissionCodes.includes(input.permissionCode); + + this.applicationMetricsService?.recordAuthorizationDecision(allowed ? "allow" : "deny"); + this.logger.info( + { + event: allowed ? "authorization_allowed" : "authorization_denied", + organizationId: input.organizationId, + permissionCode: input.permissionCode, + userId: input.userId, + }, + allowed ? "Authorization allowed" : "Authorization denied", ); - const allowed = permissionCodes.includes(input.permissionCode); - this.logger.info( + return { allowed }; + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } + + return this.applicationTelemetryService.runInSpan( + "access_control.authorize_action", { - event: allowed ? "authorization_allowed" : "authorization_denied", - organizationId: input.organizationId, - permissionCode: input.permissionCode, - userId: input.userId, + "authorization.permission_code": input.permissionCode, + "tenant.id": input.organizationId, + "user.id": input.userId, }, - allowed ? "Authorization allowed" : "Authorization denied", + executeOperation, ); - - return { allowed }; } } diff --git a/src/modules/access-control/application/use-cases/create-role.use-case.ts b/src/modules/access-control/application/use-cases/create-role.use-case.ts index 9490704..6297132 100644 --- a/src/modules/access-control/application/use-cases/create-role.use-case.ts +++ b/src/modules/access-control/application/use-cases/create-role.use-case.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { Role } from "../../domain/entities/role.entity"; import { @@ -26,47 +27,63 @@ export class CreateRoleUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(CreateRoleUseCase.name); } public async execute(input: CreateRoleInput): Promise { - const now = new Date(); - const role = Role.create({ - id: randomUUID(), - name: input.name, - now, - organizationId: input.organizationId, - }); + const executeOperation = async (): Promise => { + const now = new Date(); + const role = Role.create({ + id: randomUUID(), + name: input.name, + now, + organizationId: input.organizationId, + }); + + await this.databaseExecutor.withTransaction(async () => { + await this.roleRepository.save(role); + await this.internalEventBus.publish({ + actorUserId: input.actorUserId, + name: role.name, + occurredAt: role.createdAt, + organizationId: role.organizationId, + roleId: role.id, + type: "access_control.role_created", + }); + }); + + this.logger.info( + { + event: "role_created", + organizationId: input.organizationId, + roleId: role.id, + userId: input.actorUserId, + }, + "Role created", + ); - await this.databaseExecutor.withTransaction(async () => { - await this.roleRepository.save(role); - await this.internalEventBus.publish({ - actorUserId: input.actorUserId, + return { + createdAt: role.createdAt.toISOString(), name: role.name, - occurredAt: role.createdAt, organizationId: role.organizationId, roleId: role.id, - type: "access_control.role_created", - }); - }); + updatedAt: role.updatedAt.toISOString(), + }; + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } - this.logger.info( + return this.applicationTelemetryService.runInSpan( + "access_control.create_role", { - event: "role_created", - organizationId: input.organizationId, - roleId: role.id, - userId: input.actorUserId, + "tenant.id": input.organizationId, }, - "Role created", + executeOperation, ); - - return { - createdAt: role.createdAt.toISOString(), - name: role.name, - organizationId: role.organizationId, - roleId: role.id, - updatedAt: role.updatedAt.toISOString(), - }; } } diff --git a/src/modules/access-control/application/use-cases/grant-permission-to-role.use-case.ts b/src/modules/access-control/application/use-cases/grant-permission-to-role.use-case.ts index 164c1d9..39e7c73 100644 --- a/src/modules/access-control/application/use-cases/grant-permission-to-role.use-case.ts +++ b/src/modules/access-control/application/use-cases/grant-permission-to-role.use-case.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { PermissionNotFoundError, RoleNotFoundError } from "../../domain/access-control.errors"; import { RolePermission } from "../../domain/entities/role-permission.entity"; @@ -40,67 +41,83 @@ export class GrantPermissionToRoleUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(GrantPermissionToRoleUseCase.name); } public async execute(input: GrantPermissionToRoleInput): Promise { - const role = await this.roleRepository.findById(input.roleId, input.organizationId); + const executeOperation = async (): Promise => { + const role = await this.roleRepository.findById(input.roleId, input.organizationId); - if (role === null) { - throw new RoleNotFoundError(); - } + if (role === null) { + throw new RoleNotFoundError(); + } - const permission = await this.permissionRepository.findByCode( - input.organizationId, - input.permissionCode, - ); + const permission = await this.permissionRepository.findByCode( + input.organizationId, + input.permissionCode, + ); - if (permission === null) { - throw new PermissionNotFoundError(); - } + if (permission === null) { + throw new PermissionNotFoundError(); + } - const rolePermission = RolePermission.create({ - id: randomUUID(), - now: new Date(), - organizationId: input.organizationId, - permissionId: permission.id, - roleId: role.id, - }); - - await this.databaseExecutor.withTransaction(async () => { - await this.rolePermissionRepository.save(rolePermission); - await this.internalEventBus.publish({ - actorUserId: input.actorUserId, - occurredAt: rolePermission.createdAt, + const rolePermission = RolePermission.create({ + id: randomUUID(), + now: new Date(), organizationId: input.organizationId, - permissionCode: permission.code, permissionId: permission.id, roleId: role.id, - type: "access_control.permission_granted", }); - }); - this.logger.info( - { - event: "permission_granted", - organizationId: input.organizationId, + await this.databaseExecutor.withTransaction(async () => { + await this.rolePermissionRepository.save(rolePermission); + await this.internalEventBus.publish({ + actorUserId: input.actorUserId, + occurredAt: rolePermission.createdAt, + organizationId: input.organizationId, + permissionCode: permission.code, + permissionId: permission.id, + roleId: role.id, + type: "access_control.permission_granted", + }); + }); + + this.logger.info( + { + event: "permission_granted", + organizationId: input.organizationId, + permissionCode: permission.code, + permissionId: permission.id, + roleId: role.id, + userId: input.actorUserId, + }, + "Permission granted to role", + ); + + return { + createdAt: rolePermission.createdAt.toISOString(), + organizationId: rolePermission.organizationId, permissionCode: permission.code, permissionId: permission.id, roleId: role.id, - userId: input.actorUserId, + rolePermissionId: rolePermission.id, + updatedAt: rolePermission.updatedAt.toISOString(), + }; + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } + + return this.applicationTelemetryService.runInSpan( + "access_control.grant_permission_to_role", + { + "tenant.id": input.organizationId, }, - "Permission granted to role", + executeOperation, ); - - return { - createdAt: rolePermission.createdAt.toISOString(), - organizationId: rolePermission.organizationId, - permissionCode: permission.code, - permissionId: permission.id, - roleId: role.id, - rolePermissionId: rolePermission.id, - updatedAt: rolePermission.updatedAt.toISOString(), - }; } } diff --git a/src/modules/audit-logs/application/use-cases/append-audit-log.use-case.ts b/src/modules/audit-logs/application/use-cases/append-audit-log.use-case.ts index 4e82dd8..ba5aca0 100644 --- a/src/modules/audit-logs/application/use-cases/append-audit-log.use-case.ts +++ b/src/modules/audit-logs/application/use-cases/append-audit-log.use-case.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; +import { ApplicationMetricsService } from "../../../../bootstrap/telemetry/application-metrics.service"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { RequestCorrelationContext } from "../../../../shared/request-correlation/request-correlation.context"; import { AUDIT_LOG_REPOSITORY, @@ -31,36 +33,61 @@ export class AppendAuditLogUseCase { private readonly auditLogRepository: AuditLogRepository, private readonly requestCorrelationContext: RequestCorrelationContext, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, + @Optional() + private readonly applicationMetricsService?: ApplicationMetricsService, ) { this.logger.setContext(AppendAuditLogUseCase.name); } public async execute(input: AppendAuditLogInput): Promise { - const entry = AuditLogEntry.create({ - action: input.action, - correlationId: this.requestCorrelationContext.getCorrelationIdOrDefault(), - id: randomUUID(), - metadata: input.metadata, - resource: input.resource, - tenantId: input.tenantId, - timestamp: input.timestamp ?? new Date(), - userId: input.userId, - }); + const executeOperation = async (): Promise => { + const entry = AuditLogEntry.create({ + action: input.action, + correlationId: this.requestCorrelationContext.getCorrelationIdOrDefault(), + id: randomUUID(), + metadata: input.metadata, + resource: input.resource, + tenantId: input.tenantId, + timestamp: input.timestamp ?? new Date(), + userId: input.userId, + }); + const startedAt = performance.now(); - await this.auditLogRepository.append(entry); - this.logger.info( + await this.auditLogRepository.append(entry); + this.applicationMetricsService?.recordAuditOperation({ + durationMs: performance.now() - startedAt, + operation: "append", + }); + this.logger.info( + { + action: entry.action, + correlationId: entry.correlationId, + event: "audit_log_appended", + resource: entry.resource, + tenantId: entry.tenantId, + userId: entry.userId, + }, + "Audit log appended", + ); + + return mapAuditLogView(entry); + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } + + return this.applicationTelemetryService.runInSpan( + "audit_logs.append", { - action: entry.action, - correlationId: entry.correlationId, - event: "audit_log_appended", - resource: entry.resource, - tenantId: entry.tenantId, - userId: entry.userId, + "audit.action": input.action, + "audit.resource": input.resource, + "tenant.id": input.tenantId ?? "bootstrap", }, - "Audit log appended", + executeOperation, ); - - return mapAuditLogView(entry); } } diff --git a/src/modules/audit-logs/application/use-cases/list-audit-logs.use-case.ts b/src/modules/audit-logs/application/use-cases/list-audit-logs.use-case.ts index 59b9ec2..5431407 100644 --- a/src/modules/audit-logs/application/use-cases/list-audit-logs.use-case.ts +++ b/src/modules/audit-logs/application/use-cases/list-audit-logs.use-case.ts @@ -1,6 +1,8 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; +import { ApplicationMetricsService } from "../../../../bootstrap/telemetry/application-metrics.service"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InvalidAuditLogTimeRangeError } from "../../domain/audit-log.errors"; import type { AuditLogAction } from "../../domain/entities/audit-log-entry.entity"; import { @@ -13,6 +15,8 @@ import { mapAuditLogView } from "./append-audit-log.use-case"; export interface ListAuditLogsInput { readonly action?: AuditLogAction; readonly from?: Date; + readonly limit: number; + readonly offset: number; readonly tenantId: string; readonly to?: Date; readonly userId?: string; @@ -24,30 +28,58 @@ export class ListAuditLogsUseCase { @Inject(AUDIT_LOG_REPOSITORY) private readonly auditLogRepository: AuditLogRepository, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, + @Optional() + private readonly applicationMetricsService?: ApplicationMetricsService, ) { this.logger.setContext(ListAuditLogsUseCase.name); } public async execute(input: ListAuditLogsInput): Promise { - if (input.from !== undefined && input.to !== undefined && input.from > input.to) { - throw new InvalidAuditLogTimeRangeError(); - } + const executeOperation = async (): Promise => { + if (input.from !== undefined && input.to !== undefined && input.from > input.to) { + throw new InvalidAuditLogTimeRangeError(); + } + + const startedAt = performance.now(); + const entries = await this.auditLogRepository.list(input); + + this.applicationMetricsService?.recordAuditOperation({ + durationMs: performance.now() - startedAt, + operation: "query", + }); + this.logger.info( + { + action: input.action, + event: "audit_log_query", + from: input.from?.toISOString(), + limit: input.limit, + offset: input.offset, + resultCount: entries.length, + tenantId: input.tenantId, + to: input.to?.toISOString(), + userId: input.userId, + }, + "Audit log query completed", + ); - const entries = await this.auditLogRepository.list(input); + return entries.map((entry) => mapAuditLogView(entry)); + }; - this.logger.info( + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } + + return this.applicationTelemetryService.runInSpan( + "audit_logs.list", { - action: input.action, - event: "audit_log_query", - from: input.from?.toISOString(), - resultCount: entries.length, - tenantId: input.tenantId, - to: input.to?.toISOString(), - userId: input.userId, + "audit.action": input.action ?? "all", + "audit.limit": input.limit, + "audit.offset": input.offset, + "tenant.id": input.tenantId, }, - "Audit log query completed", + executeOperation, ); - - return entries.map((entry) => mapAuditLogView(entry)); } } diff --git a/src/modules/audit-logs/domain/repositories/audit-log.repository.ts b/src/modules/audit-logs/domain/repositories/audit-log.repository.ts index 5db028c..acdc761 100644 --- a/src/modules/audit-logs/domain/repositories/audit-log.repository.ts +++ b/src/modules/audit-logs/domain/repositories/audit-log.repository.ts @@ -6,6 +6,8 @@ export const AUDIT_LOG_REPOSITORY = Symbol("AUDIT_LOG_REPOSITORY"); export interface ListAuditLogsFilters { readonly action?: AuditLogAction; readonly from?: Date; + readonly limit: number; + readonly offset: number; readonly tenantId: string; readonly to?: Date; readonly userId?: string; diff --git a/src/modules/audit-logs/infrastructure/http/audit-logs.controller.ts b/src/modules/audit-logs/infrastructure/http/audit-logs.controller.ts index 26c2754..452d68e 100644 --- a/src/modules/audit-logs/infrastructure/http/audit-logs.controller.ts +++ b/src/modules/audit-logs/infrastructure/http/audit-logs.controller.ts @@ -29,6 +29,8 @@ export class AuditLogsController { const input = { ...(query.action === undefined ? {} : { action: query.action }), ...(query.from === undefined ? {} : { from: new Date(query.from) }), + limit: query.limit ?? 50, + offset: query.offset ?? 0, tenantId: query.tenantId, ...(query.to === undefined ? {} : { to: new Date(query.to) }), ...(query.userId === undefined ? {} : { userId: query.userId }), diff --git a/src/modules/audit-logs/infrastructure/http/list-audit-logs.request.ts b/src/modules/audit-logs/infrastructure/http/list-audit-logs.request.ts index 47bd022..23f6531 100644 --- a/src/modules/audit-logs/infrastructure/http/list-audit-logs.request.ts +++ b/src/modules/audit-logs/infrastructure/http/list-audit-logs.request.ts @@ -1,4 +1,5 @@ -import { IsISO8601, IsIn, IsOptional, IsUUID } from "class-validator"; +import { Type } from "class-transformer"; +import { IsISO8601, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from "class-validator"; import { AUDIT_LOG_ACTIONS } from "../../domain/entities/audit-log-entry.entity"; @@ -21,4 +22,18 @@ export class ListAuditLogsRequestDto { @IsOptional() @IsISO8601() public readonly to?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + public readonly limit?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(1000) + public readonly offset?: number; } diff --git a/src/modules/audit-logs/infrastructure/persistence/pg-audit-log.repository.ts b/src/modules/audit-logs/infrastructure/persistence/pg-audit-log.repository.ts index 0cf3ca6..d32fa7c 100644 --- a/src/modules/audit-logs/infrastructure/persistence/pg-audit-log.repository.ts +++ b/src/modules/audit-logs/infrastructure/persistence/pg-audit-log.repository.ts @@ -71,6 +71,11 @@ export class PgAuditLogRepository implements AuditLogRepository { predicates.push(`timestamp <= $${values.length}`); } + values.push(filters.limit); + const limitPlaceholder = `$${values.length}`; + values.push(filters.offset); + const offsetPlaceholder = `$${values.length}`; + const result = await this.databaseExecutor.query( ` SELECT @@ -85,6 +90,8 @@ export class PgAuditLogRepository implements AuditLogRepository { FROM audit_logs WHERE ${predicates.join("\n AND ")} ORDER BY timestamp DESC, id DESC + LIMIT ${limitPlaceholder} + OFFSET ${offsetPlaceholder} `, values, ); diff --git a/src/modules/identity/application/use-cases/create-user-account.use-case.ts b/src/modules/identity/application/use-cases/create-user-account.use-case.ts index 7d5d36a..6bcd9b6 100644 --- a/src/modules/identity/application/use-cases/create-user-account.use-case.ts +++ b/src/modules/identity/application/use-cases/create-user-account.use-case.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { USERS_IDENTITY_CONTRACT, @@ -46,71 +47,87 @@ export class CreateUserAccountUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(CreateUserAccountUseCase.name); } public async execute(input: CreateUserAccountInput): Promise { - const email = EmailAddress.create(input.email); - - this.credentialPolicy.ensurePasswordIsAllowed(input.password); - - const existingAccount = await this.accountRepository.findByEmail(email); - - if (existingAccount !== null) { - throw new DuplicateAccountEmailError(); - } - - const userId = randomUUID(); - const accountId = randomUUID(); - const now = new Date(); - - await this.databaseExecutor.withTransaction(async () => { - await this.usersIdentityContract.createUser({ - fullName: input.fullName, - userId, - }); - - const account = Account.create({ - email, - id: accountId, - now, - userId, + const executeOperation = async (): Promise => { + const email = EmailAddress.create(input.email); + + this.credentialPolicy.ensurePasswordIsAllowed(input.password); + + const existingAccount = await this.accountRepository.findByEmail(email); + + if (existingAccount !== null) { + throw new DuplicateAccountEmailError(); + } + + const userId = randomUUID(); + const accountId = randomUUID(); + const now = new Date(); + + await this.databaseExecutor.withTransaction(async () => { + await this.usersIdentityContract.createUser({ + fullName: input.fullName, + userId, + }); + + const account = Account.create({ + email, + id: accountId, + now, + userId, + }); + + const passwordHash = await this.passwordHasher.hash(input.password); + const credential = Credential.create({ + accountId, + now, + passwordHash, + }); + + await this.accountRepository.save(account); + await this.credentialRepository.save(credential); + await this.internalEventBus.publish({ + accountId, + actorUserId: userId, + email: email.normalized, + occurredAt: now, + type: "user.created", + userId, + }); }); - const passwordHash = await this.passwordHasher.hash(input.password); - const credential = Credential.create({ - accountId, - now, - passwordHash, - }); + this.logger.info( + { + accountId, + event: "account_created", + userId, + }, + "Identity account created", + ); - await this.accountRepository.save(account); - await this.credentialRepository.save(credential); - await this.internalEventBus.publish({ + return { accountId, - actorUserId: userId, email: email.normalized, - occurredAt: now, - type: "user.created", + status: "active", userId, - }); - }); + }; + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } - this.logger.info( + return this.applicationTelemetryService.runInSpan( + "identity.create_user_account", { - accountId, - event: "account_created", - userId, + "identity.email": input.email, }, - "Identity account created", + executeOperation, ); - - return { - accountId, - email: email.normalized, - status: "active", - userId, - }; } } diff --git a/src/modules/identity/application/use-cases/invalidate-session.use-case.ts b/src/modules/identity/application/use-cases/invalidate-session.use-case.ts index 2c89b83..be8119b 100644 --- a/src/modules/identity/application/use-cases/invalidate-session.use-case.ts +++ b/src/modules/identity/application/use-cases/invalidate-session.use-case.ts @@ -1,7 +1,8 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { InvalidAccessTokenError } from "../../domain/identity.errors"; import { SESSION_REPOSITORY, type SessionRepository } from "../../domain/repositories/session.repository"; @@ -15,46 +16,61 @@ export class InvalidateSessionUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(InvalidateSessionUseCase.name); } public async execute(accessToken: string): Promise { - const payload = await this.accessTokenService.verify(accessToken); - const session = await this.sessionRepository.findById(payload.sid); - - if ( - session === null || - session.status !== "active" || - session.jti !== payload.jti || - session.organizationId !== payload.oid || - session.isExpired(new Date()) - ) { - throw new InvalidAccessTokenError(); - } + const executeOperation = async (): Promise => { + const payload = await this.accessTokenService.verify(accessToken); + const session = await this.sessionRepository.findById(payload.sid); + + if ( + session === null || + session.status !== "active" || + session.jti !== payload.jti || + session.organizationId !== payload.oid || + session.isExpired(new Date()) + ) { + throw new InvalidAccessTokenError(); + } - const revokedSession = session.revoke(new Date()); - - await this.databaseExecutor.withTransaction(async () => { - await this.sessionRepository.update(revokedSession); - await this.internalEventBus.publish({ - accountId: revokedSession.accountId, - occurredAt: revokedSession.updatedAt, - organizationId: revokedSession.organizationId, - sessionId: revokedSession.id, - type: "identity.logout_succeeded", - userId: revokedSession.userId, + const revokedSession = session.revoke(new Date()); + + await this.databaseExecutor.withTransaction(async () => { + await this.sessionRepository.update(revokedSession); + await this.internalEventBus.publish({ + accountId: revokedSession.accountId, + occurredAt: revokedSession.updatedAt, + organizationId: revokedSession.organizationId, + sessionId: revokedSession.id, + type: "identity.logout_succeeded", + userId: revokedSession.userId, + }); }); - }); - - this.logger.info( - { - accountId: revokedSession.accountId, - event: "session_invalidated", - sessionId: revokedSession.id, - userId: revokedSession.userId, - }, - "Identity session invalidated", + + this.logger.info( + { + accountId: revokedSession.accountId, + event: "session_invalidated", + sessionId: revokedSession.id, + userId: revokedSession.userId, + }, + "Identity session invalidated", + ); + }; + + if (this.applicationTelemetryService === undefined) { + await executeOperation(); + return; + } + + await this.applicationTelemetryService.runInSpan( + "identity.invalidate_session", + {}, + executeOperation, ); } } diff --git a/src/modules/identity/application/use-cases/login-with-password.use-case.ts b/src/modules/identity/application/use-cases/login-with-password.use-case.ts index 69933e6..8425d11 100644 --- a/src/modules/identity/application/use-cases/login-with-password.use-case.ts +++ b/src/modules/identity/application/use-cases/login-with-password.use-case.ts @@ -1,12 +1,14 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { ApplicationConfigService } from "../../../../bootstrap/config/application-config.service"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; -import type { NexusError } from "../../../../shared/domain/nexus.errors"; +import { ApplicationMetricsService } from "../../../../bootstrap/telemetry/application-metrics.service"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; +import { readErrorCode } from "../../../../shared/domain/read-error-code"; import { TenantContextRequiredError } from "../../../../shared/tenancy/tenant.errors"; import { ORGANIZATIONS_TENANCY_CONTRACT, @@ -69,112 +71,136 @@ export class LoginWithPasswordUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, + @Optional() + private readonly applicationMetricsService?: ApplicationMetricsService, ) { this.logger.setContext(LoginWithPasswordUseCase.name); } public async execute(input: LoginWithPasswordInput): Promise { - const email = EmailAddress.create(input.email); - let failedUserId: string | null = null; - - try { - const account = await this.accountRepository.findByEmail(email); - - if (account === null || account.status !== "active") { - throw new InvalidCredentialsError(); - } - - const user = await this.usersIdentityContract.getUserById(account.userId); - const credential = await this.credentialRepository.findByAccountId(account.id); - - if (user === null || user.status !== "active" || credential === null) { - throw new InvalidCredentialsError(); - } - - failedUserId = user.userId; - - const passwordMatches = await this.passwordHasher.verify( - credential.passwordHash, - input.password, - ); - - if (!passwordMatches) { - throw new InvalidCredentialsError(); - } - - const organizationId = await this.resolveOrganizationContext(user.userId, input.organizationId); - const now = new Date(); - const expiresAt = new Date( - now.getTime() + 1000 * 60 * this.configuration.auth.jwtExpiresInMinutes, - ); - const session = Session.start({ - accountId: account.id, - expiresAt, - id: randomUUID(), - jti: randomUUID(), - now, - organizationId, - userId: user.userId, - }); - - await this.databaseExecutor.withTransaction(async () => { - await this.sessionRepository.save(session); - await this.internalEventBus.publish({ + const executeOperation = async (): Promise => { + const email = EmailAddress.create(input.email); + let failedUserId: string | null = null; + + try { + const account = await this.accountRepository.findByEmail(email); + + if (account === null || account.status !== "active") { + throw new InvalidCredentialsError(); + } + + const user = await this.usersIdentityContract.getUserById(account.userId); + const credential = await this.credentialRepository.findByAccountId(account.id); + + if (user === null || user.status !== "active" || credential === null) { + throw new InvalidCredentialsError(); + } + + failedUserId = user.userId; + + const passwordMatches = await this.passwordHasher.verify( + credential.passwordHash, + input.password, + ); + + if (!passwordMatches) { + throw new InvalidCredentialsError(); + } + + const organizationId = await this.resolveOrganizationContext( + user.userId, + input.organizationId, + ); + const now = new Date(); + const expiresAt = new Date( + now.getTime() + 1000 * 60 * this.configuration.auth.jwtExpiresInMinutes, + ); + const session = Session.start({ accountId: account.id, - occurredAt: now, + expiresAt, + id: randomUUID(), + jti: randomUUID(), + now, organizationId, - sessionId: session.id, - type: "identity.login_succeeded", userId: user.userId, }); - }); - const accessToken = await this.accessTokenService.issue( - { - aid: account.id, - jti: session.jti, - oid: organizationId, - sid: session.id, - sub: user.userId, - }, - expiresAt, - ); + await this.databaseExecutor.withTransaction(async () => { + await this.sessionRepository.save(session); + await this.internalEventBus.publish({ + accountId: account.id, + occurredAt: now, + organizationId, + sessionId: session.id, + type: "identity.login_succeeded", + userId: user.userId, + }); + }); - this.logger.info( - { - accountId: account.id, - event: "login_succeeded", + const accessToken = await this.accessTokenService.issue( + { + aid: account.id, + jti: session.jti, + oid: organizationId, + sid: session.id, + sub: user.userId, + }, + expiresAt, + ); + + this.applicationMetricsService?.recordLoginResult("success"); + this.logger.info( + { + accountId: account.id, + event: "login_succeeded", + sessionId: session.id, + userId: user.userId, + }, + "Identity login succeeded", + ); + + return { + accessToken, + principal: { + accountId: account.id, + accountStatus: account.status, + email: account.email.normalized, + organizationId, + userId: user.userId, + userStatus: user.status, + }, sessionId: session.id, - userId: user.userId, - }, - "Identity login succeeded", - ); - - return { - accessToken, - principal: { - accountId: account.id, - accountStatus: account.status, - email: account.email.normalized, - organizationId, - userId: user.userId, - userStatus: user.status, - }, - sessionId: session.id, - tokenType: "Bearer", - }; - } catch (error) { - if (this.isAuditableLoginFailure(error)) { - await this.publishLoginFailureAudit({ - email: email.normalized, - organizationId: input.organizationId ?? null, - reason: this.readErrorCode(error), - userId: failedUserId, - }); + tokenType: "Bearer", + }; + } catch (error) { + this.applicationMetricsService?.recordLoginResult("failure"); + + if (this.isAuditableLoginFailure(error)) { + await this.publishLoginFailureAudit({ + email: email.normalized, + organizationId: input.organizationId ?? null, + reason: readErrorCode(error), + userId: failedUserId, + }); + } + + throw error; } + }; - throw error; + if (this.applicationTelemetryService === undefined) { + return executeOperation(); } + + return this.applicationTelemetryService.runInSpan( + "identity.login_with_password", + { + "identity.has_organization": input.organizationId === undefined ? "false" : "true", + }, + executeOperation, + ); } private async resolveOrganizationContext( @@ -264,16 +290,4 @@ export class LoginWithPasswordUseCase { } } - private readErrorCode(error: unknown): string { - if ( - typeof error === "object" && - error !== null && - "code" in error && - typeof (error as NexusError).code === "string" - ) { - return (error as NexusError).code; - } - - return "unknown_error"; - } } diff --git a/src/modules/organizations/application/use-cases/create-organization.use-case.ts b/src/modules/organizations/application/use-cases/create-organization.use-case.ts index 26f04ed..f303e30 100644 --- a/src/modules/organizations/application/use-cases/create-organization.use-case.ts +++ b/src/modules/organizations/application/use-cases/create-organization.use-case.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { ACCESS_CONTROL_BOOTSTRAP_CONTRACT, @@ -44,53 +45,67 @@ export class CreateOrganizationUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(CreateOrganizationUseCase.name); } public async execute(input: CreateOrganizationInput): Promise { - const now = new Date(); - const organization = Organization.create({ - id: randomUUID(), - name: input.name, - now, - }); - - await this.databaseExecutor.withTransaction(async () => { - await this.organizationRepository.save(organization); - await this.usersTenancyContract.createMembership({ - actorUserId: input.createdByUserId, - organizationId: organization.id, - userId: input.createdByUserId, + const executeOperation = async (): Promise => { + const now = new Date(); + const organization = Organization.create({ + id: randomUUID(), + name: input.name, + now, }); - await this.accessControlBootstrapContract.bootstrapTenantAccessControl({ - createdByUserId: input.createdByUserId, - organizationId: organization.id, + + await this.databaseExecutor.withTransaction(async () => { + await this.organizationRepository.save(organization); + await this.usersTenancyContract.createMembership({ + actorUserId: input.createdByUserId, + organizationId: organization.id, + userId: input.createdByUserId, + }); + await this.accessControlBootstrapContract.bootstrapTenantAccessControl({ + createdByUserId: input.createdByUserId, + organizationId: organization.id, + }); + await this.internalEventBus.publish({ + actorUserId: input.createdByUserId, + name: organization.name, + occurredAt: organization.createdAt, + organizationId: organization.id, + type: "organization.created", + }); }); - await this.internalEventBus.publish({ - actorUserId: input.createdByUserId, + + this.logger.info( + { + event: "organization_created", + organizationId: organization.id, + userId: input.createdByUserId, + }, + "Organization created", + ); + + return { + createdAt: organization.createdAt.toISOString(), name: organization.name, - occurredAt: organization.createdAt, organizationId: organization.id, - type: "organization.created", - }); - }); + status: organization.status, + updatedAt: organization.updatedAt.toISOString(), + }; + }; - this.logger.info( - { - event: "organization_created", - organizationId: organization.id, - userId: input.createdByUserId, - }, - "Organization created", - ); + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } - return { - createdAt: organization.createdAt.toISOString(), - name: organization.name, - organizationId: organization.id, - status: organization.status, - updatedAt: organization.updatedAt.toISOString(), - }; + return this.applicationTelemetryService.runInSpan( + "organizations.create_organization", + {}, + executeOperation, + ); } } diff --git a/src/modules/organizations/application/use-cases/list-organization-memberships.use-case.ts b/src/modules/organizations/application/use-cases/list-organization-memberships.use-case.ts index 4c6ab51..305e302 100644 --- a/src/modules/organizations/application/use-cases/list-organization-memberships.use-case.ts +++ b/src/modules/organizations/application/use-cases/list-organization-memberships.use-case.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from "@nestjs/common"; import { + type ListMembershipsByOrganizationInput, USERS_TENANCY_CONTRACT, type MembershipSnapshot, type UsersTenancyContract, @@ -23,8 +24,10 @@ export class ListOrganizationMembershipsUseCase { private readonly usersTenancyContract: UsersTenancyContract, ) {} - public async execute(organizationId: string): Promise { - const organization = await this.organizationRepository.findById(organizationId); + public async execute( + input: ListMembershipsByOrganizationInput, + ): Promise { + const organization = await this.organizationRepository.findById(input.organizationId); if (organization === null) { throw new OrganizationNotFoundError(); @@ -34,6 +37,6 @@ export class ListOrganizationMembershipsUseCase { throw new OrganizationInactiveError(); } - return this.usersTenancyContract.listMembershipsByOrganization(organizationId); + return this.usersTenancyContract.listMembershipsByOrganization(input); } } diff --git a/src/modules/organizations/infrastructure/http/list-organization-memberships.request.ts b/src/modules/organizations/infrastructure/http/list-organization-memberships.request.ts new file mode 100644 index 0000000..e5c85c0 --- /dev/null +++ b/src/modules/organizations/infrastructure/http/list-organization-memberships.request.ts @@ -0,0 +1,18 @@ +import { Type } from "class-transformer"; +import { IsInt, IsOptional, Max, Min } from "class-validator"; + +export class ListOrganizationMembershipsRequestDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + public readonly limit?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(1000) + public readonly offset?: number; +} diff --git a/src/modules/organizations/infrastructure/http/organizations.controller.ts b/src/modules/organizations/infrastructure/http/organizations.controller.ts index 5966f03..2ed843a 100644 --- a/src/modules/organizations/infrastructure/http/organizations.controller.ts +++ b/src/modules/organizations/infrastructure/http/organizations.controller.ts @@ -1,12 +1,4 @@ -import { - Body, - Controller, - Get, - Param, - Patch, - Post, - UseGuards, -} from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; import { AuthenticatedPrincipal } from "../../../../shared/auth/authenticated-principal.decorator"; import { AuthenticatedRequestGuard } from "../../../../shared/auth/authenticated-request.guard"; @@ -21,6 +13,7 @@ import { GetOrganizationByIdUseCase } from "../../application/use-cases/get-orga import { ListOrganizationMembershipsUseCase } from "../../application/use-cases/list-organization-memberships.use-case"; import { CreateOrganizationMembershipRequestDto } from "./create-organization-membership.request"; import { CreateOrganizationRequestDto } from "./create-organization.request"; +import { ListOrganizationMembershipsRequestDto } from "./list-organization-memberships.request"; import { OrganizationIdParamsDto } from "./organization-id.params"; @Controller("organizations") @@ -83,7 +76,14 @@ export class OrganizationsController { @Get(":id/memberships") @UseGuards(AuthenticatedRequestGuard, TenantContextGuard, AuthorizationGuard) @RequirePermission("membership:view") - public listMemberships(@Param() params: OrganizationIdParamsDto) { - return this.listOrganizationMembershipsUseCase.execute(params.id); + public listMemberships( + @Param() params: OrganizationIdParamsDto, + @Query() query: ListOrganizationMembershipsRequestDto, + ) { + return this.listOrganizationMembershipsUseCase.execute({ + limit: query.limit ?? 50, + offset: query.offset ?? 0, + organizationId: params.id, + }); } } diff --git a/src/modules/users/application/contracts/users-tenancy.contract.ts b/src/modules/users/application/contracts/users-tenancy.contract.ts index 480e158..83ad4a0 100644 --- a/src/modules/users/application/contracts/users-tenancy.contract.ts +++ b/src/modules/users/application/contracts/users-tenancy.contract.ts @@ -15,6 +15,12 @@ export interface MembershipSnapshot { readonly userId: string; } +export interface ListMembershipsByOrganizationInput { + readonly limit: number; + readonly offset: number; + readonly organizationId: string; +} + export interface UsersTenancyContract { countActiveMemberships(userId: string): Promise; createMembership(input: CreateMembershipInput): Promise; @@ -22,5 +28,7 @@ export interface UsersTenancyContract { userId: string, organizationId: string, ): Promise; - listMembershipsByOrganization(organizationId: string): Promise; + listMembershipsByOrganization( + input: ListMembershipsByOrganizationInput, + ): Promise; } diff --git a/src/modules/users/application/use-cases/create-membership.use-case.ts b/src/modules/users/application/use-cases/create-membership.use-case.ts index cb01621..0e7838a 100644 --- a/src/modules/users/application/use-cases/create-membership.use-case.ts +++ b/src/modules/users/application/use-cases/create-membership.use-case.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PinoLogger } from "nestjs-pino"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { ApplicationTelemetryService } from "../../../../bootstrap/telemetry/application-telemetry.service"; import { InternalEventBus } from "../../../../shared/events/internal-event-bus"; import { Membership } from "../../domain/entities/membership.entity"; import { @@ -29,60 +30,77 @@ export class CreateMembershipUseCase { private readonly databaseExecutor: DatabaseExecutor, private readonly internalEventBus: InternalEventBus, private readonly logger: PinoLogger, + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, ) { this.logger.setContext(CreateMembershipUseCase.name); } public async execute(input: CreateMembershipInput): Promise { - const user = await this.userRepository.findById(input.userId); + const executeOperation = async (): Promise => { + const user = await this.userRepository.findById(input.userId); - if (user === null) { - throw new UserNotFoundError(); - } + if (user === null) { + throw new UserNotFoundError(); + } - const existingMembership = await this.membershipRepository.findActiveByUserIdAndOrganizationId( - input.userId, - input.organizationId, - ); + const existingMembership = + await this.membershipRepository.findActiveByUserIdAndOrganizationId( + input.userId, + input.organizationId, + ); - if (existingMembership !== null) { - throw new MembershipAlreadyExistsError(); - } + if (existingMembership !== null) { + throw new MembershipAlreadyExistsError(); + } - const membership = Membership.create({ - id: randomUUID(), - now: new Date(), - organizationId: input.organizationId, - userId: input.userId, - }); + const membership = Membership.create({ + id: randomUUID(), + now: new Date(), + organizationId: input.organizationId, + userId: input.userId, + }); - await this.databaseExecutor.withTransaction(async () => { - await this.membershipRepository.save(membership); - await this.internalEventBus.publish({ - actorUserId: input.actorUserId, - membershipId: membership.id, - occurredAt: membership.createdAt, - organizationId: membership.organizationId, - type: "membership.assigned", - userId: membership.userId, + await this.databaseExecutor.withTransaction(async () => { + await this.membershipRepository.save(membership); + await this.internalEventBus.publish({ + actorUserId: input.actorUserId, + membershipId: membership.id, + occurredAt: membership.createdAt, + organizationId: membership.organizationId, + type: "membership.assigned", + userId: membership.userId, + }); }); - }); - this.logger.info( - { - event: "membership_created", + this.logger.info( + { + event: "membership_created", + membershipId: membership.id, + organizationId: membership.organizationId, + userId: membership.userId, + }, + "Membership created", + ); + + return { membershipId: membership.id, organizationId: membership.organizationId, + status: membership.status, userId: membership.userId, + }; + }; + + if (this.applicationTelemetryService === undefined) { + return executeOperation(); + } + + return this.applicationTelemetryService.runInSpan( + "users.create_membership", + { + "tenant.id": input.organizationId, }, - "Membership created", + executeOperation, ); - - return { - membershipId: membership.id, - organizationId: membership.organizationId, - status: membership.status, - userId: membership.userId, - }; } } diff --git a/src/modules/users/application/use-cases/list-memberships-by-organization.use-case.ts b/src/modules/users/application/use-cases/list-memberships-by-organization.use-case.ts index 3472f64..bfe4d5e 100644 --- a/src/modules/users/application/use-cases/list-memberships-by-organization.use-case.ts +++ b/src/modules/users/application/use-cases/list-memberships-by-organization.use-case.ts @@ -4,7 +4,10 @@ import { MEMBERSHIP_REPOSITORY, type MembershipRepository, } from "../../domain/repositories/membership.repository"; -import type { MembershipSnapshot } from "../contracts/users-tenancy.contract"; +import type { + ListMembershipsByOrganizationInput, + MembershipSnapshot, +} from "../contracts/users-tenancy.contract"; @Injectable() export class ListMembershipsByOrganizationUseCase { @@ -13,8 +16,10 @@ export class ListMembershipsByOrganizationUseCase { private readonly membershipRepository: MembershipRepository, ) {} - public async execute(organizationId: string): Promise { - const memberships = await this.membershipRepository.findByOrganizationId(organizationId); + public async execute( + input: ListMembershipsByOrganizationInput, + ): Promise { + const memberships = await this.membershipRepository.findByOrganizationId(input); return memberships.map((membership) => ({ membershipId: membership.id, diff --git a/src/modules/users/domain/repositories/membership.repository.ts b/src/modules/users/domain/repositories/membership.repository.ts index ee41fcc..e1bdbf2 100644 --- a/src/modules/users/domain/repositories/membership.repository.ts +++ b/src/modules/users/domain/repositories/membership.repository.ts @@ -2,12 +2,20 @@ import type { Membership } from "../entities/membership.entity"; export const MEMBERSHIP_REPOSITORY = Symbol("MEMBERSHIP_REPOSITORY"); +export interface ListMembershipsByOrganizationFilters { + readonly limit: number; + readonly offset: number; + readonly organizationId: string; +} + export interface MembershipRepository { countActiveByUserId(userId: string): Promise; findActiveByUserIdAndOrganizationId( userId: string, organizationId: string, ): Promise; - findByOrganizationId(organizationId: string): Promise; + findByOrganizationId( + filters: ListMembershipsByOrganizationFilters, + ): Promise; save(membership: Membership): Promise; } diff --git a/src/modules/users/infrastructure/persistence/pg-membership.repository.ts b/src/modules/users/infrastructure/persistence/pg-membership.repository.ts index 36257da..9b51dd3 100644 --- a/src/modules/users/infrastructure/persistence/pg-membership.repository.ts +++ b/src/modules/users/infrastructure/persistence/pg-membership.repository.ts @@ -2,7 +2,10 @@ import { Injectable } from "@nestjs/common"; import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; import { Membership } from "../../domain/entities/membership.entity"; -import type { MembershipRepository } from "../../domain/repositories/membership.repository"; +import type { + ListMembershipsByOrganizationFilters, + MembershipRepository, +} from "../../domain/repositories/membership.repository"; import { MembershipAlreadyExistsError } from "../../domain/user.errors"; interface MembershipRow { @@ -56,15 +59,19 @@ export class PgMembershipRepository implements MembershipRepository { return this.mapRow(row); } - public async findByOrganizationId(organizationId: string): Promise { + public async findByOrganizationId( + filters: ListMembershipsByOrganizationFilters, + ): Promise { const result = await this.databaseExecutor.query( ` SELECT id, organization_id, user_id, status, created_at, updated_at FROM memberships WHERE organization_id = $1 - ORDER BY created_at ASC + ORDER BY created_at ASC, id ASC + LIMIT $2 + OFFSET $3 `, - [organizationId], + [filters.organizationId, filters.limit, filters.offset], ); return result.rows.map((row) => this.mapRow(row)); diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 45871cc..a8a09d0 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -55,8 +55,10 @@ class UsersTenancyContractAdapter implements UsersTenancyContract { return this.findActiveMembershipUseCase.execute(userId, organizationId); } - public listMembershipsByOrganization(organizationId: string) { - return this.listMembershipsByOrganizationUseCase.execute(organizationId); + public listMembershipsByOrganization( + input: Parameters[0], + ) { + return this.listMembershipsByOrganizationUseCase.execute(input); } } diff --git a/src/shared/domain/nexus.errors.ts b/src/shared/domain/nexus.errors.ts index 7d92de8..e32ebd1 100644 --- a/src/shared/domain/nexus.errors.ts +++ b/src/shared/domain/nexus.errors.ts @@ -11,6 +11,12 @@ export abstract class NexusError extends Error { export abstract class ValidationError extends NexusError {} +export class InvalidRequestError extends ValidationError { + public constructor(publicMessage: string) { + super("Request validation failed", "invalid_request", publicMessage); + } +} + export abstract class ConflictError extends NexusError {} export abstract class AuthenticationError extends NexusError {} diff --git a/src/shared/domain/read-error-code.ts b/src/shared/domain/read-error-code.ts new file mode 100644 index 0000000..26554fb --- /dev/null +++ b/src/shared/domain/read-error-code.ts @@ -0,0 +1,14 @@ +import type { NexusError } from "./nexus.errors"; + +export function readErrorCode(error: unknown): string { + if ( + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as NexusError).code === "string" + ) { + return (error as NexusError).code; + } + + return "unknown_error"; +} diff --git a/src/shared/events/internal-event-bus.ts b/src/shared/events/internal-event-bus.ts index 1314388..f3bc9fd 100644 --- a/src/shared/events/internal-event-bus.ts +++ b/src/shared/events/internal-event-bus.ts @@ -1,5 +1,6 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Optional } from "@nestjs/common"; +import { ApplicationTelemetryService } from "../../bootstrap/telemetry/application-telemetry.service"; import type { InternalEvent, InternalEventType } from "./internal-events"; export type InternalEventHandler = ( @@ -10,6 +11,11 @@ export type InternalEventHandler = export class InternalEventBus { private readonly subscribers = new Map(); + public constructor( + @Optional() + private readonly applicationTelemetryService?: ApplicationTelemetryService, + ) {} + public subscribe( type: TType, handler: InternalEventHandler>, @@ -35,10 +41,36 @@ export class InternalEventBus { } public async publish(event: InternalEvent): Promise { - const handlers = this.subscribers.get(event.type) ?? []; + const executePublish = async (): Promise => { + const handlers = this.subscribers.get(event.type) ?? []; + + for (const handler of handlers) { + if (this.applicationTelemetryService === undefined) { + await handler(event); + continue; + } + + await this.applicationTelemetryService.runInSpan( + "internal_event.handle", + { + "event.type": event.type, + }, + async () => handler(event), + ); + } + }; - for (const handler of handlers) { - await handler(event); + if (this.applicationTelemetryService === undefined) { + await executePublish(); + return; } + + await this.applicationTelemetryService.runInSpan( + "internal_event.publish", + { + "event.type": event.type, + }, + executePublish, + ); } } diff --git a/test/functional/access-control/access-control.e2e-spec.ts b/test/functional/access-control/access-control.e2e-spec.ts index 796e671..8907b7a 100644 --- a/test/functional/access-control/access-control.e2e-spec.ts +++ b/test/functional/access-control/access-control.e2e-spec.ts @@ -121,10 +121,9 @@ describeIfDocker("Access control endpoints", () => { .set("Authorization", `Bearer ${memberTenantLogin.accessToken}`) .expect(403); - expect(deniedResponse.body).toEqual({ - error: "Forbidden", + expectCorrelatedErrorResponse(deniedResponse, { + error: "permission_denied", message: "Permission denied", - statusCode: 403, }); const roleResponse = await request(httpServer) @@ -165,6 +164,20 @@ describeIfDocker("Access control endpoints", () => { }); }); +function expectCorrelatedErrorResponse( + response: { body: unknown; headers: Record }, + expected: { readonly error: string; readonly message: string }, +): void { + expect(response.body).toEqual({ + correlation_id: expect.any(String), + error: expected.error, + message: expected.message, + }); + expect(response.headers["x-correlation-id"]).toBe( + (response.body as { correlation_id: string }).correlation_id, + ); +} + async function createAccount( httpServer: Parameters[0], email: string, diff --git a/test/functional/audit-logs/audit-logs.e2e-spec.ts b/test/functional/audit-logs/audit-logs.e2e-spec.ts index 18c387a..0340733 100644 --- a/test/functional/audit-logs/audit-logs.e2e-spec.ts +++ b/test/functional/audit-logs/audit-logs.e2e-spec.ts @@ -48,7 +48,7 @@ describeIfDocker("Audit log endpoints", () => { ); const member = await createAccount(httpServer, "member-audit-http@example.com", "Member Audit"); - await request(httpServer) + const membershipResponse = await request(httpServer) .post(`/organizations/${organization.organizationId}/memberships`) .set("Authorization", `Bearer ${ownerTenantLogin.accessToken}`) .send({ userId: member.userId }) @@ -60,13 +60,18 @@ describeIfDocker("Audit log endpoints", () => { organization.organizationId, ); - await request(httpServer) + const deniedResponse = await request(httpServer) .get(`/audit-logs?tenantId=${organization.organizationId}`) .set("Authorization", `Bearer ${memberTenantLogin.accessToken}`) .expect(403); + expectCorrelatedErrorResponse(deniedResponse, { + error: "permission_denied", + message: "Permission denied", + }); + const auditLogsResponse = await request(httpServer) - .get(`/audit-logs?tenantId=${organization.organizationId}`) + .get(`/audit-logs?tenantId=${organization.organizationId}&limit=10&offset=0`) .set("Authorization", `Bearer ${ownerTenantLogin.accessToken}`) .expect(200); @@ -89,6 +94,28 @@ describeIfDocker("Audit log endpoints", () => { ), ).toBe(true); + const membershipAuditEntry = auditLogsResponse.body.find( + (entry: { action: string; metadata: { targetUserId?: string }; correlationId: string }) => + entry.action === "membership_assigned" && entry.metadata.targetUserId === member.userId, + ); + + expect(membershipAuditEntry).toMatchObject({ + correlationId: membershipResponse.headers["x-correlation-id"], + }); + + const firstAuditPage = await request(httpServer) + .get(`/audit-logs?tenantId=${organization.organizationId}&limit=1&offset=0`) + .set("Authorization", `Bearer ${ownerTenantLogin.accessToken}`) + .expect(200); + const secondAuditPage = await request(httpServer) + .get(`/audit-logs?tenantId=${organization.organizationId}&limit=1&offset=1`) + .set("Authorization", `Bearer ${ownerTenantLogin.accessToken}`) + .expect(200); + + expect(firstAuditPage.body).toHaveLength(1); + expect(secondAuditPage.body).toHaveLength(1); + expect(firstAuditPage.body[0].id).not.toBe(secondAuditPage.body[0].id); + await createAccount(httpServer, "other-owner-audit-http@example.com", "Other Owner"); const otherBootstrap = await login(httpServer, "other-owner-audit-http@example.com", undefined); const otherOrganization = await createOrganization( @@ -101,6 +128,17 @@ describeIfDocker("Audit log endpoints", () => { .get(`/audit-logs?tenantId=${otherOrganization.organizationId}`) .set("Authorization", `Bearer ${ownerTenantLogin.accessToken}`) .expect(403); + + const metricsResponse = await request(httpServer).get("/metrics").expect(200); + + expect(metricsResponse.headers["content-type"]).toContain("text/plain"); + expect(metricsResponse.text).toContain('nexus_identity_logins_total{result="success"}'); + expect(metricsResponse.text).toContain('nexus_authorization_decisions_total{result="deny"}'); + expect(metricsResponse.text).toContain('nexus_audit_operations_total{operation="append"}'); + expect(metricsResponse.text).toContain('nexus_audit_operations_total{operation="query"}'); + expect(metricsResponse.text).toContain( + 'nexus_http_requests_total{method="GET",route="/audit-logs",status_code="200"}', + ); }); it("persists null tenant ids for bootstrap logout and login failure without tenant context", async () => { @@ -170,6 +208,20 @@ describeIfDocker("Audit log endpoints", () => { }); }); +function expectCorrelatedErrorResponse( + response: { body: unknown; headers: Record }, + expected: { readonly error: string; readonly message: string }, +): void { + expect(response.body).toEqual({ + correlation_id: expect.any(String), + error: expected.error, + message: expected.message, + }); + expect(response.headers["x-correlation-id"]).toBe( + (response.body as { correlation_id: string }).correlation_id, + ); +} + async function createAccount( httpServer: Parameters[0], email: string, diff --git a/test/functional/identity/identity.e2e-spec.ts b/test/functional/identity/identity.e2e-spec.ts index 2dec53c..b57f883 100644 --- a/test/functional/identity/identity.e2e-spec.ts +++ b/test/functional/identity/identity.e2e-spec.ts @@ -50,6 +50,7 @@ describeIfDocker("Identity endpoints", () => { email: "jane@example.com", status: "active", }); + expect(typeof createAccountResponse.headers["x-correlation-id"]).toBe("string"); const loginResponse = await request(httpServer) .post("/identity/login") @@ -65,6 +66,7 @@ describeIfDocker("Identity endpoints", () => { email: "jane@example.com", }, }); + expect(loginResponse.headers["x-correlation-id"]).toBeDefined(); await request(httpServer) .post("/identity/logout") @@ -88,10 +90,40 @@ describeIfDocker("Identity endpoints", () => { }) .expect(401); - expect(response.body).toEqual({ - error: "Unauthorized", + expectCorrelatedErrorResponse(response, { + error: "invalid_credentials", message: "Invalid credentials", - statusCode: 401, + }); + }); + + it("fails fast on invalid input with a standardized validation payload", async () => { + const httpServer = application.getHttpServer() as Parameters[0]; + + const response = await request(httpServer) + .post("/identity/login") + .send({ + email: "jane@example.com", + password: "short", + }) + .expect(400); + + expectCorrelatedErrorResponse(response, { + error: "invalid_request", + message: "password must be longer than or equal to 8 characters", }); }); }); + +function expectCorrelatedErrorResponse( + response: { body: unknown; headers: Record }, + expected: { readonly error: string; readonly message: string }, +): void { + expect(response.body).toEqual({ + correlation_id: expect.any(String), + error: expected.error, + message: expected.message, + }); + expect(response.headers["x-correlation-id"]).toBe( + (response.body as { correlation_id: string }).correlation_id, + ); +} diff --git a/test/functional/organizations/organizations.e2e-spec.ts b/test/functional/organizations/organizations.e2e-spec.ts index 3cc0692..d1146c9 100644 --- a/test/functional/organizations/organizations.e2e-spec.ts +++ b/test/functional/organizations/organizations.e2e-spec.ts @@ -67,6 +67,7 @@ describeIfDocker("Organizations endpoints", () => { status: "active", userId: member.userId, }); + expect(membershipResponse.headers["x-correlation-id"]).toBeDefined(); const listMembershipsResponse = await request(httpServer) .get(`/organizations/${organization.organizationId}/memberships`) @@ -85,6 +86,19 @@ describeIfDocker("Organizations endpoints", () => { userId: member.userId, }), ]); + + const paginatedMembershipsResponse = await request(httpServer) + .get(`/organizations/${organization.organizationId}/memberships?limit=1&offset=1`) + .set("Authorization", `Bearer ${tenantLogin.accessToken}`) + .expect(200); + + expect(paginatedMembershipsResponse.body).toEqual([ + expect.objectContaining({ + organizationId: organization.organizationId, + status: "active", + userId: member.userId, + }), + ]); }); it("rejects tenant-scoped access when the session has no tenant context", async () => { @@ -102,10 +116,9 @@ describeIfDocker("Organizations endpoints", () => { .set("Authorization", `Bearer ${bootstrapLogin.accessToken}`) .expect(403); - expect(response.body).toEqual({ - error: "Forbidden", + expectCorrelatedErrorResponse(response, { + error: "tenant_context_required", message: "Tenant context is required", - statusCode: 403, }); }); @@ -142,10 +155,9 @@ describeIfDocker("Organizations endpoints", () => { .set("Authorization", `Bearer ${betaTenantLogin.accessToken}`) .expect(403); - expect(crossTenantResponse.body).toEqual({ - error: "Forbidden", + expectCorrelatedErrorResponse(crossTenantResponse, { + error: "tenant_context_denied", message: "Tenant access denied", - statusCode: 403, }); const deactivateResponse = await request(httpServer) @@ -160,10 +172,9 @@ describeIfDocker("Organizations endpoints", () => { .set("Authorization", `Bearer ${alphaTenantLogin.accessToken}`) .expect(400); - expect(inactiveResponse.body).toEqual({ - error: "Bad Request", + expectCorrelatedErrorResponse(inactiveResponse, { + error: "organization_inactive", message: "Organization is inactive", - statusCode: 400, }); }); @@ -184,14 +195,27 @@ describeIfDocker("Organizations endpoints", () => { }) .expect(404); - expect(response.body).toEqual({ - error: "Not Found", + expectCorrelatedErrorResponse(response, { + error: "membership_not_found", message: "Membership not found", - statusCode: 404, }); }); }); +function expectCorrelatedErrorResponse( + response: { body: unknown; headers: Record }, + expected: { readonly error: string; readonly message: string }, +): void { + expect(response.body).toEqual({ + correlation_id: expect.any(String), + error: expected.error, + message: expected.message, + }); + expect(response.headers["x-correlation-id"]).toBe( + (response.body as { correlation_id: string }).correlation_id, + ); +} + async function createAccount( httpServer: Parameters[0], email: string, diff --git a/test/integration/modules/audit-logs/audit-logs.integration.spec.ts b/test/integration/modules/audit-logs/audit-logs.integration.spec.ts index 3ad890c..7c30187 100644 --- a/test/integration/modules/audit-logs/audit-logs.integration.spec.ts +++ b/test/integration/modules/audit-logs/audit-logs.integration.spec.ts @@ -111,6 +111,7 @@ describeIfDocker("Audit logs integration", () => { const listAuditLogs = application.get(ListAuditLogsUseCase); const requestCorrelationContext = application.get(RequestCorrelationContext); const internalEventBus = application.get(InternalEventBus); + const pool = application.get(DATABASE_POOL); const alphaOwner = await createUserAccount.execute({ email: "alpha-audit@example.com", @@ -144,26 +145,60 @@ describeIfDocker("Audit logs integration", () => { }), ); - const alphaLogs = await listAuditLogs.execute({ + const firstAuditPage = await listAuditLogs.execute({ + limit: 2, + offset: 0, + tenantId: alphaOrganization.organizationId, + }); + const secondAuditPage = await listAuditLogs.execute({ + limit: 2, + offset: 2, + tenantId: alphaOrganization.organizationId, + }); + const deniedLogs = await listAuditLogs.execute({ + action: "authorization_denied", + limit: 10, + offset: 0, tenantId: alphaOrganization.organizationId, }); const betaLogs = await listAuditLogs.execute({ + limit: 10, + offset: 0, tenantId: betaOrganization.organizationId, }); + const indexRows = await pool.query<{ indexname: string }>( + ` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'audit_logs' + ORDER BY indexname ASC + `, + ); - expect(alphaLogs.every((entry) => entry.tenantId === alphaOrganization.organizationId)).toBe( + expect(firstAuditPage.every((entry) => entry.tenantId === alphaOrganization.organizationId)).toBe( true, ); + expect(firstAuditPage).toHaveLength(2); + expect(secondAuditPage).toHaveLength(2); + expect(firstAuditPage.map((entry) => entry.id)).not.toEqual( + secondAuditPage.map((entry) => entry.id), + ); expect(betaLogs.every((entry) => entry.tenantId === betaOrganization.organizationId)).toBe( true, ); - expect(alphaLogs).toEqual( + expect(deniedLogs).toEqual([ + expect.objectContaining({ + action: "authorization_denied", + correlationId: "request-abc-123", + tenantId: alphaOrganization.organizationId, + }), + ]); + expect(indexRows.rows.map((row) => row.indexname)).toEqual( expect.arrayContaining([ - expect.objectContaining({ - action: "authorization_denied", - correlationId: "request-abc-123", - tenantId: alphaOrganization.organizationId, - }), + "idx_audit_logs_tenant_action_timestamp", + "idx_audit_logs_tenant_timestamp_id", + "idx_audit_logs_tenant_user_timestamp", ]), ); diff --git a/test/support/unit-test-doubles.ts b/test/support/unit-test-doubles.ts index bf6629e..453f3da 100644 --- a/test/support/unit-test-doubles.ts +++ b/test/support/unit-test-doubles.ts @@ -1,3 +1,5 @@ +import type { ApplicationMetricsService } from "../../src/bootstrap/telemetry/application-metrics.service"; +import type { ApplicationTelemetryService } from "../../src/bootstrap/telemetry/application-telemetry.service"; import type { DatabaseExecutor } from "../../src/bootstrap/persistence/database.executor"; import type { InternalEventBus } from "../../src/shared/events/internal-event-bus"; @@ -13,3 +15,29 @@ export function createInternalEventBusMock(): InternalEventBus { subscribe: jest.fn(), } as unknown as InternalEventBus; } + +export function createApplicationTelemetryServiceMock(): ApplicationTelemetryService { + return { + bindSpan: jest.fn((_: unknown, operation: () => void) => operation()), + endHttpServerSpan: jest.fn(), + runInSpan: jest.fn( + async ( + _name: string, + _attributes: Record, + operation: () => Promise, + ) => operation(), + ), + startHttpServerSpan: jest.fn(), + } as unknown as ApplicationTelemetryService; +} + +export function createApplicationMetricsServiceMock(): ApplicationMetricsService { + return { + recordAuditOperation: jest.fn(), + recordAuthorizationDecision: jest.fn(), + recordHttpRequest: jest.fn(), + recordLoginResult: jest.fn(), + recordModuleFailure: jest.fn(), + renderPrometheusMetrics: jest.fn().mockReturnValue(""), + } as unknown as ApplicationMetricsService; +} diff --git a/test/unit/bootstrap/errors/http-exception.filter.spec.ts b/test/unit/bootstrap/errors/http-exception.filter.spec.ts new file mode 100644 index 0000000..df17dda --- /dev/null +++ b/test/unit/bootstrap/errors/http-exception.filter.spec.ts @@ -0,0 +1,118 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; +import type { ArgumentsHost } from "@nestjs/common"; +import type { PinoLogger } from "nestjs-pino"; + +import { GlobalExceptionFilter } from "../../../../src/bootstrap/errors/http-exception.filter"; +import type { ApplicationMetricsService } from "../../../../src/bootstrap/telemetry/application-metrics.service"; +import { InvalidCredentialsError } from "../../../../src/modules/identity/domain/identity.errors"; + +function createLoggerMock(): PinoLogger { + return { + error: jest.fn(), + setContext: jest.fn(), + warn: jest.fn(), + } as unknown as PinoLogger; +} + +function createArgumentsHostMock(request: Record, response: Record) { + return { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + } as ArgumentsHost; +} + +describe("GlobalExceptionFilter", () => { + it("maps semantic errors to the public HTTP contract with correlation id", () => { + const logger = createLoggerMock(); + const metrics = { + recordModuleFailure: jest.fn(), + } as unknown as ApplicationMetricsService; + const filter = new GlobalExceptionFilter(logger, metrics); + const response = { + json: jest.fn(), + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + const request = { + id: "request-123", + method: "POST", + url: "/identity/login", + }; + + filter.catch( + new InvalidCredentialsError(), + createArgumentsHostMock(request, response), + ); + + expect(response.setHeader).toHaveBeenCalledWith("x-correlation-id", "request-123"); + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + correlation_id: "request-123", + error: "invalid_credentials", + message: "Invalid credentials", + }); + expect(metrics.recordModuleFailure).toHaveBeenCalledWith({ + errorCode: "invalid_credentials", + module: "identity", + operation: "http_request", + }); + }); + + it("returns a generic body for unexpected exceptions", () => { + const logger = createLoggerMock(); + const metrics = { + recordModuleFailure: jest.fn(), + } as unknown as ApplicationMetricsService; + const filter = new GlobalExceptionFilter(logger, metrics); + const response = { + json: jest.fn(), + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + const request = { + id: "request-500", + method: "GET", + url: "/metrics", + }; + + filter.catch(new Error("boom"), createArgumentsHostMock(request, response)); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ + correlation_id: "request-500", + error: "internal_error", + message: "Internal server error", + }); + expect((logger.error as jest.Mock).mock.calls).toHaveLength(1); + }); + + it("normalizes raw Nest HTTP exceptions into semantic codes", () => { + const filter = new GlobalExceptionFilter( + createLoggerMock(), + { recordModuleFailure: jest.fn() } as unknown as ApplicationMetricsService, + ); + const response = { + json: jest.fn(), + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + const request = { + id: "request-400", + method: "GET", + url: "/health", + }; + + filter.catch( + new HttpException("invalid query", HttpStatus.BAD_REQUEST), + createArgumentsHostMock(request, response), + ); + + expect(response.json).toHaveBeenCalledWith({ + correlation_id: "request-400", + error: "invalid_request", + message: "invalid query", + }); + }); +}); diff --git a/test/unit/bootstrap/errors/validation-exception.factory.spec.ts b/test/unit/bootstrap/errors/validation-exception.factory.spec.ts new file mode 100644 index 0000000..08c039d --- /dev/null +++ b/test/unit/bootstrap/errors/validation-exception.factory.spec.ts @@ -0,0 +1,41 @@ +import { createValidationExceptionFactory } from "../../../../src/bootstrap/errors/validation-exception.factory"; + +describe("createValidationExceptionFactory", () => { + it("uses the first direct validation constraint message", () => { + const exceptionFactory = createValidationExceptionFactory(); + + const error = exceptionFactory([ + { + constraints: { + isEmail: "email must be an email", + }, + property: "email", + }, + ] as never); + + expect(error.code).toBe("invalid_request"); + expect(error.publicMessage).toBe("email must be an email"); + }); + + it("falls back to nested validation messages", () => { + const exceptionFactory = createValidationExceptionFactory(); + + const error = exceptionFactory([ + { + children: [ + { + constraints: { + minLength: "password must be longer than or equal to 8 characters", + }, + property: "password", + }, + ], + property: "body", + }, + ] as never); + + expect(error.publicMessage).toBe( + "password must be longer than or equal to 8 characters", + ); + }); +}); diff --git a/test/unit/bootstrap/logging/pino-logger.config.spec.ts b/test/unit/bootstrap/logging/pino-logger.config.spec.ts index 753d128..7106667 100644 --- a/test/unit/bootstrap/logging/pino-logger.config.spec.ts +++ b/test/unit/bootstrap/logging/pino-logger.config.spec.ts @@ -5,7 +5,7 @@ import pino from "pino"; import { buildPinoHttpConfiguration } from "../../../../src/bootstrap/logging/pino-logger.config"; describe("buildPinoHttpConfiguration", () => { - it("produces structured logs with timestamp, level and message", () => { + it("produces structured logs with timestamp, string level and normalized fields", () => { const output = new PassThrough(); const chunks: Buffer[] = []; @@ -34,6 +34,9 @@ describe("buildPinoHttpConfiguration", () => { const logger = pino( { base: configuration.base ?? null, + ...(configuration.formatters === undefined + ? {} + : { formatters: configuration.formatters }), level: configuration.level ?? "info", messageKey: configuration.messageKey ?? "message", timestamp: configuration.timestamp ?? false, @@ -42,17 +45,38 @@ describe("buildPinoHttpConfiguration", () => { ); logger.info("foundation ready"); + logger.info( + { + correlationId: "request-123", + context: "HealthController", + organizationId: "tenant-1", + userId: "user-1", + }, + "structured ready", + ); - const entry = JSON.parse(Buffer.concat(chunks).toString("utf8").trim()) as { - level: number; + const entries = Buffer.concat(chunks) + .toString("utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const entry = entries[0] as { + level: string; message: string; timestamp: string; }; + const structuredEntry = entries[1] as Record; expect(entry).toMatchObject({ - level: 30, + level: "info", message: "foundation ready", }); expect(typeof entry.timestamp).toBe("string"); + expect(structuredEntry).toMatchObject({ + correlation_id: "request-123", + message: "structured ready", + tenant_id: "tenant-1", + user_id: "user-1", + }); }); }); diff --git a/test/unit/bootstrap/telemetry/application-metrics.service.spec.ts b/test/unit/bootstrap/telemetry/application-metrics.service.spec.ts new file mode 100644 index 0000000..583a763 --- /dev/null +++ b/test/unit/bootstrap/telemetry/application-metrics.service.spec.ts @@ -0,0 +1,32 @@ +import { ApplicationMetricsService } from "../../../../src/bootstrap/telemetry/application-metrics.service"; + +describe("ApplicationMetricsService", () => { + it("renders Prometheus counters and histograms for the recorded operations", () => { + const service = new ApplicationMetricsService(); + + service.recordHttpRequest({ + durationMs: 12, + method: "GET", + route: "/health", + statusCode: 200, + }); + service.recordLoginResult("success"); + service.recordAuthorizationDecision("deny"); + service.recordAuditOperation({ + durationMs: 6, + operation: "append", + }); + + const output = service.renderPrometheusMetrics(); + + expect(output).toContain("nexus_http_requests_total"); + expect(output).toContain('method="GET"'); + expect(output).toContain("nexus_http_request_duration_ms_bucket"); + expect(output).toContain("nexus_identity_logins_total"); + expect(output).toContain('result="success"'); + expect(output).toContain("nexus_authorization_decisions_total"); + expect(output).toContain('result="deny"'); + expect(output).toContain("nexus_audit_operations_total"); + expect(output).toContain('operation="append"'); + }); +}); diff --git a/test/unit/modules/access-control/application/authorize-action.use-case.spec.ts b/test/unit/modules/access-control/application/authorize-action.use-case.spec.ts index 6ccf1b9..cdd07df 100644 --- a/test/unit/modules/access-control/application/authorize-action.use-case.spec.ts +++ b/test/unit/modules/access-control/application/authorize-action.use-case.spec.ts @@ -1,6 +1,10 @@ import type { PinoLogger } from "nestjs-pino"; import { AuthorizeActionUseCase } from "../../../../../src/modules/access-control/application/use-cases/authorize-action.use-case"; +import { + createApplicationMetricsServiceMock, + createApplicationTelemetryServiceMock, +} from "../../../../support/unit-test-doubles"; function createLoggerMock(): PinoLogger { return { @@ -11,6 +15,8 @@ function createLoggerMock(): PinoLogger { describe("AuthorizeActionUseCase", () => { it("allows when the user has the required permission through assigned roles", async () => { + const applicationMetricsService = createApplicationMetricsServiceMock(); + const applicationTelemetryService = createApplicationTelemetryServiceMock(); const useCase = new AuthorizeActionUseCase( { listPermissionCodesByUserIdAndOrganizationId: jest @@ -18,6 +24,8 @@ describe("AuthorizeActionUseCase", () => { .mockResolvedValue(["membership:view", "membership:create"]), } as never, createLoggerMock(), + applicationTelemetryService, + applicationMetricsService, ); const decision = await useCase.execute({ @@ -27,9 +35,18 @@ describe("AuthorizeActionUseCase", () => { }); expect(decision.allowed).toBe(true); + expect(applicationMetricsService.recordAuthorizationDecision).toHaveBeenCalledWith("allow"); + expect(applicationTelemetryService.runInSpan).toHaveBeenCalledWith( + "access_control.authorize_action", + expect.objectContaining({ + "authorization.permission_code": "membership:create", + }), + expect.any(Function), + ); }); it("denies by default when the user has no matching permission", async () => { + const applicationMetricsService = createApplicationMetricsServiceMock(); const useCase = new AuthorizeActionUseCase( { listPermissionCodesByUserIdAndOrganizationId: jest @@ -37,6 +54,8 @@ describe("AuthorizeActionUseCase", () => { .mockResolvedValue(["membership:view"]), } as never, createLoggerMock(), + undefined, + applicationMetricsService, ); const decision = await useCase.execute({ @@ -46,5 +65,6 @@ describe("AuthorizeActionUseCase", () => { }); expect(decision.allowed).toBe(false); + expect(applicationMetricsService.recordAuthorizationDecision).toHaveBeenCalledWith("deny"); }); }); diff --git a/test/unit/modules/audit-logs/application/list-audit-logs.use-case.spec.ts b/test/unit/modules/audit-logs/application/list-audit-logs.use-case.spec.ts index af6068b..1c7c4bd 100644 --- a/test/unit/modules/audit-logs/application/list-audit-logs.use-case.spec.ts +++ b/test/unit/modules/audit-logs/application/list-audit-logs.use-case.spec.ts @@ -34,6 +34,8 @@ describe("ListAuditLogsUseCase", () => { const result = await useCase.execute({ action: "organization_created", from: new Date("2026-03-26T00:00:00.000Z"), + limit: 20, + offset: 10, tenantId: "tenant-1", to: new Date("2026-03-27T00:00:00.000Z"), userId: "user-1", @@ -42,6 +44,8 @@ describe("ListAuditLogsUseCase", () => { expect(repository.list).toHaveBeenCalledWith({ action: "organization_created", from: new Date("2026-03-26T00:00:00.000Z"), + limit: 20, + offset: 10, tenantId: "tenant-1", to: new Date("2026-03-27T00:00:00.000Z"), userId: "user-1", @@ -61,6 +65,8 @@ describe("ListAuditLogsUseCase", () => { await expect( useCase.execute({ from: new Date("2026-03-27T00:00:00.000Z"), + limit: 50, + offset: 0, tenantId: "tenant-1", to: new Date("2026-03-26T00:00:00.000Z"), }), diff --git a/test/unit/modules/identity/application/login-with-password.use-case.spec.ts b/test/unit/modules/identity/application/login-with-password.use-case.spec.ts index 19cef86..6a27ba6 100644 --- a/test/unit/modules/identity/application/login-with-password.use-case.spec.ts +++ b/test/unit/modules/identity/application/login-with-password.use-case.spec.ts @@ -10,6 +10,8 @@ import { OrganizationInactiveError } from "../../../../../src/modules/organizati import { MembershipNotFoundError } from "../../../../../src/modules/users/domain/user.errors"; import { TenantContextRequiredError } from "../../../../../src/shared/tenancy/tenant.errors"; import { + createApplicationMetricsServiceMock, + createApplicationTelemetryServiceMock, createDatabaseExecutorMock, createInternalEventBusMock, } from "../../../../support/unit-test-doubles"; @@ -33,6 +35,8 @@ function createConfigService(): ApplicationConfigService { describe("LoginWithPasswordUseCase", () => { it("logs in with valid credentials and an active tenant", async () => { + const applicationMetricsService = createApplicationMetricsServiceMock(); + const applicationTelemetryService = createApplicationTelemetryServiceMock(); const account = Account.create({ email: EmailAddress.create("jane@example.com"), id: "account-1", @@ -91,6 +95,8 @@ describe("LoginWithPasswordUseCase", () => { createDatabaseExecutorMock(), createInternalEventBusMock(), createLoggerMock(), + applicationTelemetryService, + applicationMetricsService, ); const result = await useCase.execute({ @@ -103,6 +109,14 @@ describe("LoginWithPasswordUseCase", () => { expect(result.accessToken).toBe("jwt-token"); expect(result.principal.email).toBe("jane@example.com"); expect(result.principal.organizationId).toBe("organization-1"); + expect(applicationMetricsService.recordLoginResult).toHaveBeenCalledWith("success"); + expect(applicationTelemetryService.runInSpan).toHaveBeenCalledWith( + "identity.login_with_password", + { + "identity.has_organization": "true", + }, + expect.any(Function), + ); }); it("allows bootstrap login when the user has no active memberships", async () => { @@ -165,6 +179,7 @@ describe("LoginWithPasswordUseCase", () => { }); it("fails with generic invalid credentials for a bad password", async () => { + const applicationMetricsService = createApplicationMetricsServiceMock(); const account = Account.create({ email: EmailAddress.create("jane@example.com"), id: "account-1", @@ -207,6 +222,8 @@ describe("LoginWithPasswordUseCase", () => { createDatabaseExecutorMock(), createInternalEventBusMock(), createLoggerMock(), + undefined, + applicationMetricsService, ); await expect( @@ -215,6 +232,7 @@ describe("LoginWithPasswordUseCase", () => { password: "bad-password", }), ).rejects.toThrow(InvalidCredentialsError); + expect(applicationMetricsService.recordLoginResult).toHaveBeenCalledWith("failure"); }); it("fails when the user is inactive", async () => { diff --git a/test/unit/shared/auth/authorization.guard.spec.ts b/test/unit/shared/auth/authorization.guard.spec.ts new file mode 100644 index 0000000..53e9e01 --- /dev/null +++ b/test/unit/shared/auth/authorization.guard.spec.ts @@ -0,0 +1,87 @@ +import type { ExecutionContext } from "@nestjs/common"; +import type { Reflector } from "@nestjs/core"; +import type { PinoLogger } from "nestjs-pino"; + +import { AuthorizationGuard } from "../../../../src/shared/auth/authorization.guard"; +import { REQUIRED_PERMISSION_METADATA_KEY } from "../../../../src/shared/auth/require-permission.decorator"; +import { PermissionDeniedError } from "../../../../src/modules/access-control/domain/access-control.errors"; + +function createLoggerMock(): PinoLogger { + return { + error: jest.fn(), + setContext: jest.fn(), + } as unknown as PinoLogger; +} + +function createExecutionContextMock(request: Record): ExecutionContext { + return { + getClass: () => AuthorizationGuard, + getHandler: () => AuthorizationGuard.prototype.canActivate, + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; +} + +describe("AuthorizationGuard", () => { + it("returns true when no permission metadata is required", async () => { + const guard = new AuthorizationGuard( + { + getAllAndOverride: jest.fn().mockReturnValue(undefined), + } as unknown as Reflector, + {} as never, + {} as never, + createLoggerMock(), + ); + + await expect( + guard.canActivate(createExecutionContextMock({})), + ).resolves.toBe(true); + }); + + it("publishes an audit event and throws when the action is denied", async () => { + const internalEventBus = { + publish: jest.fn().mockResolvedValue(undefined), + }; + const guard = new AuthorizationGuard( + { + getAllAndOverride: jest + .fn() + .mockImplementation((metadataKey: string) => + metadataKey === REQUIRED_PERMISSION_METADATA_KEY + ? "membership:view" + : undefined, + ), + } as unknown as Reflector, + { + execute: jest.fn().mockResolvedValue({ allowed: false }), + } as never, + internalEventBus as never, + createLoggerMock(), + ); + + const request = { + authenticatedPrincipal: { + userId: "user-1", + }, + method: "GET", + originalUrl: "/organizations/organization-1/memberships", + tenantContext: { + organizationId: "organization-1", + }, + url: "/organizations/organization-1/memberships", + }; + + await expect( + guard.canActivate(createExecutionContextMock(request)), + ).rejects.toThrow(PermissionDeniedError); + expect(internalEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: "organization-1", + permissionCode: "membership:view", + type: "authorization.denied", + userId: "user-1", + }), + ); + }); +}); diff --git a/test/unit/shared/tenancy/tenant-context-resolver.service.spec.ts b/test/unit/shared/tenancy/tenant-context-resolver.service.spec.ts new file mode 100644 index 0000000..7a1e96a --- /dev/null +++ b/test/unit/shared/tenancy/tenant-context-resolver.service.spec.ts @@ -0,0 +1,100 @@ +import type { PinoLogger } from "nestjs-pino"; + +import { TenantContextResolverService } from "../../../../src/shared/tenancy/tenant-context-resolver.service"; +import { + TenantContextDeniedError, + TenantContextRequiredError, +} from "../../../../src/shared/tenancy/tenant.errors"; +import { OrganizationInactiveError } from "../../../../src/modules/organizations/domain/organization.errors"; + +function createLoggerMock(): PinoLogger { + return { + info: jest.fn(), + setContext: jest.fn(), + warn: jest.fn(), + } as unknown as PinoLogger; +} + +describe("TenantContextResolverService", () => { + it("resolves an active tenant with an active membership", async () => { + const service = new TenantContextResolverService( + { + getOrganizationById: jest.fn().mockResolvedValue({ + organizationId: "organization-1", + status: "active", + }), + } as never, + { + findActiveMembership: jest.fn().mockResolvedValue({ + membershipId: "membership-1", + organizationId: "organization-1", + status: "active", + userId: "user-1", + }), + } as never, + createLoggerMock(), + ); + + const result = await service.resolve({ + organizationId: "organization-1", + userId: "user-1", + }); + + expect(result).toEqual({ + membershipId: "membership-1", + organizationId: "organization-1", + userId: "user-1", + }); + }); + + it("denies the tenant when the route organization does not match the session", async () => { + const service = new TenantContextResolverService( + {} as never, + {} as never, + createLoggerMock(), + ); + + await expect( + service.resolve({ + organizationId: "organization-1", + routeOrganizationId: "organization-2", + userId: "user-1", + }), + ).rejects.toThrow(TenantContextDeniedError); + }); + + it("requires a tenant context for protected flows", async () => { + const service = new TenantContextResolverService( + {} as never, + {} as never, + createLoggerMock(), + ); + + await expect( + service.resolve({ + organizationId: null, + userId: "user-1", + }), + ).rejects.toThrow(TenantContextRequiredError); + }); + + it("rejects inactive organizations", async () => { + const service = new TenantContextResolverService( + { + getOrganizationById: jest.fn().mockResolvedValue({ + organizationId: "organization-1", + status: "inactive", + }), + } as never, + {} as never, + createLoggerMock(), + ); + + await expect( + service.resolve({ + organizationId: "organization-1", + userId: "user-1", + }), + ).rejects.toThrow(OrganizationInactiveError); + }); +});