diff --git a/.env.example b/.env.example index 439026c..93152d0 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ API_HOST= # ── Database ───────────────────────────────────────────────────────────────── DATABASE_URL=postgresql://user:password@localhost:5432/dorisio +POSTGRES_PASSWORD=replace-with-a-local-development-password # Comma-separated read-only PostgreSQL URLs; reads opt in per query. DATABASE_READ_REPLICA_URLS= DB_REPLICA_ENABLED=false diff --git a/README.md b/README.md index eedb18f..874f0f3 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,10 @@ DATABASE_URL={{ DATABASE_URL }} JWT_SECRET={{ JWT_SECRET }} ``` +Production Kubernetes synchronizes AWS Secrets Manager versions through +External Secrets Operator; rotation, IAM scope, CloudTrail auditing, and +rollout steps are documented in [the secret-management runbook](docs/runbooks/secrets.md). + **Feature flags** toggle features per environment with `FEATURE_*` variables and are read in code via `isFeatureEnabled('')` from `src/config`: diff --git a/docker-compose.yml b/docker-compose.yml index 7539d4f..a0a2b55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: dorisio-postgres environment: POSTGRES_USER: dorisio - POSTGRES_PASSWORD: dorisio_dev_password + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your untracked .env file} POSTGRES_DB: dorisio ports: - '5432:5432' @@ -38,11 +38,11 @@ services: container_name: dorisio-backend environment: NODE_ENV: development - DATABASE_URL: postgresql://dorisio:dorisio_dev_password@postgres:5432/dorisio + DATABASE_URL: postgresql://dorisio:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your untracked .env file}@postgres:5432/dorisio REDIS_URL: redis://redis:6379 PORT: 3000 LOG_LEVEL: debug - JWT_SECRET: dev-secret-key-change-in-production + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in your untracked .env file} STELLAR_NETWORK: testnet STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org STELLAR_SERVER_SECRET_KEY: ${STELLAR_SERVER_SECRET_KEY} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bf20605..15669e6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -34,8 +34,9 @@ JWT_SECRET={{ JWT_SECRET }} # resolved from the process environment DATABASE_URL={{ DATABASE_URL }} # or an injected secrets provider ``` -- Resolution order: process environment → optional `secretsProvider` map - (integration point for Vault / AWS Secrets Manager / SOPS). +- Resolution order: process environment → optional `secretsProvider` map. + Production Kubernetes uses External Secrets Operator with AWS Secrets + Manager; see the [secret rotation runbook](runbooks/secrets.md). - An unresolved reference is a **startup failure**, never a literal `{{ ... }}` string reaching the application. - Secret values are redacted as `[REDACTED]` in the config audit log and in diff --git a/docs/runbooks/secrets.md b/docs/runbooks/secrets.md new file mode 100644 index 0000000..1816bde --- /dev/null +++ b/docs/runbooks/secrets.md @@ -0,0 +1,63 @@ +# Secret management and rotation + +Production Kubernetes secrets are synchronized from AWS Secrets Manager by +External Secrets Operator (ESO). The checked-in +[`ExternalSecret`](../../infra/kubernetes/external-secret.aws.yaml) maps the +JSON properties in `dorisio/production/backend` to the existing +`dorisio-backend-secrets` Kubernetes Secret consumed by the Deployment. The +[`ClusterSecretStore`](../../infra/kubernetes/secret-store.aws.yaml) uses +AWS web-identity credentials; no AWS access key or secret value belongs in +this repository. + +## One-time platform setup + +1. Install ESO and configure its `external-secrets` service account with an + IRSA/web-identity role. Restrict `secretsmanager:GetSecretValue` and + `secretsmanager:DescribeSecret` to the `dorisio/production/backend-*` + secret ARN; grant `kms:Decrypt` only for the customer-managed key used by + that secret, if applicable. +2. Create the JSON secret `dorisio/production/backend` in AWS Secrets Manager + with `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, and the optional Stellar + and Stripe keys used by the deployment. +3. Enable CloudTrail management/data-event auditing for Secrets Manager, + retain the events in the organization’s protected audit account, and alert + on denied reads, unexpected principals, and secret-version changes. +4. Apply the Kubernetes manifests and verify + `kubectl -n dorisio get externalsecret dorisio-backend-secrets` reports + `Ready=True`. Confirm the generated Secret contains the expected key names + without printing or decoding any values. + +The sample store currently uses `us-east-1`; set the region and remote secret +path for each environment before applying. Use a separate secret and IAM role +per environment. The legacy `secret.example.yaml` is a local/manual template +only and is deliberately not included in the Kustomize production resources. + +## Rotation procedure + +1. Create a new AWS Secrets Manager version. For database/Redis credentials, + provision the new credential while the old credential remains valid. +2. Move the staging secret to the new version and verify ESO synchronization, + readiness, database/cache connectivity, and error-rate/latency dashboards. +3. Promote the new version in production. ESO polls hourly; force a sync with + `kubectl annotate es dorisio-backend-secrets -n dorisio force-sync=$(date +%s) --overwrite` + when the change must be picked up immediately. +4. Restart the backend rollout after synchronization. The application pins + connection credentials and JWT configuration at process startup; a restart + is required for those values to take effect. +5. Verify the rollout and authentication/payment health before revoking the + old credential. Keep the previous secret version available for rollback + until the observation window closes. + +JWT key rotation invalidates tokens signed only by the old key. Coordinate +the rollout with clients or use the application’s supported token lifetime +and re-authentication policy. Record the change ticket, secret version IDs +(never values), approver, rollout revision, and rollback decision. ESO and +CloudTrail logs must not include secret payloads; application configuration +audit output redacts keys classified as secrets. + +## Incident response + +If a secret may have leaked, create a replacement version immediately, verify +that ESO synced it, roll the workload, revoke the exposed credential, and +review CloudTrail access events. Follow [incident response](../INCIDENT_RESPONSE.md) +and do not paste secret values into tickets, logs, or chat. diff --git a/infra/kubernetes/external-secret.aws.yaml b/infra/kubernetes/external-secret.aws.yaml new file mode 100644 index 0000000..66d58aa --- /dev/null +++ b/infra/kubernetes/external-secret.aws.yaml @@ -0,0 +1,41 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: dorisio-backend-secrets + namespace: dorisio +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: dorisio-aws-secrets + target: + name: dorisio-backend-secrets + creationPolicy: Owner + data: + - secretKey: DATABASE_URL + remoteRef: + key: dorisio/production/backend + property: DATABASE_URL + - secretKey: REDIS_URL + remoteRef: + key: dorisio/production/backend + property: REDIS_URL + - secretKey: JWT_SECRET + remoteRef: + key: dorisio/production/backend + property: JWT_SECRET + - secretKey: STELLAR_SERVER_SECRET_KEY + remoteRef: + key: dorisio/production/backend + property: STELLAR_SERVER_SECRET_KEY + optional: true + - secretKey: STRIPE_SECRET_KEY + remoteRef: + key: dorisio/production/backend + property: STRIPE_SECRET_KEY + optional: true + - secretKey: STRIPE_WEBHOOK_SECRET + remoteRef: + key: dorisio/production/backend + property: STRIPE_WEBHOOK_SECRET + optional: true diff --git a/infra/kubernetes/kustomization.yaml b/infra/kubernetes/kustomization.yaml index ceeddbf..cc86cb5 100644 --- a/infra/kubernetes/kustomization.yaml +++ b/infra/kubernetes/kustomization.yaml @@ -4,7 +4,8 @@ namespace: dorisio resources: - namespace.yaml - configmap.yaml - - secret.example.yaml + - secret-store.aws.yaml + - external-secret.aws.yaml - service.yaml - rollout.yaml - hpa.yaml diff --git a/infra/kubernetes/secret-store.aws.yaml b/infra/kubernetes/secret-store.aws.yaml new file mode 100644 index 0000000..9559aad --- /dev/null +++ b/infra/kubernetes/secret-store.aws.yaml @@ -0,0 +1,14 @@ +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: dorisio-aws-secrets +spec: + provider: + aws: + service: SecretsManager + region: us-east-1 + auth: + jwt: + serviceAccountRef: + name: external-secrets + namespace: external-secrets diff --git a/src/config/env.ts b/src/config/env.ts index 9b559ee..17b06fd 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,196 +1,18 @@ /** - * Backwards-compatible configuration entry point. + * Back-compat shim for the pre-centralized configuration module (issue #60). * - * The canonical schema and layered loader live in `schema.ts` and `loader.ts`. - * Older modules import from `config/env`, so this module intentionally re-exports - * the same validated snapshot and the small compatibility helper used by the - * security plugin. + * Keep existing `import { config } from '../config/env'` call sites working, + * but route all validation, secret resolution, and auditing through the single + * validated loader. Do not duplicate or bypass its schema here. */ -import dotenv from 'dotenv'; -import { z } from 'zod'; import { getConfig } from './loader'; -dotenv.config(); - -const EnvSchema = z.object({ - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - PORT: z.string().transform(Number).default('3000'), - LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), - // Max time (ms) to wait for in-flight requests to drain and resources to - // close on SIGTERM/SIGINT before forcing exit (#23). - SHUTDOWN_TIMEOUT_MS: z.string().transform(Number).default('30000'), - DATABASE_URL: z.string().optional(), - DB_POOL_MIN: z.string().transform(Number).default('2'), - DB_POOL_MAX: z.string().transform(Number).default('20'), - DB_CONNECTION_TIMEOUT_MS: z.string().transform(Number).default('5000'), - DB_IDLE_TIMEOUT_MS: z.string().transform(Number).default('30000'), - DB_MAX_LIFETIME_MS: z.string().transform(Number).default('1800000'), - DB_STATEMENT_TIMEOUT_MS: z.string().transform(Number).default('10000'), - DB_SLOW_QUERY_THRESHOLD_MS: z.string().transform(Number).default('200'), - DB_LOG_QUERIES: z.string().transform((val) => val === 'true').default('false'), - // Read-query result cache (1-5 minutes, per issue #12) - DB_QUERY_CACHE_TTL_MS: z.string().transform(Number).default('60000'), - DB_QUERY_CACHE_MAX_TTL_MS: z.string().transform(Number).default('300000'), - DB_QUERY_CACHE_MAX_ENTRIES: z.string().transform(Number).default('1000'), - DB_QUERY_CACHE_ENABLED: z.string().transform((val) => val !== 'false').default('true'), - DB_LEAK_DETECTION_TIMEOUT_MS: z.string().transform(Number).default('30000'), - DB_CIRCUIT_BREAKER_FAILURES: z.string().transform(Number).default('5'), - DB_CIRCUIT_BREAKER_RESET_MS: z.string().transform(Number).default('10000'), - // External services circuit breaker (Stellar Horizon, webhooks, etc.) - CIRCUIT_BREAKER_FAILURE_THRESHOLD: z.string().transform(Number).default('0.5'), - CIRCUIT_BREAKER_SUCCESS_THRESHOLD: z.string().transform(Number).default('2'), - CIRCUIT_BREAKER_TIMEOUT_MS: z.string().transform(Number).default('60000'), - CIRCUIT_BREAKER_RESET_TIMEOUT_MS: z.string().transform(Number).default('30000'), - CIRCUIT_BREAKER_MIN_REQUESTS: z.string().transform(Number).default('5'), - CIRCUIT_BREAKER_ROLLING_WINDOW_MS: z.string().transform(Number).default('60000'), - CIRCUIT_BREAKER_VOLUME_THRESHOLD: z.string().transform(Number).default('5'), - STELLAR_CIRCUIT_BREAKER_ENABLED: z - .string() - .transform((val) => val === 'true') - .default('true'), - WEBHOOK_CIRCUIT_BREAKER_ENABLED: z - .string() - .transform((val) => val === 'true') - .default('true'), - REDIS_URL: z.string().default('redis://localhost:6379'), - REDIS_HOST: z.string().optional(), - REDIS_PORT: z.string().transform(Number).optional(), - REDIS_POOL_MIN: z.string().transform(Number).default('5'), - REDIS_POOL_MAX: z.string().transform(Number).default('20'), - REDIS_POOL_IDLE_TIMEOUT_MS: z.string().transform(Number).default(String(5 * 60 * 1000)), - REDIS_CONNECTION_TIMEOUT_MS: z.string().transform(Number).default(String(30 * 1000)), - REDIS_HEALTHCHECK_INTERVAL_MS: z.string().transform(Number).default(String(60 * 1000)), - CACHE_FALLBACK_MEMORY_SIZE: z.string().transform(Number).default('1000'), - // Background job processing - JOBS_WORKERS_ENABLED: z - .string() - .transform((val) => val === 'true') - .default('false'), - JOBS_CONCURRENCY: z.string().transform(Number).default('5'), - JOB_DEFAULT_ATTEMPTS: z.string().transform(Number).default('3'), - JOB_BACKOFF_MS: z.string().transform(Number).default('1000'), - JWT_SECRET: z.string().default('your-secret-key-change-in-production'), - JWT_EXPIRES_IN: z.string().default('15m'), - JWT_REFRESH_EXPIRES_IN: z.string().default('7d'), - // Error tracking (Sentry compatible) - SENTRY_DSN: z.string().optional(), - ERROR_TRACKING_ENABLED: z - .string() - .transform((val) => val !== 'false') - .default('true'), - ERROR_TRACKING_SAMPLE_RATE: z - .string() - .transform(Number) - .refine((val) => !Number.isNaN(val) && val >= 0 && val <= 1, { - message: 'ERROR_TRACKING_SAMPLE_RATE must be between 0 and 1', - }) - .default('1'), - ERROR_TRACKING_TIMEOUT_MS: z.string().transform(Number).default('5000'), - STELLAR_NETWORK: z.enum(['testnet', 'mainnet', 'standalone']).default('testnet'), - STELLAR_HORIZON_URL: z.string().default('https://horizon-testnet.stellar.org'), - STELLAR_HORIZON_TIMEOUT_MS: z.string().transform(Number).default('60000'), - STELLAR_SERVER_SECRET_KEY: z.string().optional(), - USDC_CONTRACT_ID: z.string().optional(), - USDC_ISSUER: z.string().optional(), - // External payment processing - PAYMENTS_PROVIDER: z.enum(['stripe', 'none']).default('none'), - STRIPE_SECRET_KEY: z.string().optional(), - STRIPE_WEBHOOK_SECRET: z.string().optional(), - STRIPE_API_BASE: z.string().default('https://api.stripe.com'), - PAYMENTS_WEBHOOK_TOLERANCE_SECONDS: z.string().transform(Number).default('300'), - WALLET_NONCE_EXPIRY: z.string().transform(Number).default('600'), - MIN_PAYOUT_AMOUNT: z.string().transform(Number).default('50'), - FRONTEND_URL: z.string().default('http://localhost:3000'), - SENDGRID_API_KEY: z.string().optional(), - EMAIL_FROM: z.string().email().optional(), - // Reverse proxy trust (see docs/RATE_LIMITING.md). Controls how request.ip - // is derived from X-Forwarded-For. `false` (default) ignores forwarded - // headers; a number trusts that many proxy hops; otherwise a comma - // separated list of trusted proxy IPs/CIDRs. - TRUST_PROXY: z.string().default('false'), - // API rate limiting (see src/config/rate-limit.ts for policies) - RATE_LIMIT_ENABLED: z - .string() - .transform((val) => val !== 'false') - .default('true'), - RATE_LIMIT_STORE: z.enum(['memory', 'redis']).default('memory'), - RATE_LIMIT_PUBLIC_MAX: z.string().transform(Number).default('100'), - RATE_LIMIT_PUBLIC_WINDOW_MS: z.string().transform(Number).default('60000'), - RATE_LIMIT_AUTHENTICATED_MAX: z.string().transform(Number).default('300'), - RATE_LIMIT_AUTHENTICATED_WINDOW_MS: z.string().transform(Number).default('60000'), - RATE_LIMIT_SENSITIVE_MAX: z.string().transform(Number).default('10'), - RATE_LIMIT_SENSITIVE_WINDOW_MS: z.string().transform(Number).default('60000'), - // Tip media uploads (#64). `MEDIA_STORAGE=local` keeps files on disk and - // `MEDIA_SCANNER=eicar` is the development scanner; production is expected to - // run `MEDIA_STORAGE=s3` with a clamd instance behind CLAMAV_HOST. - MEDIA_STORAGE: z.enum(['s3', 'local']).default('local'), - MEDIA_LOCAL_ROOT: z.string().default('var/media'), - MEDIA_STORAGE_TIMEOUT_MS: z.string().transform(Number).default('15000'), - MEDIA_S3_BUCKET: z.string().optional(), - MEDIA_S3_REGION: z.string().default('us-east-1'), - MEDIA_S3_ACCESS_KEY_ID: z.string().optional(), - MEDIA_S3_SECRET_ACCESS_KEY: z.string().optional(), - MEDIA_S3_SESSION_TOKEN: z.string().optional(), - MEDIA_S3_ENDPOINT: z.string().optional(), - MEDIA_S3_FORCE_PATH_STYLE: z - .string() - .transform((val) => val === 'true') - .default('false'), - MEDIA_CDN_BASE_URL: z.string().optional(), - // direct = presigned upload straight to storage; proxy = bytes through the API. - MEDIA_UPLOAD_MODE: z.enum(['direct', 'proxy']).default('direct'), - MEDIA_UPLOAD_TTL_SECONDS: z.string().transform(Number).default(String(15 * 60)), - MEDIA_MAX_IMAGE_BYTES: z.string().transform(Number).default(String(8 * 1024 * 1024)), - MEDIA_MAX_VIDEO_BYTES: z.string().transform(Number).default(String(64 * 1024 * 1024)), - MEDIA_DEFAULT_QUOTA_BYTES: z.string().transform(Number).default(String(256 * 1024 * 1024)), - MEDIA_SCANNER: z - .enum(['clamav', 'eicar', 'none']) - .default(process.env.NODE_ENV === 'production' ? 'clamav' : 'eicar'), - CLAMAV_HOST: z.string().optional(), - CLAMAV_PORT: z.string().transform(Number).default('3310'), - CLAMAV_TIMEOUT_MS: z.string().transform(Number).default('15000'), - // sharp is optional; ffmpeg/ffprobe are probed at runtime. - MEDIA_PROCESSOR: z.enum(['auto', 'sharp', 'ffmpeg', 'none']).default('auto'), - MEDIA_TRANSCODE_VIDEO: z - .string() - .transform((val) => val === 'true') - .default('false'), - MEDIA_FFMPEG_PATH: z.string().default('ffmpeg'), - MEDIA_FFPROBE_PATH: z.string().default('ffprobe'), -}); - -type Environment = z.infer; - -const validateEnv = (): Environment => { - const env = EnvSchema.safeParse(process.env); - - if (!env.success) { - console.error('Invalid environment variables:', env.error.format()); - process.exit(1); - } - - return env.data; -}; - -export const config = validateEnv(); - -/** - * Convert the comma-separated CORS allowlist into the shape Fastify expects. - * An omitted or wildcard value intentionally permits any origin. - */ -export function getCorsOrigins(): true | string[] { - const raw = config.CORS_ORIGINS?.trim(); - if (!raw || raw === '*') return true; - - const origins = raw - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); +export const config = getConfig(); /** Resolve CORS origins from the centralized configuration. */ export function getCorsOrigins(): string[] | true { - const raw = config.CORS_ORIGINS; - if (!raw) return config.NODE_ENV === 'development' || config.NODE_ENV === 'test' ? true : []; + const raw = config.CORS_ORIGINS?.trim(); + if (!raw || raw === '*') return true; const origins = raw.split(',').map((origin) => origin.trim()).filter(Boolean); return origins.length > 0 ? origins : true; }