Files
apf_portal/apps/portal-bff/.env.example
T
Julien Gautier 60183d51ae
CI / commits (pull_request) Successful in 2m7s
CI / scan (pull_request) Successful in 2m20s
CI / check (pull_request) Successful in 2m28s
CI / a11y (pull_request) Successful in 1m31s
CI / perf (pull_request) Successful in 4m52s
feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle
the audit foundations were already in place from earlier work (prisma
AuditEvent model, postgres roles + grants in
infra/local/init/postgres/01-init.sql, AuditWriter.recordEvent with
SET LOCAL ROLE audit_writer). this pr layers typed methods on top
and emits the first four events from real code paths.

typed methods on AuditWriter:
  signIn, signInFailed, signOut, sessionExpired. callers pass the
  raw entra oid; hashing happens inside the writer so the salt
  never leaves the audit module. adr-0013 explicitly defers adding
  typed methods "as the matching feature ships" — auth has shipped,
  so we add the four events tied to today's code paths. the other
  four catalogue entries (session.revoked, token.validation.failed,
  mfa.assertion.failed, authz.deny) wait for their triggering paths
  (admin "logout everywhere", obo, requiremfa guard, etc.).

HashUserIdService:
  reads LOG_USER_ID_SALT once at injection, exposes hash(userId) →
  16-hex-char digest. same salt + same input ⇒ same output across
  audit_events.actor_id_hash (adr-0013) and the future pino
  user_id_hash (adr-0012) — that stability is the join key
  investigators use to correlate the two streams.

LOG_USER_ID_SALT env var:
  promoted from .env.example's "future vars" block to the active
  section. mandatory at boot; new assertLogUserIdSalt() validator
  follows the same pattern as session-secret /
  session-encryption-key (base64url, ≥32 bytes decoded, placeholder
  rejected). wired in main.ts.

AuditModule is now @Global():
  the previous in-line comment claimed it was "imported globally by
  AppModule" but the decorator was missing — without it,
  AuthController and the absolute-timeout middleware couldn't
  inject AuditWriter without re-importing AuditModule. AuditModule
  now also provides HashUserIdService.

emission points:
  - /auth/callback success → audit.signIn after session.save()
    (blocking per adr-0013: a failed audit fails the sign-in, the
    user sees a 5xx, ops gets a pino line).
  - /auth/callback failure → audit.signInFailed with a
    discriminator failureKind. covers all six rejection paths:
    entra-error, missing-code-or-state, no-pre-auth-cookie, and the
    three AuthCodeFlowError kinds (state-mismatch, flow-expired,
    token-exchange-failed). the malformed-cookie branch collapses
    into no-pre-auth-cookie since the controller can't distinguish.
  - /auth/logout (authenticated only) → audit.signOut before
    session.destroy() — once destroy runs the actor id is gone.
    anonymous logout emits no audit row (would be noise).
  - absolute-timeout middleware → audit.sessionExpired with
    reason: 'absolute' and ageMs for forensic granularity. idle-ttl
    expiry stays silent (no bff observation point — redis drops the
    key on its own).

specs:
  - auth.module.spec + session.module.spec now bootstrap their own
    @Global() stub for PrismaService + ClsService so auditwriter's
    transitive resolution works in the slice-of-graph test without
    booting prisma. lesson learned the hard way on prs #115/#116:
    nx loads apps/portal-bff/.env locally and masks env-graph bugs
    that ci's clean shell surfaces. all 142 specs pass under
    env -u REDIS_URL -u SESSION_SECRET -u SESSION_ENCRYPTION_KEY
    -u LOG_USER_ID_SALT -u DATABASE_URL -u ENTRA_*.
  - 19 new specs across check-log-user-id-salt,
    hash-user-id.service, audit.service typed-methods,
    auth.controller, and absolute-timeout.middleware.

out of scope, landing in follow-ups:
  - the other four adr-0013 catalogue events.
  - separate audit_database_url pool with audit_writer-only creds
    (production hardening, deferred behind SET LOCAL ROLE in v1).
  - retention purge job + startup self-test probe (on-prem infra
    adr).
  - cls-populating middleware that puts actorIdHash on the request
    context — every emission path in this pr has the user in hand
    and passes it explicitly; the middleware can come when more
    routes need it.
2026-05-13 14:08:30 +02:00

147 lines
7.3 KiB
Bash

