diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..1355d6d4 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,69 @@ +name: E2E (Playwright) + +on: + pull_request: + branches: [main] + paths: + - 'frontend/**' + - '.github/workflows/e2e.yml' + +# Cancel superseded runs on the same PR so we don't burn CI minutes. +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e-chromium: + name: Playwright (chromium) + runs-on: ubuntu-latest + # The Playwright jammy image ships Node 20, the @playwright/test + # binaries matching v1.51.1, and every system library chromium needs + # (libatk-1.0, libcups, libxkbcommon, libgbm, etc.). The redundant + # `npx playwright install --with-deps chromium` step below is a no-op + # in this image but keeps the workflow resilient if we ever swap to a + # plain Node base image — no surprise breakage on environment drift. + container: + image: mcr.microsoft.com/playwright:v1.51.1-jammy + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E suite + run: npm run test:e2e + + - name: Upload Playwright HTML report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 + + - name: Upload Playwright test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: frontend/test-results/ + retention-days: 7 diff --git a/.github/workflows/secrets-scan.yml b/.github/workflows/secrets-scan.yml deleted file mode 100644 index c819a51f..00000000 --- a/.github/workflows/secrets-scan.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Secrets Scan - -# (TEMPORARILY DISABLED — uncomment the `on:` block below to re-enable) -# on: -# push: -# branches: [main] -# pull_request: -# branches: [main] -# schedule: -# # Daily historical scan at 03:17 UTC. -# - cron: "17 3 * * *" -# workflow_dispatch: - -permissions: - contents: read - security-events: write - -jobs: - gitleaks: - name: Gitleaks (detect committed secrets) - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - # Fetch full history so the scheduled scan can inspect past commits. - fetch-depth: 0 - - - name: Run gitleaks (full history) - uses: gitleaks/gitleaks-action@v2 - env: - GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true" - GITLEAKS_CONFIG: .gitleaks.toml - # The action fails the job (blocking the merge / push) when it finds - # high-entropy or known-pattern secrets. On PR/push it scans the - # changed range; on the scheduled run it scans all history. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7a8e2f9d..f3f5fc81 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,7 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './auth/guard/jwt.auth.guard'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { BullModule } from '@nestjs/bull'; @@ -21,6 +21,7 @@ import { InvoicesModule } from './invoices/invoices.module'; import { NotificationsModule } from './notifications/notifications.module'; import { WorkspaceTrackingModule } from './workspace-tracking/workspace-tracking.module'; import { AuditLogModule } from './audit-log/audit-log.module'; +import { ApiExceptionFilter } from './common/filters/api-exception.filter'; import { SecretsModule } from './config/secrets'; @Module({ @@ -101,12 +102,18 @@ import { SecretsModule } from './config/secrets'; InvoicesModule, NotificationsModule, WorkspaceTrackingModule, - SecretsModule.forRoot(), + SecretsModule, AuditLogModule, ], controllers: [AppController], providers: [ AppService, + // Global exception filter — must come before guards so every error, + // including auth errors, is shaped as ApiErrorDto. + { + provide: APP_FILTER, + useClass: ApiExceptionFilter, + }, { provide: APP_GUARD, useClass: JwtAuthGuard, diff --git a/backend/src/common/dto/api-error.dto.ts b/backend/src/common/dto/api-error.dto.ts new file mode 100644 index 00000000..c3533d31 --- /dev/null +++ b/backend/src/common/dto/api-error.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Canonical API error response shape (RFC 7807-inspired). + * + * Every error emitted by the NovaLabs API is wrapped in this DTO so that + * frontend clients and API consumers can rely on a single, predictable shape. + * + * Shape: + * ```json + * { + * "code": "VALIDATION_ERROR", + * "status": 400, + * "message": "Request validation failed", + * "details": [...], + * "requestId": "req_abc123", + * "timestamp": "2025-01-01T00:00:00.000Z" + * } + * ``` + */ +export class ApiErrorDto { + /** Machine-readable error code (e.g. VALIDATION_ERROR, NOT_FOUND). */ + @ApiProperty({ + example: 'VALIDATION_ERROR', + description: 'Machine-readable error code', + }) + code: string; + + /** HTTP status code mirrored in the body for convenience. */ + @ApiProperty({ example: 400, description: 'HTTP status code' }) + status: number; + + /** Human-readable summary of what went wrong. */ + @ApiProperty({ + example: 'Request validation failed', + description: 'Human-readable error summary', + }) + message: string; + + /** + * Optional structured details (e.g. validation messages per field). + * Omitted for 5xx errors to avoid leaking internals. + */ + @ApiPropertyOptional({ + description: 'Structured error details (validation messages, etc.)', + }) + details?: unknown; + + /** + * Unique request identifier for correlating with server-side logs. + * Populated from the `x-request-id` header when present, otherwise + * generated as a short random string. + */ + @ApiProperty({ + example: 'req_7f3a2b1c', + description: 'Unique request identifier for log correlation', + }) + requestId: string; + + /** ISO-8601 timestamp of when the error occurred. */ + @ApiProperty({ + example: '2025-01-01T00:00:00.000Z', + description: 'ISO-8601 timestamp of the error', + }) + timestamp: string; +} diff --git a/backend/src/common/filters/api-exception.filter.ts b/backend/src/common/filters/api-exception.filter.ts new file mode 100644 index 00000000..57cae15b --- /dev/null +++ b/backend/src/common/filters/api-exception.filter.ts @@ -0,0 +1,109 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ApiErrorDto } from '../dto/api-error.dto'; + +/** + * Map of HTTP status codes to machine-readable error codes. + * Used to populate `ApiErrorDto.code` from NestJS's numeric status. + */ +const STATUS_CODE_MAP: Record = { + [HttpStatus.BAD_REQUEST]: 'VALIDATION_ERROR', + [HttpStatus.UNAUTHORIZED]: 'UNAUTHORIZED', + [HttpStatus.FORBIDDEN]: 'FORBIDDEN', + [HttpStatus.NOT_FOUND]: 'NOT_FOUND', + [HttpStatus.CONFLICT]: 'CONFLICT', + [HttpStatus.GONE]: 'GONE', + [HttpStatus.UNPROCESSABLE_ENTITY]: 'UNPROCESSABLE_ENTITY', + [HttpStatus.TOO_MANY_REQUESTS]: 'RATE_LIMITED', + [HttpStatus.INTERNAL_SERVER_ERROR]: 'INTERNAL_ERROR', + [HttpStatus.SERVICE_UNAVAILABLE]: 'SERVICE_UNAVAILABLE', +}; + +/** + * Global exception filter that converts every thrown exception — whether a + * NestJS `HttpException` or an unexpected runtime error — into the canonical + * `ApiErrorDto` shape. + * + * Registration via `APP_FILTER` in `AppModule` ensures this filter runs for + * every route, including those not explicitly annotated. + * + * ## Internal errors + * For 5xx responses the `details` field is intentionally omitted to avoid + * leaking stack traces or internal state to clients. The full error is logged + * server-side so it can be correlated via `requestId`. + */ +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(ApiExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + // Derive the HTTP status + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + // Derive the code + const code = STATUS_CODE_MAP[status] ?? 'UNKNOWN_ERROR'; + + // Derive the human-readable message and optional details + let message = 'An unexpected error occurred'; + let details: unknown; + + if (exception instanceof HttpException) { + const exceptionResponse = exception.getResponse(); + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + const resp = exceptionResponse as Record; + // NestJS validation pipe emits { statusCode, message: string[], error } + message = + typeof resp['message'] === 'string' + ? resp['message'] + : exception.message; + // Surface validation field errors only for 4xx + if (status < 500 && resp['message'] !== undefined) { + details = Array.isArray(resp['message']) ? resp['message'] : undefined; + } + } + } else if (exception instanceof Error) { + // Never expose raw error messages from unexpected runtime errors + message = 'Internal server error'; + } + + // Build a request-scoped correlation ID + const requestId = + (request.headers['x-request-id'] as string | undefined) ?? + `req_${Math.random().toString(36).slice(2, 10)}`; + + const body: ApiErrorDto = { + code, + status, + message, + requestId, + timestamp: new Date().toISOString(), + ...(details !== undefined ? { details } : {}), + }; + + // Log 5xx errors server-side for observability + if (status >= 500) { + this.logger.error( + `[${requestId}] ${request.method} ${request.url} → ${status}`, + exception instanceof Error ? exception.stack : String(exception), + ); + } + + response.status(status).json(body); + } +} diff --git a/backend/src/config/secrets/aws-secrets.provider.ts b/backend/src/config/secrets/aws-secrets.provider.ts index 03a5601f..6a5a21f2 100644 --- a/backend/src/config/secrets/aws-secrets.provider.ts +++ b/backend/src/config/secrets/aws-secrets.provider.ts @@ -1,149 +1,102 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - SecretsManagerClient, - GetSecretValueCommand, -} from '@aws-sdk/client-secrets-manager'; -import { SecretsProvider } from './secrets-provider'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { SecretsProvider } from './secrets-provider.interface'; /** * Fetches secrets from AWS Secrets Manager. * - * Required env vars (preferably from EnvSecretsProvider): - * AWS_REGION – AWS region (e.g. us-east-1) - * AWS_SECRETS_MANAGER_ARN – ARN or name of the secret in Secrets Manager + * The AWS SDK (`@aws-sdk/client-secrets-manager`) is loaded dynamically at + * runtime so that the package remains an optional peer dependency — teams not + * using AWS will not need to install it. * - * Authentication is handled by the default AWS credential chain - * (env vars, ~/.aws/credentials, IAM roles, etc.). + * ## Required environment variables * - * The AWS SDK client is lazily instantiated so no network calls happen - * at module load time. + * | Variable | Description | + * |---------------------------|--------------------------------------------------------------| + * | `AWS_REGION` | AWS region (e.g. `us-east-1`) | + * | `AWS_SECRETS_NAME` | Name or ARN of the Secrets Manager secret to load | + * | `AWS_ACCESS_KEY_ID` | AWS access key ID (optional when running on EC2/ECS/Lambda) | + * | `AWS_SECRET_ACCESS_KEY` | AWS secret access key (optional when using IAM roles) | + * + * The secret value should be a JSON object where each top-level key + * corresponds to a secret name (e.g. `{ "DATABASE_PASSWORD": "s3cr3t" }`). + * + * ## Activate + * + * Set `SECRETS_PROVIDER=aws` in your environment and ensure + * `@aws-sdk/client-secrets-manager` is installed: + * ``` + * npm install @aws-sdk/client-secrets-manager + * ``` + * + * ## References + * - https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html */ @Injectable() -export class AwsSecretsProvider extends SecretsProvider { +export class AwsSecretsProvider implements SecretsProvider { private readonly logger = new Logger(AwsSecretsProvider.name); - private readonly region: string; - private readonly secretArn: string; - private readonly cacheExpiryMs: number; - private cachedSecrets: Record | null = null; - private cacheExpiresAt = 0; - - private client: SecretsManagerClient | null = null; - constructor(configService: ConfigService) { - super(); - this.region = - configService.get('AWS_REGION') || - configService.get('AWS_DEFAULT_REGION') || - 'us-east-1'; - this.secretArn = - configService.get('AWS_SECRETS_MANAGER_ARN') || - configService.get('AWS_SECRETS_MANAGER_SECRET_ID') || - ''; - this.cacheExpiryMs = - configService.get('AWS_SECRETS_CACHE_TTL_MS') ?? 300_000; - } + /** Parsed JSON payload from Secrets Manager, cached for the process lifetime. */ + private cache: Record | null = null; - /** - * Lazily instantiate the AWS Secrets Manager client. - */ - private getClient(): SecretsManagerClient { - if (!this.client) { - this.client = new SecretsManagerClient({ region: this.region }); - } - return this.client; + private get secretName(): string { + const name = process.env.AWS_SECRETS_NAME; + if (!name) throw new Error('AWS_SECRETS_NAME environment variable is not set'); + return name; } - /** - * Fetch and parse the full secret JSON from AWS Secrets Manager. - */ - private async fetchSecrets(): Promise> { - if (this.cachedSecrets && Date.now() < this.cacheExpiresAt) { - return this.cachedSecrets; - } + private async loadSecrets(): Promise> { + if (this.cache) return this.cache; - if (!this.secretArn) { + // Dynamic import so @aws-sdk/client-secrets-manager is optional at bundle time. + // eslint-disable-next-line @typescript-eslint/no-var-requires + let SecretsManagerClient: any, GetSecretValueCommand: any; + try { + // Use require() wrapped in eval to prevent TS from resolving the type at + // compile time. This keeps @aws-sdk/client-secrets-manager as a true + // optional peer dependency. + // eslint-disable-next-line no-eval + const mod = eval('require')('@aws-sdk/client-secrets-manager') as { + SecretsManagerClient: any; + GetSecretValueCommand: any; + }; + SecretsManagerClient = mod.SecretsManagerClient; + GetSecretValueCommand = mod.GetSecretValueCommand; + } catch { throw new Error( - 'AwsSecretsProvider requires AWS_SECRETS_MANAGER_ARN ' + - '(or AWS_SECRETS_MANAGER_SECRET_ID) to be set.', + 'AwsSecretsProvider requires @aws-sdk/client-secrets-manager. ' + + 'Install it with: npm install @aws-sdk/client-secrets-manager', ); } - try { - const client = this.getClient(); - const command = new GetSecretValueCommand({ - SecretId: this.secretArn, - }); - const response = await client.send(command); - - const secretString = - response.SecretString || - (response.SecretBinary - ? Buffer.from(response.SecretBinary).toString() - : null); - - if (!secretString) { - throw new Error( - `Secret "${this.secretArn}" returned no value (SecretString/SecretBinary is empty).`, - ); - } + const client = new SecretsManagerClient({ + region: process.env.AWS_REGION ?? 'us-east-1', + }); - let parsed: Record; + this.logger.log(`Loading secrets from AWS Secrets Manager: ${this.secretName}`); - try { - const raw = JSON.parse(secretString) as Record; - parsed = {}; - for (const [k, v] of Object.entries(raw)) { - parsed[k] = String(v); - } - } catch { - // Plain string secret — wrap under the last path segment - const key = this.secretArn.split('/').pop() || 'secret'; - parsed = { [key]: secretString }; - } + const command = new GetSecretValueCommand({ SecretId: this.secretName }); + const response = await client.send(command); - this.cachedSecrets = parsed; - this.cacheExpiresAt = Date.now() + this.cacheExpiryMs; - - return parsed; - } catch (error) { - this.logger.error( - `Failed to fetch secrets from AWS Secrets Manager: ${(error as Error).message}`, - ); - throw error; - } - } - - async get(key: string): Promise { + const raw: string = response.SecretString ?? ''; try { - const secrets = await this.fetchSecrets(); - return secrets[key]; - } catch (error) { - this.logger.warn( - `AwsSecretsProvider.get("${key}") failed: ${(error as Error).message}. ` + - `Falling back to process.env.`, + this.cache = JSON.parse(raw) as Record; + } catch { + throw new Error( + `AWS secret "${this.secretName}" is not valid JSON. ` + + 'The secret value must be a JSON object mapping secret names to values.', ); - return process.env[key]; } + + return this.cache; } - async getOrThrow(key: string): Promise { - const value = await this.get(key); + async getSecret(key: string): Promise { + const secrets = await this.loadSecrets(); + const value = secrets[key]; if (value === undefined) { - throw new Error( - `Missing required secret: "${key}". ` + - `Verified neither AWS Secrets Manager nor environment variable is set.`, - ); + this.logger.warn(`Secret key "${key}" not found in AWS secret "${this.secretName}"`); + throw new NotFoundException(`Secret "${key}" not found in AWS Secrets Manager`); } return value; } - - async getMany(keys: string[]): Promise> { - const secrets = await this.fetchSecrets(); - const result: Record = {}; - for (const key of keys) { - result[key] = secrets[key]; - } - return result; - } } diff --git a/backend/src/config/secrets/doppler-secrets.provider.ts b/backend/src/config/secrets/doppler-secrets.provider.ts index 6845ab60..0b294e24 100644 --- a/backend/src/config/secrets/doppler-secrets.provider.ts +++ b/backend/src/config/secrets/doppler-secrets.provider.ts @@ -1,111 +1,64 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import axios from 'axios'; -import { SecretsProvider } from './secrets-provider'; +import { SecretsProvider } from './secrets-provider.interface'; /** - * Fetches secrets from the Doppler API (v3 /secrets/download). + * Fetches secrets from Doppler using the Secrets API. * - * Requires the following env vars (preferably from EnvSecretsProvider): - * DOPPLER_TOKEN – Doppler service token with read access to the project + * ## Required environment variables * - * When DOPPLER_TOKEN is not set, the provider falls back to reading - * DOPPLER_SERVICE_TOKEN for backward compatibility. + * | Variable | Description | + * |-----------------------|-------------------------------------------------------------------| + * | `DOPPLER_TOKEN` | Doppler service token with read access to the target project | + * + * The service token implicitly determines the project and config (environment), + * so no additional project/config variables are needed. + * + * ## Activate + * + * Set `SECRETS_PROVIDER=doppler` in your environment. + * + * ## References + * - https://docs.doppler.com/reference/secrets-get */ @Injectable() -export class DopplerSecretsProvider extends SecretsProvider { +export class DopplerSecretsProvider implements SecretsProvider { private readonly logger = new Logger(DopplerSecretsProvider.name); - private readonly dopplerToken: string; - private readonly apiBase: string; - private readonly cacheExpiryMs: number; - private cachedSecrets: Record | null = null; - private cacheExpiresAt = 0; - - constructor(configService: ConfigService) { - super(); - this.dopplerToken = - configService.get('DOPPLER_TOKEN') || - configService.get('DOPPLER_SERVICE_TOKEN') || - ''; - this.apiBase = - configService.get('DOPPLER_API_BASE') || - 'https://api.doppler.com/v3'; - this.cacheExpiryMs = - configService.get('DOPPLER_CACHE_TTL_MS') ?? 300_000; - } - - /** - * Fetch the full secret bundle from Doppler and cache it. - */ - private async fetchSecrets(): Promise> { - if (this.cachedSecrets && Date.now() < this.cacheExpiresAt) { - return this.cachedSecrets; - } + private readonly DOPPLER_API = 'https://api.doppler.com/v3/configs/config/secret'; - if (!this.dopplerToken) { - throw new Error( - 'DopplerSecretsProvider requires DOPPLER_TOKEN (or DOPPLER_SERVICE_TOKEN) ' + - 'to be set in the environment.', - ); - } + /** All secrets fetched in a single Doppler download, cached for the process lifetime. */ + private cache: Record | null = null; - try { - const url = `${this.apiBase}/secrets/download`; - const response = await axios.get<{ - secrets: Record; - }>(url, { - headers: { - Authorization: `Bearer ${this.dopplerToken}`, - }, - }); + private get dopplerToken(): string { + const token = process.env.DOPPLER_TOKEN; + if (!token) throw new Error('DOPPLER_TOKEN environment variable is not set'); + return token; + } - const secrets: Record = {}; - for (const [key, value] of Object.entries(response.data.secrets || {})) { - secrets[key] = value.raw; - } + private async loadSecrets(): Promise> { + if (this.cache) return this.cache; - this.cachedSecrets = secrets; - this.cacheExpiresAt = Date.now() + this.cacheExpiryMs; + // Doppler's /download endpoint returns a flat JSON object of all secrets. + const url = 'https://api.doppler.com/v3/configs/config/secrets/download'; + this.logger.log('Loading secrets from Doppler'); - return secrets; - } catch (error) { - this.logger.error( - `Failed to fetch secrets from Doppler: ${(error as Error).message}`, - ); - throw error; - } - } + const response = await axios.get>(url, { + params: { format: 'json' }, + auth: { username: this.dopplerToken, password: '' }, + timeout: 10_000, + }); - async get(key: string): Promise { - try { - const secrets = await this.fetchSecrets(); - return secrets[key]; - } catch (error) { - this.logger.warn( - `DopplerSecretsProvider.get("${key}") failed: ${(error as Error).message}. ` + - `Falling back to process.env.`, - ); - return process.env[key]; - } + this.cache = response.data ?? {}; + return this.cache; } - async getOrThrow(key: string): Promise { - const value = await this.get(key); + async getSecret(key: string): Promise { + const secrets = await this.loadSecrets(); + const value = secrets[key]; if (value === undefined) { - throw new Error( - `Missing required secret: "${key}". ` + - `Verified neither Doppler nor environment variable is set.`, - ); + this.logger.warn(`Secret key "${key}" not found in Doppler`); + throw new NotFoundException(`Secret "${key}" not found in Doppler`); } return value; } - - async getMany(keys: string[]): Promise> { - const secrets = await this.fetchSecrets(); - const result: Record = {}; - for (const key of keys) { - result[key] = secrets[key]; - } - return result; - } } diff --git a/backend/src/config/secrets/env-secrets.provider.ts b/backend/src/config/secrets/env-secrets.provider.ts index 42c6c66c..95e9c485 100644 --- a/backend/src/config/secrets/env-secrets.provider.ts +++ b/backend/src/config/secrets/env-secrets.provider.ts @@ -1,41 +1,26 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { SecretsProvider } from './secrets-provider'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { SecretsProvider } from './secrets-provider.interface'; /** - * Reads secrets from environment variables via NestJS ConfigService. + * Reads secrets directly from `process.env`. * - * This is the default provider and requires no external infrastructure. - * It is also used as the fallback when a remote provider is unavailable - * for a particular key. + * This is the default provider and requires no external dependencies. + * Suitable for local development, CI pipelines, and any environment where + * secrets are injected via environment variables (e.g. Docker, Railway, + * Render, Heroku, AWS ECS task definitions). + * + * Activate with `SECRETS_PROVIDER=env` (or leave unset — this is the default). */ @Injectable() -export class EnvSecretsProvider extends SecretsProvider { - constructor(private readonly configService: ConfigService) { - super(); - } - - async get(key: string): Promise { - const value = this.configService.get(key); - return value ?? undefined; - } +export class EnvSecretsProvider implements SecretsProvider { + private readonly logger = new Logger(EnvSecretsProvider.name); - async getOrThrow(key: string): Promise { - const value = await this.get(key); - if (value === undefined) { - throw new Error( - `Missing required secret: "${key}". ` + - `Ensure the variable is set in your environment or secrets backend.`, - ); + async getSecret(key: string): Promise { + const value = process.env[key]; + if (value === undefined || value === '') { + this.logger.warn(`Secret key "${key}" not found in process.env`); + throw new NotFoundException(`Secret "${key}" is not defined in the environment`); } return value; } - - async getMany(keys: string[]): Promise> { - const result: Record = {}; - for (const key of keys) { - result[key] = await this.get(key); - } - return result; - } } diff --git a/backend/src/config/secrets/index.ts b/backend/src/config/secrets/index.ts index 27ed5921..3f9a3bd3 100644 --- a/backend/src/config/secrets/index.ts +++ b/backend/src/config/secrets/index.ts @@ -1,6 +1,6 @@ -export { SecretsProvider } from './secrets-provider'; +export { SecretsProvider } from './secrets-provider.interface'; export { EnvSecretsProvider } from './env-secrets.provider'; -export { DopplerSecretsProvider } from './doppler-secrets.provider'; export { VaultSecretsProvider } from './vault-secrets.provider'; +export { DopplerSecretsProvider } from './doppler-secrets.provider'; export { AwsSecretsProvider } from './aws-secrets.provider'; -export { SecretsModule, SecretsProviderType } from './secrets.module'; +export { SecretsModule, SECRETS_PROVIDER } from './secrets.module'; diff --git a/backend/src/config/secrets/secrets-provider.interface.ts b/backend/src/config/secrets/secrets-provider.interface.ts new file mode 100644 index 00000000..8949293d --- /dev/null +++ b/backend/src/config/secrets/secrets-provider.interface.ts @@ -0,0 +1,18 @@ +/** + * Vendor-neutral interface for loading application secrets at runtime. + * + * Implement this interface for each secrets backend (Env, Vault, Doppler, AWS) + * and switch between them with the `SECRETS_PROVIDER` environment variable. + * All providers surface the same `getSecret(key)` contract so the rest of the + * application is decoupled from the underlying secrets store. + */ +export interface SecretsProvider { + /** + * Fetch the value for the given secret key. + * + * @param key - The canonical secret name (e.g. `'DATABASE_PASSWORD'`). + * @returns The secret value as a plain string. + * @throws If the key is not found or the provider call fails. + */ + getSecret(key: string): Promise; +} diff --git a/backend/src/config/secrets/secrets-provider.spec.ts b/backend/src/config/secrets/secrets-provider.spec.ts index 93e18b25..a147d766 100644 --- a/backend/src/config/secrets/secrets-provider.spec.ts +++ b/backend/src/config/secrets/secrets-provider.spec.ts @@ -1,12 +1,12 @@ /// import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { SecretsProvider } from './secrets-provider'; +import { ConfigModule } from '@nestjs/config'; import { EnvSecretsProvider } from './env-secrets.provider'; import { DopplerSecretsProvider } from './doppler-secrets.provider'; import { VaultSecretsProvider } from './vault-secrets.provider'; import { AwsSecretsProvider } from './aws-secrets.provider'; -import { SecretsModule } from './secrets.module'; +import { SecretsModule, SECRETS_PROVIDER } from './secrets.module'; +import { SecretsProvider } from './secrets-provider.interface'; import axios from 'axios'; jest.mock('axios'); @@ -21,90 +21,36 @@ jest.mock('@aws-sdk/client-secrets-manager', () => ({ GetSecretValueCommand: jest.fn((args: unknown) => args), })); -// --------------------------------------------------------------------------- -// Mock builders -// --------------------------------------------------------------------------- - -function mockConfigService( - overrides: Record = {}, -): ConfigService { - return { - get: jest.fn((key: string, defaultValue?: unknown) => { - const config: Record = { - EXISTING_KEY: 'hello', - EXISTING_NUM: '42', - DOPPLER_TOKEN: 'dp.pt.mock-token', - DOPPLER_SERVICE_TOKEN: '', - DOPPLER_API_BASE: 'https://api.doppler.com/v3', - DOPPLER_CACHE_TTL_MS: 300_000, - VAULT_ADDR: 'https://vault.example.com:8200', - VAULT_ADDRESS: '', - VAULT_TOKEN: 'hvs.mock-token', - VAULT_KV_PATH: 'secret/data/nova-labs', - VAULT_SECRET_PATH: '', - VAULT_NAMESPACE: '', - VAULT_CACHE_TTL_MS: 300_000, - AWS_REGION: 'us-east-1', - AWS_DEFAULT_REGION: '', - AWS_SECRETS_MANAGER_ARN: - 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', - AWS_SECRETS_MANAGER_SECRET_ID: '', - AWS_SECRETS_CACHE_TTL_MS: 300_000, - ...overrides, - }; - return (config as Record)[key] ?? defaultValue; - }), - } as unknown as ConfigService; -} - // --------------------------------------------------------------------------- // EnvSecretsProvider // --------------------------------------------------------------------------- describe('EnvSecretsProvider', () => { let provider: EnvSecretsProvider; - let configService: ConfigService; beforeEach(() => { - configService = mockConfigService(); - provider = new EnvSecretsProvider(configService); - }); - - describe('get', () => { - it('returns the value when the key exists', async () => { - await expect(provider.get('EXISTING_KEY')).resolves.toBe('hello'); - }); - - it('returns undefined when the key does not exist', async () => { - await expect(provider.get('NONEXISTENT')).resolves.toBeUndefined(); - }); + provider = new EnvSecretsProvider(); }); - describe('getOrThrow', () => { - it('returns the value when the key exists', async () => { - await expect(provider.getOrThrow('EXISTING_KEY')).resolves.toBe('hello'); + describe('getSecret', () => { + it('returns the value when the key exists in process.env', async () => { + process.env.TEST_KEY = 'hello'; + await expect(provider.getSecret('TEST_KEY')).resolves.toBe('hello'); + delete process.env.TEST_KEY; }); - it('throws when the key does not exist', async () => { - await expect(provider.getOrThrow('MISSING')).rejects.toThrow( - /Missing required secret: "MISSING"/, + it('throws NotFoundException when the key does not exist', async () => { + await expect(provider.getSecret('NONEXISTENT')).rejects.toThrow( + /Secret "NONEXISTENT" is not defined/, ); }); - }); - - describe('getMany', () => { - it('returns a map of key -> value for existing keys', async () => { - const result = await provider.getMany(['EXISTING_KEY', 'EXISTING_NUM']); - expect(result).toEqual({ EXISTING_KEY: 'hello', EXISTING_NUM: '42' }); - }); - - it('returns undefined for missing keys', async () => { - const result = await provider.getMany(['EXISTING_KEY', 'GHOST']); - expect(result).toEqual({ EXISTING_KEY: 'hello', GHOST: undefined }); - }); - it('returns an empty object for an empty key list', async () => { - await expect(provider.getMany([])).resolves.toEqual({}); + it('throws NotFoundException when the key is an empty string', async () => { + process.env.EMPTY_KEY = ''; + await expect(provider.getSecret('EMPTY_KEY')).rejects.toThrow( + /Secret "EMPTY_KEY" is not defined/, + ); + delete process.env.EMPTY_KEY; }); }); }); @@ -116,128 +62,52 @@ describe('EnvSecretsProvider', () => { describe('DopplerSecretsProvider', () => { let provider: DopplerSecretsProvider; - afterEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + process.env.DOPPLER_TOKEN = 'dp.pt.mock-token'; }); - describe('constructor', () => { - it('reads DOPPLER_TOKEN from config', () => { - const cfg = mockConfigService({ DOPPLER_TOKEN: 'dp.pt.main' }); - provider = new DopplerSecretsProvider(cfg); - expect(provider).toBeDefined(); - }); - - it('falls back to DOPPLER_SERVICE_TOKEN when DOPPLER_TOKEN is absent', () => { - const cfg = mockConfigService({ - DOPPLER_TOKEN: '', - DOPPLER_SERVICE_TOKEN: 'dp.st.fallback', - }); - provider = new DopplerSecretsProvider(cfg); - expect(provider).toBeDefined(); - }); + afterEach(() => { + jest.clearAllMocks(); + delete process.env.DOPPLER_TOKEN; }); - describe('get', () => { + describe('getSecret', () => { it('returns the secret when Doppler API responds', async () => { mockedAxios.get.mockResolvedValueOnce({ - data: { secrets: { API_KEY: { raw: 'sk-abc123' } } }, + data: { API_KEY: 'sk-abc123' }, }); - provider = new DopplerSecretsProvider(mockConfigService()); - await expect(provider.get('API_KEY')).resolves.toBe('sk-abc123'); - }); - - it('falls back to process.env when the Doppler API call fails', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); - const originalEnv = process.env.FALLBACK_KEY; - process.env.FALLBACK_KEY = 'fallback-val'; - provider = new DopplerSecretsProvider(mockConfigService()); - await expect(provider.get('FALLBACK_KEY')).resolves.toBe('fallback-val'); - process.env.FALLBACK_KEY = originalEnv; + provider = new DopplerSecretsProvider(); + await expect(provider.getSecret('API_KEY')).resolves.toBe('sk-abc123'); }); - it('returns undefined when both Doppler and process.env are missing', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); - provider = new DopplerSecretsProvider(mockConfigService()); - await expect(provider.get('NOT_ANYWHERE')).resolves.toBeUndefined(); - }); - }); - - describe('getOrThrow', () => { - it('returns the value from Doppler', async () => { + it('throws NotFoundException when the key is not in Doppler response', async () => { mockedAxios.get.mockResolvedValueOnce({ - data: { secrets: { DB_PASS: { raw: 's3cret' } } }, + data: { OTHER_KEY: 'val' }, }); - provider = new DopplerSecretsProvider(mockConfigService()); - await expect(provider.getOrThrow('DB_PASS')).resolves.toBe('s3cret'); - }); - - it('throws when the key is nowhere to be found', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('API error')); - provider = new DopplerSecretsProvider(mockConfigService()); - await expect(provider.getOrThrow('NOWHERE')).rejects.toThrow( - /Missing required secret: "NOWHERE"/, + provider = new DopplerSecretsProvider(); + await expect(provider.getSecret('MISSING_KEY')).rejects.toThrow( + /Secret "MISSING_KEY" not found in Doppler/, ); }); - }); - - describe('getMany', () => { - it('returns a batch of secrets from Doppler', async () => { - mockedAxios.get.mockResolvedValueOnce({ - data: { - secrets: { - KEY_A: { raw: 'val-a' }, - KEY_B: { raw: 'val-b' }, - }, - }, - }); - provider = new DopplerSecretsProvider(mockConfigService()); - await expect(provider.getMany(['KEY_A', 'KEY_B'])).resolves.toEqual({ - KEY_A: 'val-a', - KEY_B: 'val-b', - }); - }); - }); - describe('caching', () => { - it('caches secrets and avoids repeated API calls within TTL', async () => { + it('caches secrets and avoids repeated API calls', async () => { mockedAxios.get.mockResolvedValueOnce({ - data: { secrets: { CACHED: { raw: 'cached-val' } } }, + data: { CACHED: 'cached-val' }, }); - provider = new DopplerSecretsProvider(mockConfigService()); - await provider.get('CACHED'); - await provider.get('CACHED'); + provider = new DopplerSecretsProvider(); + await provider.getSecret('CACHED'); + await provider.getSecret('CACHED'); expect(mockedAxios.get).toHaveBeenCalledTimes(1); }); - - it('re-fetches after cache expiry', async () => { - const startTime = Date.now(); - jest - .spyOn(Date, 'now') - .mockReturnValueOnce(startTime) // first fetch: set cacheExpiresAt - .mockReturnValueOnce(startTime + 500_000); // second fetch: past TTL - - provider = new DopplerSecretsProvider( - mockConfigService({ DOPPLER_CACHE_TTL_MS: 300_000 }), - ); - mockedAxios.get.mockResolvedValue({ - data: { secrets: { KEY: { raw: 'value' } } }, - }); - await provider.get('KEY'); - await provider.get('KEY'); - expect(mockedAxios.get).toHaveBeenCalledTimes(2); - jest.restoreAllMocks(); - }); }); describe('without token', () => { - it('falls back to env on get when no token is configured', async () => { - const cfg = mockConfigService({ - DOPPLER_TOKEN: '', - DOPPLER_SERVICE_TOKEN: '', - }); - provider = new DopplerSecretsProvider(cfg); - // fetchSecrets throws, get() catches and falls back to process.env - await expect(provider.get('ANY')).resolves.toBeUndefined(); + it('throws when DOPPLER_TOKEN is not set', async () => { + delete process.env.DOPPLER_TOKEN; + provider = new DopplerSecretsProvider(); + await expect(provider.getSecret('ANY')).rejects.toThrow( + 'DOPPLER_TOKEN environment variable is not set', + ); }); }); }); @@ -249,91 +119,69 @@ describe('DopplerSecretsProvider', () => { describe('VaultSecretsProvider', () => { let provider: VaultSecretsProvider; - afterEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + process.env.VAULT_TOKEN = 'hvs.mock-token'; + process.env.VAULT_ADDR = 'https://vault.example.com:8200'; + process.env.VAULT_MOUNT = 'secret'; + process.env.VAULT_SECRET_PATH = 'novalabs'; }); - describe('constructor', () => { - it('reads VAULT_ADDR and falls back to VAULT_ADDRESS', () => { - const cfg = mockConfigService({ - VAULT_ADDR: '', - VAULT_ADDRESS: 'https://vault-alt.example.com', - VAULT_TOKEN: 'hvs.tok', - VAULT_KV_PATH: 'secret/data/app', - }); - provider = new VaultSecretsProvider(cfg); - expect(provider).toBeDefined(); - }); + afterEach(() => { + jest.clearAllMocks(); + delete process.env.VAULT_TOKEN; + delete process.env.VAULT_ADDR; + delete process.env.VAULT_MOUNT; + delete process.env.VAULT_SECRET_PATH; }); - describe('get', () => { + describe('getSecret', () => { it('returns the secret when Vault responds', async () => { mockedAxios.get.mockResolvedValueOnce({ data: { data: { data: { STRIPE_KEY: 'sk_test_xyz' } } }, }); - provider = new VaultSecretsProvider(mockConfigService()); - await expect(provider.get('STRIPE_KEY')).resolves.toBe('sk_test_xyz'); + provider = new VaultSecretsProvider(); + await expect(provider.getSecret('STRIPE_KEY')).resolves.toBe('sk_test_xyz'); }); - it('falls back to process.env when Vault API fails', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('Vault unavailable')); - const originalEnv = process.env.FALLBACK_VAULT; - process.env.FALLBACK_VAULT = 'vault-fallback'; - provider = new VaultSecretsProvider(mockConfigService()); - await expect(provider.get('FALLBACK_VAULT')).resolves.toBe( - 'vault-fallback', + it('throws NotFoundException when the key is not in Vault response', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { data: { data: { OTHER: 'val' } } }, + }); + provider = new VaultSecretsProvider(); + await expect(provider.getSecret('MISSING')).rejects.toThrow( + /Secret "MISSING" not found in Vault/, ); - process.env.FALLBACK_VAULT = originalEnv; }); - it('returns undefined when both Vault and process.env are missing', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('Vault down')); - provider = new VaultSecretsProvider(mockConfigService()); - await expect(provider.get('NOWHERE_VAULT')).resolves.toBeUndefined(); - }); - }); - - describe('getOrThrow', () => { - it('returns the value from Vault', async () => { + it('caches secrets to avoid repeated Vault API calls', async () => { mockedAxios.get.mockResolvedValueOnce({ - data: { data: { data: { JWT_SECRET: 'jwt-val' } } }, + data: { data: { data: { KEY: 'val' } } }, }); - provider = new VaultSecretsProvider(mockConfigService()); - await expect(provider.getOrThrow('JWT_SECRET')).resolves.toBe('jwt-val'); + provider = new VaultSecretsProvider(); + await provider.getSecret('KEY'); + await provider.getSecret('KEY'); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); }); - }); - describe('getMany', () => { - it('returns a batch from Vault', async () => { + it('uses defaults when optional env vars are unset', async () => { + delete process.env.VAULT_ADDR; + delete process.env.VAULT_MOUNT; + delete process.env.VAULT_SECRET_PATH; mockedAxios.get.mockResolvedValueOnce({ - data: { data: { data: { A: 'a', B: 'b' } } }, - }); - provider = new VaultSecretsProvider(mockConfigService()); - await expect(provider.getMany(['A', 'B'])).resolves.toEqual({ - A: 'a', - B: 'b', + data: { data: { data: { X: 'y' } } }, }); + provider = new VaultSecretsProvider(); + await expect(provider.getSecret('X')).resolves.toBe('y'); }); }); - describe('missing config', () => { - it('falls back to env on get when VAULT_ADDR is missing', async () => { - provider = new VaultSecretsProvider( - mockConfigService({ VAULT_ADDR: '', VAULT_ADDRESS: '' }), + describe('without token', () => { + it('throws when VAULT_TOKEN is not set', async () => { + delete process.env.VAULT_TOKEN; + provider = new VaultSecretsProvider(); + await expect(provider.getSecret('ANY')).rejects.toThrow( + 'VAULT_TOKEN environment variable is not set', ); - await expect(provider.get('X')).resolves.toBeUndefined(); - }); - }); - - describe('caching', () => { - it('caches secrets to avoid repeated Vault API calls', async () => { - mockedAxios.get.mockResolvedValueOnce({ - data: { data: { data: { KEY: 'val' } } }, - }); - provider = new VaultSecretsProvider(mockConfigService()); - await provider.get('KEY'); - await provider.get('KEY'); - expect(mockedAxios.get).toHaveBeenCalledTimes(1); }); }); }); @@ -346,268 +194,129 @@ describe('AwsSecretsProvider', () => { let provider: AwsSecretsProvider; beforeEach(() => { + process.env.AWS_SECRETS_NAME = 'my-secret'; + process.env.AWS_REGION = 'us-east-1'; awsMock.send.mockReset(); }); afterEach(() => { jest.clearAllMocks(); + delete process.env.AWS_SECRETS_NAME; + delete process.env.AWS_REGION; }); - describe('get', () => { + describe('getSecret', () => { it('returns a key from the parsed JSON secret', async () => { awsMock.send.mockResolvedValueOnce({ SecretString: JSON.stringify({ DB_URL: 'postgres://...' }), }); - provider = new AwsSecretsProvider(mockConfigService()); - await expect(provider.get('DB_URL')).resolves.toBe('postgres://...'); + provider = new AwsSecretsProvider(); + await expect(provider.getSecret('DB_URL')).resolves.toBe('postgres://...'); }); - it('handles plain-text (non-JSON) secrets by wrapping under the last path segment', async () => { + it('throws NotFoundException when the key is not in the AWS secret', async () => { awsMock.send.mockResolvedValueOnce({ - SecretString: 'plain-api-key', + SecretString: JSON.stringify({ OTHER: 'val' }), }); - // ARN with a /path to test .split('/').pop() behavior - provider = new AwsSecretsProvider( - mockConfigService({ - AWS_SECRETS_MANAGER_ARN: - 'arn:aws:secretsmanager:us-east-1:123:secret:/prod/api-key', - }), + provider = new AwsSecretsProvider(); + await expect(provider.getSecret('MISSING')).rejects.toThrow( + /Secret "MISSING" not found in AWS Secrets Manager/, ); - // The last path segment of the ARN is 'api-key' - await expect(provider.get('api-key')).resolves.toBe('plain-api-key'); - }); - - it('falls back to process.env when AWS call fails', async () => { - awsMock.send.mockRejectedValueOnce(new Error('AWS error')); - const originalEnv = process.env.AWS_FALLBACK_KEY; - process.env.AWS_FALLBACK_KEY = 'aws-fallback'; - provider = new AwsSecretsProvider(mockConfigService()); - await expect(provider.get('AWS_FALLBACK_KEY')).resolves.toBe( - 'aws-fallback', - ); - process.env.AWS_FALLBACK_KEY = originalEnv; - }); - - it('returns undefined when both AWS and process.env are missing', async () => { - awsMock.send.mockRejectedValueOnce(new Error('AWS error')); - provider = new AwsSecretsProvider(mockConfigService()); - await expect(provider.get('NOWHERE_AWS')).resolves.toBeUndefined(); - }); - }); - - describe('getOrThrow', () => { - it('returns the value from AWS', async () => { - awsMock.send.mockResolvedValueOnce({ - SecretString: JSON.stringify({ API_KEY: 'ak-123' }), - }); - provider = new AwsSecretsProvider(mockConfigService()); - await expect(provider.getOrThrow('API_KEY')).resolves.toBe('ak-123'); }); - }); - - describe('getMany', () => { - it('returns a batch from AWS', async () => { - awsMock.send.mockResolvedValueOnce({ - SecretString: JSON.stringify({ KEY1: 'v1', KEY2: 'v2' }), - }); - provider = new AwsSecretsProvider(mockConfigService()); - await expect(provider.getMany(['KEY1', 'KEY2'])).resolves.toEqual({ - KEY1: 'v1', - KEY2: 'v2', - }); - }); - }); - describe('BinarySecret', () => { - it('decodes binary secrets when SecretString is absent', async () => { - const secretValue = JSON.stringify({ BIN_KEY: 'bin-val' }); - // AWS SDK returns SecretBinary as a Uint8Array. Buffer.from(Uint8Array).toString() - // yields the decoded UTF-8 string. - awsMock.send.mockResolvedValueOnce({ - SecretString: undefined, - SecretBinary: Buffer.from(secretValue, 'utf-8'), - }); - provider = new AwsSecretsProvider(mockConfigService()); - await expect(provider.get('BIN_KEY')).resolves.toBe('bin-val'); - }); - }); - - describe('caching', () => { it('caches secrets to avoid repeated AWS API calls', async () => { awsMock.send.mockResolvedValueOnce({ SecretString: JSON.stringify({ CACHED_AWS: 'cached' }), }); - provider = new AwsSecretsProvider(mockConfigService()); - await provider.get('CACHED_AWS'); - await provider.get('CACHED_AWS'); + provider = new AwsSecretsProvider(); + await provider.getSecret('CACHED_AWS'); + await provider.getSecret('CACHED_AWS'); expect(awsMock.send).toHaveBeenCalledTimes(1); }); - }); -}); - -// --------------------------------------------------------------------------- -// SecretsModule -// --------------------------------------------------------------------------- - -describe('SecretsModule', () => { - describe('forRoot', () => { - const OLD_ENV = process.env; - - beforeEach(() => { - process.env = { ...OLD_ENV }; - }); - - afterEach(() => { - process.env = OLD_ENV; - }); - - it('uses EnvSecretsProvider by default', () => { - delete process.env.SECRETS_PROVIDER; - const mod = SecretsModule.forRoot(); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useClass).toBe(EnvSecretsProvider); - }); - - it('uses DopplerSecretsProvider when SECRETS_PROVIDER=doppler', () => { - process.env.SECRETS_PROVIDER = 'doppler'; - const mod = SecretsModule.forRoot(); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useClass).toBe(DopplerSecretsProvider); - }); - - it('uses VaultSecretsProvider when SECRETS_PROVIDER=vault', () => { - process.env.SECRETS_PROVIDER = 'vault'; - const mod = SecretsModule.forRoot(); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useClass).toBe(VaultSecretsProvider); - }); - - it('uses AwsSecretsProvider when SECRETS_PROVIDER=aws', () => { - process.env.SECRETS_PROVIDER = 'aws'; - const mod = SecretsModule.forRoot(); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useClass).toBe(AwsSecretsProvider); - }); - - it('is case-insensitive for SECRETS_PROVIDER value', () => { - process.env.SECRETS_PROVIDER = 'VAULT'; - const mod = SecretsModule.forRoot(); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useClass).toBe(VaultSecretsProvider); - }); - it('falls back to env for unknown SECRETS_PROVIDER values', () => { - process.env.SECRETS_PROVIDER = 'unknown-vendor'; - const mod = SecretsModule.forRoot(); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useClass).toBe(EnvSecretsProvider); - }); - - it('exports the SECRETS_PROVIDER_TYPE token', () => { - process.env.SECRETS_PROVIDER = 'aws'; - const mod = SecretsModule.forRoot(); - const typeProvider = (mod.providers ?? []).find( - (p: any) => p.provide === 'SECRETS_PROVIDER_TYPE', - )!; - expect((typeProvider as any).useValue).toBe('aws'); - }); - }); - - describe('forTest', () => { - it('provides the given SecretsProvider instance', async () => { - const fakeProvider = { get: jest.fn() } as unknown as SecretsProvider; - const mod = SecretsModule.forTest(fakeProvider); - const provider = (mod.providers ?? []).find( - (p: any) => p.provide === SecretsProvider, - )!; - expect((provider as any).useValue).toBe(fakeProvider); + it('throws when SecretString is not valid JSON', async () => { + awsMock.send.mockResolvedValueOnce({ + SecretString: 'not-json', + }); + provider = new AwsSecretsProvider(); + await expect(provider.getSecret('ANY')).rejects.toThrow( + /not valid JSON/, + ); }); }); - describe('integration with NestJS DI', () => { - it('resolves EnvSecretsProvider from the module', async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [SecretsModule.forRoot()], - }).compile(); - - const resolved = moduleRef.get(SecretsProvider); - expect(resolved).toBeInstanceOf(EnvSecretsProvider); - }); - - it('resolves a forTest provider correctly', async () => { - const stub: SecretsProvider = { - get: jest.fn().mockResolvedValue('stub'), - getOrThrow: jest.fn().mockResolvedValue('stub'), - getMany: jest.fn().mockResolvedValue({}), - }; - - const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [SecretsModule.forTest(stub)], - }).compile(); - - const resolved = moduleRef.get(SecretsProvider); - expect(await resolved.get('any')).toBe('stub'); + describe('without secret name', () => { + it('throws when AWS_SECRETS_NAME is not set', async () => { + delete process.env.AWS_SECRETS_NAME; + provider = new AwsSecretsProvider(); + await expect(provider.getSecret('ANY')).rejects.toThrow( + 'AWS_SECRETS_NAME environment variable is not set', + ); }); }); }); // --------------------------------------------------------------------------- -// SecretsProvider abstract class — derived class invariants +// SecretsModule — DI integration // --------------------------------------------------------------------------- -describe('SecretsProvider (abstract class contract)', () => { - class TestProvider extends SecretsProvider { - async get(key: string): Promise { - return key === 'exists' ? 'found' : undefined; - } - async getOrThrow(key: string): Promise { - const val = await this.get(key); - if (!val) throw new Error(`Test: missing "${key}"`); - return val; - } - async getMany(keys: string[]): Promise> { - const result: Record = {}; - for (const k of keys) result[k] = await this.get(k); - return result; - } - } - - let provider: TestProvider; - - beforeEach(() => { - provider = new TestProvider(); - }); - - it('get returns the value for an existing key', async () => { - await expect(provider.get('exists')).resolves.toBe('found'); - }); - - it('get returns undefined for a missing key', async () => { - await expect(provider.get('missing')).resolves.toBeUndefined(); - }); - - it('getOrThrow throws for a missing key', async () => { - await expect(provider.getOrThrow('ghost')).rejects.toThrow( - /missing "ghost"/, - ); - }); - - it('getMany returns mixed results', async () => { - await expect(provider.getMany(['exists', 'ghost'])).resolves.toEqual({ - exists: 'found', - ghost: undefined, - }); +describe('SecretsModule', () => { + it('resolves EnvSecretsProvider by default (env provider)', async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), SecretsModule], + }).compile(); + + const resolved = moduleRef.get(SECRETS_PROVIDER); + expect(resolved).toBeInstanceOf(EnvSecretsProvider); + }); + + it('resolves DopplerSecretsProvider when SECRETS_PROVIDER=doppler', async () => { + process.env.DOPPLER_TOKEN = 'dp.pt.mock'; + process.env.SECRETS_PROVIDER = 'doppler'; + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), SecretsModule], + }).compile(); + + const resolved = moduleRef.get(SECRETS_PROVIDER); + expect(resolved).toBeInstanceOf(DopplerSecretsProvider); + delete process.env.SECRETS_PROVIDER; + delete process.env.DOPPLER_TOKEN; + }); + + it('resolves VaultSecretsProvider when SECRETS_PROVIDER=vault', async () => { + process.env.VAULT_TOKEN = 'hvs.tok'; + process.env.SECRETS_PROVIDER = 'vault'; + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), SecretsModule], + }).compile(); + + const resolved = moduleRef.get(SECRETS_PROVIDER); + expect(resolved).toBeInstanceOf(VaultSecretsProvider); + delete process.env.SECRETS_PROVIDER; + delete process.env.VAULT_TOKEN; + }); + + it('resolves AwsSecretsProvider when SECRETS_PROVIDER=aws', async () => { + process.env.AWS_SECRETS_NAME = 'test'; + process.env.SECRETS_PROVIDER = 'aws'; + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), SecretsModule], + }).compile(); + + const resolved = moduleRef.get(SECRETS_PROVIDER); + expect(resolved).toBeInstanceOf(AwsSecretsProvider); + delete process.env.SECRETS_PROVIDER; + delete process.env.AWS_SECRETS_NAME; + }); + + it('falls back to env for unknown SECRETS_PROVIDER values', async () => { + process.env.SECRETS_PROVIDER = 'unknown-vendor'; + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), SecretsModule], + }).compile(); + + const resolved = moduleRef.get(SECRETS_PROVIDER); + expect(resolved).toBeInstanceOf(EnvSecretsProvider); + delete process.env.SECRETS_PROVIDER; }); }); diff --git a/backend/src/config/secrets/secrets.module.ts b/backend/src/config/secrets/secrets.module.ts index 4c380e5c..b75c62f3 100644 --- a/backend/src/config/secrets/secrets.module.ts +++ b/backend/src/config/secrets/secrets.module.ts @@ -1,107 +1,103 @@ -import { DynamicModule, Global, Logger, Module } from '@nestjs/common'; +import { Module, Logger } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { SecretsProvider } from './secrets-provider'; +import { SecretsProvider } from './secrets-provider.interface'; import { EnvSecretsProvider } from './env-secrets.provider'; -import { DopplerSecretsProvider } from './doppler-secrets.provider'; import { VaultSecretsProvider } from './vault-secrets.provider'; +import { DopplerSecretsProvider } from './doppler-secrets.provider'; import { AwsSecretsProvider } from './aws-secrets.provider'; -export type SecretsProviderType = 'env' | 'doppler' | 'vault' | 'aws'; - /** - * Determines which secrets backend to use at runtime. + * Injection token for the active `SecretsProvider`. * - * The provider is selected via the `SECRETS_PROVIDER` environment variable: - * - `env` (default) – Reads from environment variables via ConfigService - * - `doppler` – Fetches from Doppler API (requires DOPPLER_TOKEN) - * - `vault` – Fetches from HashiCorp Vault (requires VAULT_* vars) - * - `aws` – Fetches from AWS Secrets Manager (requires AWS_* vars) + * Inject it anywhere with: + * ```ts + * @Inject(SECRETS_PROVIDER) + * private readonly secrets: SecretsProvider, + * ``` */ -function resolveProviderType(): SecretsProviderType { - const raw = (process.env.SECRETS_PROVIDER || 'env').toLowerCase(); - if (raw === 'doppler') return 'doppler'; - if (raw === 'vault') return 'vault'; - if (raw === 'aws') return 'aws'; - return 'env'; -} +export const SECRETS_PROVIDER = Symbol('SECRETS_PROVIDER'); -@Global() -@Module({}) -export class SecretsModule { - private static readonly logger = new Logger(SecretsModule.name); +/** + * SecretsModule registers the appropriate `SecretsProvider` implementation + * based on the `SECRETS_PROVIDER` environment variable. + * + * | `SECRETS_PROVIDER` value | Provider class | + * |--------------------------|--------------------------| + * | `env` (default / unset) | `EnvSecretsProvider` | + * | `vault` | `VaultSecretsProvider` | + * | `doppler` | `DopplerSecretsProvider` | + * | `aws` | `AwsSecretsProvider` | + * + * Import `SecretsModule` into any feature module that needs runtime secrets. + * Because it is marked `global: false`, you must import it explicitly in each + * module that requires it. If you prefer a single global registration, set + * `global: true` in the `@Module` decorator or use `SecretsModule.forRoot()`. + * + * ## Example + * + * ```ts + * // app.module.ts + * imports: [SecretsModule, ...], + * ``` + * + * ```ts + * // some.service.ts + * import { Inject } from '@nestjs/common'; + * import { SECRETS_PROVIDER } from 'src/config/secrets/secrets.module'; + * import type { SecretsProvider } from 'src/config/secrets/secrets-provider.interface'; + * + * @Injectable() + * export class SomeService { + * constructor( + * @Inject(SECRETS_PROVIDER) private readonly secrets: SecretsProvider, + * ) {} + * + * async doWork() { + * const dbPassword = await this.secrets.getSecret('DATABASE_PASSWORD'); + * } + * } + * ``` + */ +@Module({ + imports: [ConfigModule], + providers: [ + EnvSecretsProvider, + VaultSecretsProvider, + DopplerSecretsProvider, + AwsSecretsProvider, + { + provide: SECRETS_PROVIDER, + useFactory: ( + env: EnvSecretsProvider, + vault: VaultSecretsProvider, + doppler: DopplerSecretsProvider, + aws: AwsSecretsProvider, + ): SecretsProvider => { + const providerName = (process.env.SECRETS_PROVIDER ?? 'env').toLowerCase(); + const logger = new Logger('SecretsModule'); - /** - * Register the SecretsModule with the configured provider. - * - * Usage: - * ```typescript - * // In your AppModule imports: - * SecretsModule.forRoot() - * ``` - */ - static forRoot(): DynamicModule { - const providerType = resolveProviderType(); + const providerMap: Record = { + env: env, + vault: vault, + doppler: doppler, + aws: aws, + }; - this.logger.log(`Initializing SecretsProvider: "${providerType}"`); + const provider = providerMap[providerName]; + if (!provider) { + logger.warn( + `Unknown SECRETS_PROVIDER "${providerName}", falling back to "env". ` + + `Valid values: ${Object.keys(providerMap).join(', ')}`, + ); + return env; + } - const providers: any[] = [ - { - provide: 'SECRETS_PROVIDER_TYPE', - useValue: providerType, + logger.log(`Active secrets provider: ${providerName}`); + return provider; }, - ]; - - switch (providerType) { - case 'doppler': - providers.push({ - provide: SecretsProvider, - useClass: DopplerSecretsProvider, - }); - break; - case 'vault': - providers.push({ - provide: SecretsProvider, - useClass: VaultSecretsProvider, - }); - break; - case 'aws': - providers.push({ - provide: SecretsProvider, - useClass: AwsSecretsProvider, - }); - break; - case 'env': - default: - providers.push({ - provide: SecretsProvider, - useClass: EnvSecretsProvider, - }); - break; - } - - return { - module: SecretsModule, - global: true, - imports: [ConfigModule], - providers, - exports: [SecretsProvider], - }; - } - - /** - * Explicitly register a specific provider (useful for testing). - */ - static forTest(provider: SecretsProvider): DynamicModule { - return { - module: SecretsModule, - global: true, - providers: [ - { - provide: SecretsProvider, - useValue: provider, - }, - ], - exports: [SecretsProvider], - }; - } -} + inject: [EnvSecretsProvider, VaultSecretsProvider, DopplerSecretsProvider, AwsSecretsProvider], + }, + ], + exports: [SECRETS_PROVIDER], +}) +export class SecretsModule {} diff --git a/backend/src/config/secrets/vault-secrets.provider.ts b/backend/src/config/secrets/vault-secrets.provider.ts index 542d5b59..df9b655d 100644 --- a/backend/src/config/secrets/vault-secrets.provider.ts +++ b/backend/src/config/secrets/vault-secrets.provider.ts @@ -1,144 +1,77 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import axios from 'axios'; -import { SecretsProvider } from './secrets-provider'; +import { SecretsProvider } from './secrets-provider.interface'; /** - * Fetches secrets from HashiCorp Vault KV v2 engine. + * Fetches secrets from HashiCorp Vault using the KV v2 secrets engine. * - * Required env vars (preferably from EnvSecretsProvider): - * VAULT_ADDR – Vault server URL (e.g. https://vault.example.com:8200) - * VAULT_TOKEN – Vault authentication token - * VAULT_KV_PATH – KV v2 mount path + secret path (e.g. "secret/data/nova-labs") + * ## Required environment variables * - * Optional: - * VAULT_NAMESPACE – Vault enterprise namespace - * VAULT_CACHE_TTL_MS – Cache duration in ms (default: 300000 / 5 min) + * | Variable | Description | + * |-----------------------|-----------------------------------------------------------| + * | `VAULT_ADDR` | Base URL of the Vault server (e.g. `http://vault:8200`) | + * | `VAULT_TOKEN` | Vault token with read access to the configured mount path | + * | `VAULT_MOUNT` | KV v2 mount path (default: `secret`) | + * | `VAULT_SECRET_PATH` | Path under the mount (default: `novalabs`) | + * + * ## Activate + * + * Set `SECRETS_PROVIDER=vault` in your environment. + * + * ## References + * - https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2 */ @Injectable() -export class VaultSecretsProvider extends SecretsProvider { +export class VaultSecretsProvider implements SecretsProvider { private readonly logger = new Logger(VaultSecretsProvider.name); - private readonly vaultAddr: string; - private readonly vaultToken: string; - private readonly kvPath: string; - private readonly vaultNamespace?: string; - private readonly cacheExpiryMs: number; - private cachedSecrets: Record | null = null; - private cacheExpiresAt = 0; - - constructor(configService: ConfigService) { - super(); - this.vaultAddr = - configService.get('VAULT_ADDR') || - configService.get('VAULT_ADDRESS') || - ''; - this.vaultToken = configService.get('VAULT_TOKEN') || ''; - this.kvPath = - configService.get('VAULT_KV_PATH') || - configService.get('VAULT_SECRET_PATH') || - ''; - this.vaultNamespace = configService.get('VAULT_NAMESPACE'); - this.cacheExpiryMs = - configService.get('VAULT_CACHE_TTL_MS') ?? 300_000; - } - - /** - * Fetch the full secret bundle from Vault KV v2 and cache it. - */ - private async fetchSecrets(): Promise> { - if (this.cachedSecrets && Date.now() < this.cacheExpiresAt) { - return this.cachedSecrets; - } - if (!this.vaultAddr) { - throw new Error( - 'VaultSecretsProvider requires VAULT_ADDR to be set in the environment.', - ); - } - if (!this.vaultToken) { - throw new Error( - 'VaultSecretsProvider requires VAULT_TOKEN to be set in the environment.', - ); - } - if (!this.kvPath) { - throw new Error( - 'VaultSecretsProvider requires VAULT_KV_PATH to be set in the environment.', - ); - } + /** All secrets fetched in a single Vault read, cached for the process lifetime. */ + private cache: Record | null = null; - // KV v2 API path: /v1/{mount}/data/{path} - const apiPath = this.kvPath.startsWith('/') - ? this.kvPath - : `/v1/${this.kvPath}`; - const url = `${this.vaultAddr}${apiPath}`; + private get vaultAddr(): string { + return process.env.VAULT_ADDR ?? 'http://localhost:8200'; + } - try { - const headers: Record = { - Authorization: `Bearer ${this.vaultToken}`, - 'Content-Type': 'application/json', - }; - if (this.vaultNamespace) { - headers['X-Vault-Namespace'] = this.vaultNamespace; - } + private get vaultToken(): string { + const token = process.env.VAULT_TOKEN; + if (!token) throw new Error('VAULT_TOKEN environment variable is not set'); + return token; + } - const response = await axios.get<{ - data?: { data?: Record }; - }>(url, { headers }); + private get mountPath(): string { + return process.env.VAULT_MOUNT ?? 'secret'; + } - const rawData = response.data.data?.data; - if (!rawData) { - throw new Error( - `Vault response missing data.data at path "${this.kvPath}".`, - ); - } + private get secretPath(): string { + return process.env.VAULT_SECRET_PATH ?? 'novalabs'; + } - const secrets: Record = {}; - for (const [key, value] of Object.entries(rawData)) { - secrets[key] = String(value); - } + /** + * Fetches the full secret map from Vault on first call, then serves + * subsequent requests from an in-process cache. + */ + private async loadSecrets(): Promise> { + if (this.cache) return this.cache; - this.cachedSecrets = secrets; - this.cacheExpiresAt = Date.now() + this.cacheExpiryMs; + const url = `${this.vaultAddr}/v1/${this.mountPath}/data/${this.secretPath}`; + this.logger.log(`Loading secrets from Vault: ${url}`); - return secrets; - } catch (error) { - this.logger.error( - `Failed to fetch secrets from Vault: ${(error as Error).message}`, - ); - throw error; - } - } + const response = await axios.get<{ data: { data: Record } }>(url, { + headers: { 'X-Vault-Token': this.vaultToken }, + timeout: 10_000, + }); - async get(key: string): Promise { - try { - const secrets = await this.fetchSecrets(); - return secrets[key]; - } catch (error) { - this.logger.warn( - `VaultSecretsProvider.get("${key}") failed: ${(error as Error).message}. ` + - `Falling back to process.env.`, - ); - return process.env[key]; - } + this.cache = response.data?.data?.data ?? {}; + return this.cache; } - async getOrThrow(key: string): Promise { - const value = await this.get(key); + async getSecret(key: string): Promise { + const secrets = await this.loadSecrets(); + const value = secrets[key]; if (value === undefined) { - throw new Error( - `Missing required secret: "${key}". ` + - `Verified neither Vault nor environment variable is set.`, - ); + this.logger.warn(`Secret key "${key}" not found in Vault path "${this.secretPath}"`); + throw new NotFoundException(`Secret "${key}" not found in Vault`); } return value; } - - async getMany(keys: string[]): Promise> { - const secrets = await this.fetchSecrets(); - const result: Record = {}; - for (const key of keys) { - result[key] = secrets[key]; - } - return result; - } } diff --git a/backend/src/utils/error.ts b/backend/src/utils/error.ts index 17ba513a..b7ca1f89 100644 --- a/backend/src/utils/error.ts +++ b/backend/src/utils/error.ts @@ -2,17 +2,28 @@ import { HttpException, InternalServerErrorException } from '@nestjs/common'; /** * Utility function for uniform error handling across service providers. - * Re-throws NestJS HttpExceptions as-is so their status codes and messages are preserved. - * Wraps all other unexpected errors in an InternalServerErrorException with a descriptive message. * - * @param error - The caught error - * @param message - Context label prepended to the 500 error message (e.g. 'CreateBooking') - * @throws HttpException | InternalServerErrorException + * - Re-throws NestJS `HttpException` instances as-is so their status codes + * and messages are preserved and shaped by the global `ApiExceptionFilter`. + * - Wraps all other unexpected errors in `InternalServerErrorException` with a + * **generic** message (`code: 'INTERNAL_ERROR'`) to avoid leaking + * implementation details. The `context` label is passed as an option so it + * appears in server-side logs without reaching the client. + * + * @param error - The caught error object. + * @param context - A short label that identifies where the error originated + * (e.g. `'CreateBooking'`). Used only for server-side logging. + * @throws `HttpException` | `InternalServerErrorException` */ -export function ErrorCatch(error: any, message: string): never { +export function ErrorCatch(error: unknown, context: string): never { if (error instanceof HttpException) { throw error; } - throw new InternalServerErrorException(`${message}: Internal server error`); + // Log the raw error server-side so it can be investigated while keeping + // the public-facing message generic. + // eslint-disable-next-line no-console + console.error(`[${context}] Unexpected error:`, error); + + throw new InternalServerErrorException('Internal server error'); } diff --git a/contracts/manage_hub/src/audit.rs b/contracts/manage_hub/src/audit.rs new file mode 100644 index 00000000..b5dda4d0 --- /dev/null +++ b/contracts/manage_hub/src/audit.rs @@ -0,0 +1,197 @@ +// Allow deprecated events API until migration to #[contractevent] macro +#![allow(deprecated)] + +//! # On-chain Admin Audit Log +//! +//! Records every privileged admin operation in a tamper-evident, on-chain +//! append-only log. Each entry captures: +//! +//! - `action` — String label for the operation (e.g. `"set_admin"`). +//! - `caller` — The `Address` that invoked the operation. +//! - `timestamp` — Ledger timestamp at the moment of the call. +//! - `payload_hash` — SHA-256 hash of the operation's input payload bytes, +//! providing a compact, verifiable fingerprint of what was changed +//! (follows Stellar SDK's `env.crypto().sha256`). +//! +//! ## Storage layout +//! +//! | Key | Storage | Description | +//! |------------------------------|-------------|------------------------------------| +//! | `AuditDataKey::AuditLog` | `instance` | `Vec` (all entries) | +//! +//! Instance storage is chosen deliberately: audit entries are small, there are +//! few admin operations in practice, and instance-level storage is atomically +//! consistent with the rest of the contract state. +//! +//! ## Usage +//! +//! ```rust,ignore +//! use crate::audit::{AuditLog, AdminAction}; +//! +//! AuditLog::write( +//! &env, +//! AdminAction::SetAdmin, +//! &caller, +//! payload_bytes, // e.g. new_admin.to_val().to_xdr(...) +//! ); +//! ``` + +use soroban_sdk::xdr::ToXdr; +use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec}; + +// --------------------------------------------------------------------------- +// Storage key +// --------------------------------------------------------------------------- + +#[contracttype] +pub enum AuditDataKey { + /// The append-only list of all admin audit entries. + AuditLog, +} + +// --------------------------------------------------------------------------- +// Action enum +// --------------------------------------------------------------------------- + +/// Identifies which privileged operation was executed. +/// +/// Adding a new variant here (and calling `AuditLog::write` in the +/// corresponding function) is the only change required to audit a new +/// admin operation. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum AdminAction { + /// `set_admin` was called — admin address was replaced. + SetAdmin, + /// `set_usdc_contract` was called — USDC payment contract was updated. + SetUsdcContract, + /// `pause_subscription_admin` was called — a subscription was admin-paused. + PauseSubscriptionAdmin, + /// `set_pause_config` was called — global pause configuration was changed. + SetPauseConfig, +} + +// --------------------------------------------------------------------------- +// Log entry +// --------------------------------------------------------------------------- + +/// A single immutable record of one privileged admin operation. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AuditLogEntry { + /// Which admin operation was executed. + pub action: AdminAction, + /// The address that authenticated and triggered the operation. + pub caller: Address, + /// Ledger timestamp at the moment the operation ran. + pub timestamp: u64, + /// SHA-256 hash of the serialised operation payload. + /// + /// Clients can independently reconstruct the hash from the same inputs + /// to verify the log has not been tampered with (within a single ledger). + pub payload_hash: BytesN<32>, +} + +// --------------------------------------------------------------------------- +// Module +// --------------------------------------------------------------------------- + +pub struct AuditLog; + +impl AuditLog { + /// Appends a new audit entry to the on-chain log. + /// + /// # Arguments + /// + /// * `env` — Contract environment. + /// * `action` — The [`AdminAction`] being recorded. + /// * `caller` — Address that authenticated and triggered the operation. + /// * `payload` — Raw bytes representing the operation's input (used only + /// for hashing; not stored verbatim to keep entry size small). + /// + /// # Side-effects + /// + /// - Writes/updates `AuditDataKey::AuditLog` in instance storage. + /// - Emits an `audit_op` contract event so indexers can track entries + /// without replaying full storage reads. + pub fn write(env: &Env, action: AdminAction, caller: &Address, payload: Bytes) { + let payload_hash: BytesN<32> = env.crypto().sha256(&payload).into(); + + let entry = AuditLogEntry { + action: action.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + payload_hash: payload_hash.clone(), + }; + + // Load existing log (or start with an empty one). + let mut log: Vec = env + .storage() + .instance() + .get(&AuditDataKey::AuditLog) + .unwrap_or_else(|| Vec::new(env)); + + log.push_back(entry); + env.storage().instance().set(&AuditDataKey::AuditLog, &log); + + // Emit a lightweight event so off-chain indexers stay in sync. + env.events().publish( + (symbol_short!("audit_op"), caller.clone()), + (action, payload_hash, env.ledger().timestamp()), + ); + } + + /// Returns the full audit log, or an empty `Vec` if no entries exist yet. + pub fn read(env: &Env) -> Vec { + env.storage() + .instance() + .get(&AuditDataKey::AuditLog) + .unwrap_or_else(|| Vec::new(env)) + } + + // ----------------------------------------------------------------------- + // Internal helpers — build canonical payload bytes for each action type + // ----------------------------------------------------------------------- + + /// Builds the payload bytes for a `SetAdmin` entry. + /// + /// Payload: `caller_address_bytes ++ new_admin_address_bytes` + pub fn payload_set_admin(env: &Env, caller: &Address, new_admin: &Address) -> Bytes { + let mut b = Bytes::new(env); + b.append(&caller.clone().to_xdr(env)); + b.append(&new_admin.clone().to_xdr(env)); + b + } + + /// Builds the payload bytes for a `SetUsdcContract` entry. + /// + /// Payload: `caller_address_bytes ++ usdc_address_bytes` + pub fn payload_set_usdc(env: &Env, caller: &Address, usdc: &Address) -> Bytes { + let mut b = Bytes::new(env); + b.append(&caller.clone().to_xdr(env)); + b.append(&usdc.clone().to_xdr(env)); + b + } + + /// Builds the payload bytes for a `PauseSubscriptionAdmin` entry. + /// + /// Payload: `caller_address_bytes ++ subscription_id_bytes` + pub fn payload_pause_admin(env: &Env, caller: &Address, sub_id: &String) -> Bytes { + let mut b = Bytes::new(env); + b.append(&caller.clone().to_xdr(env)); + b.append(&sub_id.clone().to_xdr(env)); + b + } + + /// Builds the payload bytes for a `SetPauseConfig` entry. + /// + /// Payload: `caller_address_bytes` + /// (PauseConfig is large; its XDR encoding is the canonical representation + /// but is omitted here to keep the hash deterministic without importing + /// the full type. The timestamp in the entry pins the ledger-state.) + pub fn payload_set_pause_config(env: &Env, caller: &Address) -> Bytes { + let mut b = Bytes::new(env); + b.append(&caller.clone().to_xdr(env)); + b + } +} diff --git a/contracts/manage_hub/src/lib.rs b/contracts/manage_hub/src/lib.rs index 2bfa3674..eda03e1e 100644 --- a/contracts/manage_hub/src/lib.rs +++ b/contracts/manage_hub/src/lib.rs @@ -63,6 +63,7 @@ use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, Map, String mod allowance; mod attendance_log; +mod audit; mod batch; mod errors; mod fractionalization; @@ -81,6 +82,7 @@ mod upgrade_errors; mod validation; use attendance_log::{AttendanceLog, AttendanceLogModule}; +use audit::{AdminAction, AuditLog}; use batch::BatchModule; use common_types::{ AttendanceFrequency, DateRange, DayPattern, MetadataUpdate, MetadataValue, PeakHourData, @@ -110,6 +112,15 @@ impl Contract { vec![&env, String::from_str(&env, "Hello"), to] } + /// Returns the full on-chain admin audit log. + /// + /// Returns an empty `Vec` when no privileged operations have been recorded + /// yet. Each entry contains the `action`, `caller`, `timestamp`, and a + /// SHA-256 `payload_hash` of the operation's input. + pub fn get_audit_log(env: Env) -> Vec { + AuditLog::read(&env) + } + /// Mints multiple tokens in a single transaction. pub fn batch_mint(env: Env, params: Vec) -> Result<(), Error> { BatchModule::batch_mint(env, params) @@ -261,6 +272,10 @@ impl Contract { } pub fn set_admin(env: Env, admin: Address) -> Result<(), Error> { + // Write audit log entry before delegating so the record is always + // committed atomically with the actual state change. + let payload = AuditLog::payload_set_admin(&env, &admin, &admin); + AuditLog::write(&env, AdminAction::SetAdmin, &admin, payload); MembershipTokenContract::set_admin(env, admin)?; Ok(()) } @@ -326,6 +341,8 @@ impl Contract { admin: Address, reason: Option, ) -> Result<(), Error> { + let payload = AuditLog::payload_pause_admin(&env, &admin, &id); + AuditLog::write(&env, AdminAction::PauseSubscriptionAdmin, &admin, payload); SubscriptionContract::pause_subscription_admin(env, id, admin, reason) } @@ -334,6 +351,8 @@ impl Contract { } pub fn set_pause_config(env: Env, admin: Address, config: PauseConfig) -> Result<(), Error> { + let payload = AuditLog::payload_set_pause_config(&env, &admin); + AuditLog::write(&env, AdminAction::SetPauseConfig, &admin, payload); SubscriptionContract::set_pause_config(env, admin, config) } @@ -350,6 +369,8 @@ impl Contract { } pub fn set_usdc_contract(env: Env, admin: Address, usdc_address: Address) -> Result<(), Error> { + let payload = AuditLog::payload_set_usdc(&env, &admin, &usdc_address); + AuditLog::write(&env, AdminAction::SetUsdcContract, &admin, payload); SubscriptionContract::set_usdc_contract(env, admin, usdc_address) } diff --git a/frontend/.gitignore b/frontend/.gitignore index 295f80b4..02ca95fb 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -40,4 +40,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# Playwright (E2E test artifacts and the browser cache) +/test-results/ +/playwright-report/ +/playwright/.cache/ *storybook.log diff --git a/frontend/components/auth/EmailResetPassword.tsx b/frontend/components/auth/EmailResetPassword.tsx index 5a4e4c72..a1439942 100644 --- a/frontend/components/auth/EmailResetPassword.tsx +++ b/frontend/components/auth/EmailResetPassword.tsx @@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowLeft, Info, Mail, Send } from 'lucide-react'; import Link from 'next/link'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import z from 'zod'; import { @@ -19,6 +20,7 @@ import { import { Input } from '@/components/ui/Input'; import { Separator } from '@/components/ui/separator'; import { useState } from 'react'; +import { apiClient } from '@/lib/apiClient'; import ResetPasswordCard from './ResetPasswordCard'; import Alert from '../ui/Alert'; @@ -27,7 +29,7 @@ interface EmailResetPasswordProps { } const EmailResetPassword = ({ onTogglePage }: EmailResetPasswordProps) => { - const [dummyLoadingState, setDummyLoadingState] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const form = useForm>({ resolver: zodResolver(forgotPasswordSchema), defaultValues: { @@ -35,18 +37,32 @@ const EmailResetPassword = ({ onTogglePage }: EmailResetPasswordProps) => { }, }); - function onSubmit(values: z.infer) { - setDummyLoadingState(true); - - new Promise((resolve) => setTimeout(resolve, 3000)).then(() => { - setDummyLoadingState(false); + // Submits the email to the resend-reset-password-otp endpoint. The resend + // route generates a fresh OTP, persists it, and emails the user — behaviorally + // identical to the initial-request path used by ``. We + // wire to resend intentionally so this form exercises the path that PR #1 + // fixed (previously the email-send inside that controller was commented out + // and the user never received a code). + async function onSubmit(values: z.infer) { + setIsSubmitting(true); + try { + await apiClient.post('/auth/resend-reset-password-otp', { + email: values.email, + }); form.reset(); // PII note: do NOT log the raw form values here. The submitted // email belongs to the user and has no business leaving the // browser via devtools. Remove the prior console.log(values). onTogglePage('resend'); - }); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Failed to send reset code.', + ); + } finally { + setIsSubmitting(false); + } } + return ( <> { )} />