# BFF environment template
# Copy to .env (which is gitignored) and fill in actual values for local development.
# Production values are managed by the platform's secret manager (see future infrastructure ADR).
# Postgres connection (per ADR-0006)
# Local dev default: dockerised Postgres on port 5432, schema 'public'.
# Username / password / db must match infra/local/.env (POSTGRES_USER /
# POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth,
# this is the BFF view of the same connection.
#
# IMPORTANT — URL encoding. The password is part of the URL userinfo
# segment, so any of these characters must be URL-encoded:
# @ → %40 # → %23 : → %3A / → %2F ? → %3F
# % → %25 & → %26 = → %3D + → %2B ; → %3B
# i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read
# "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public"
# The BFF aborts at boot with a clear error if it detects an unencoded
# special character (see apps/portal-bff/src/config/check-database-url.ts).
DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public"
# Observability (per ADR-0012)
# All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see
# apps/portal-bff/src/observability/tracing.ts for the bootstrap.
# Pino log level: 'info' in prod, 'debug' in dev (default if unset).
LOG_LEVEL=debug
OTEL_SERVICE_NAME=portal-bff
OTEL_SERVICE_VERSION=dev
# Default endpoint targets the Collector provisioned in
# infra/local/dev.compose.yml. The /v1/traces suffix is required by
# the HTTP/Protobuf transport.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# v1 samples 100 % at the app; tail sampling is delegated to the
# Collector (per ADR-0012). Override only for spike investigations.
OTEL_TRACES_SAMPLER=always_on
# Identity / Entra ID app registration (per ADR-0008 / ADR-0009)
# Values come from the project's Entra application registration in the
# Azure Admin Center → App registrations → APF Portal. The four
# *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys
# are mandatory; the BFF refuses to boot without them (see
# apps/portal-bff/src/config/check-entra-config.ts).
#
# ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually
# https://login.microsoftonline.com/. The authority used by MSAL is
# `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows,
# or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant
# (per ADR-0008's dual-audience design). v1 uses the tenant-scoped
# authority; the multi-tenant switch lands when External ID activation
# is needed.
#
# ENTRA_CLIENT_SECRET is the high-value secret of this set. Never
# commit a real value. Production manages it via the deploy platform's
# secret manager (future infrastructure ADR).
ENTRA_INSTANCE_URL=https://login.microsoftonline.com/
ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_SECRET=replace_with_real_value
# Redirect URIs registered in Entra alongside the same client id. Both
# `/auth/callback` and `/auth/logout` paths are mounted by the BFF
# once the OIDC routes land in a subsequent PR.
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
# round-trip, and (once ADR-0010 ships) the session cookie's
# integrity layer. Mandatory at boot — the BFF aborts if missing or
# obviously weak (less than 32 base64-decoded bytes ≈ 256 bits of
# entropy). Generate a fresh value per environment:
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_SECRET=replace_with_32_random_bytes_base64url
# Redis connection (per ADR-0010). The BFF uses `ioredis` for session
# storage (today: just the connection; the express-session +
# connect-redis middleware lands in the next PR).
#
# REDIS_URL — full URL form including auth. Must match `infra/local/.env`
# (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose
# stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS +
# REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current
# variable supports the dev single-instance shape only.
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
# Session payload encryption (per ADR-0010 §"At-rest encryption").
# AES-256-GCM key for encrypting the session JSON that connect-redis
# writes to Redis, so a Redis dump never carries raw user identities
# / future tokens / claims in plaintext. **Distinct** from
# SESSION_SECRET, which only signs the cookie's session-id — never
# reuse one for the other. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# Session timeouts (per ADR-0010). Both optional with sensible
# defaults; override only when staging / prod policy diverges.
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
# extends the cookie's `expires` by this many seconds.
# Default 1800 (30 min).
# SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is
# destroyed regardless of activity at this age.
# Default 43200 (12 h).
# SESSION_IDLE_TIMEOUT_SECONDS=1800
# SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200
# Per-environment salt used to pseudonymise the user id before it
# lands in audit rows (per ADR-0013 §"Schema") and in Pino app log
# lines (per ADR-0012 §"User id hashing"). Same value must be used
# on both sides so audit and app logs join on `actor_id_hash`.
#
# Rotation invalidates the join key — old rows / log lines can no
# longer be correlated with the new hash. Treat as long-lived per
# environment. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
# ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET)
# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in
# in the multi-tenant phase — empty means
# "only ENTRA_TENANT_ID is accepted")
#
# Sessions (ADR-0010) — additional keys wired as the layers land:
# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA)
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA)
# REDIS_TLS ('true' in prod)
#
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
#
# Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer')
# AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job)
# AUDIT_RETENTION_DAYS (default 365)
#
# Downstream API access (ADR-0014):
# OBO_CACHE_ENCRYPTION_KEY (32-byte base64, distinct from SESSION_ENCRYPTION_KEY)
# BFF_JWKS_PRIVATE_KEY_PATH
# BFF_JWKS_KID
# <SERVICE>_API_BASE_URL (per integrated downstream)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)