diff --git a/api/src/audit/audit.interceptor.ts b/api/src/audit/audit.interceptor.ts index e80cb97..67a8916 100644 --- a/api/src/audit/audit.interceptor.ts +++ b/api/src/audit/audit.interceptor.ts @@ -61,21 +61,12 @@ export class AuditInterceptor implements NestInterceptor { const userId = req.auth?.userId ?? null return next.handle().pipe( - // Runs only on success. The interceptor has no access to the - // post-validation request body, so metadata here is limited to - // `req.params` (e.g. the deleted stream id). Actions that need - // richer metadata (e.g. AuthService.login) call auditService.log() - // directly at the service layer. - tap(() => - this.auditService.log( - userId, - action, - action === AuditAction.STREAM_DELETE - ? { streamId: Number(req.params?.id) } - : {}, - ip, - ), - ), + // metadata is empty here because the interceptor doesn't have access to + // the request body post-processing. Callers that need richer metadata + // (e.g. AuthService) call auditService.logSafely() directly. + // Fail-open policy (issue #530): logSafely never throws, so a failed + // audit INSERT cannot surface as a post-response error either. + tap(() => this.auditService.logSafely(userId, action, {}, ip)), ) } } diff --git a/api/src/audit/audit.module.ts b/api/src/audit/audit.module.ts index 0d5d841..dba1d67 100644 --- a/api/src/audit/audit.module.ts +++ b/api/src/audit/audit.module.ts @@ -1,10 +1,26 @@ import { Module } from "@nestjs/common" import { APP_INTERCEPTOR } from "@nestjs/core" +import { AdminAuditController } from "./admin-audit.controller" import { AuditInterceptor } from "./audit.interceptor" import { AuditService } from "./audit.service" +import { MetricsModule } from "../metrics/metrics.module" +/** + * Audit logging module. + * + * Fail-open policy (issue #530): audit log writes must never fail the + * primary request they are auditing. A DB hiccup on the audit table + * must not turn a valid login into a 503. `AuditService.logSafely` + * absorbs write failures (structured log + `audit_log_write_failures_total` + * metric) so security-relevant events stay observable while the + * audited action proceeds — a deliberate trade-off between audit + * coverage and product availability. The interceptor and the auth + * service both route through the safe path. + */ @Module({ + imports: [MetricsModule], + controllers: [AdminAuditController], providers: [ AuditService, { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, diff --git a/api/src/audit/audit.service.spec.ts b/api/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..312216d --- /dev/null +++ b/api/src/audit/audit.service.spec.ts @@ -0,0 +1,142 @@ +import { Logger } from "@nestjs/common" + +import { AuditAction } from "./audit-action.enum" +import { AuditService } from "./audit.service" + +interface MockPool { + query: jest.Mock +} + +interface MockMetricsService { + auditLogWriteFailuresTotal: { inc: jest.Mock } +} + +function makeService( + pool: MockPool, + metrics?: MockMetricsService, +): AuditService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the pool only needs `query` for these tests + return new AuditService(pool as any, metrics as any) +} + +describe("AuditService", () => { + let pool: MockPool + let metrics: MockMetricsService + let service: AuditService + + beforeEach(() => { + pool = { query: jest.fn() } + metrics = { + auditLogWriteFailuresTotal: { inc: jest.fn() }, + } + service = makeService(pool, metrics) + jest.clearAllMocks() + }) + + describe("log (fail-closed primitive)", () => { + it("inserts the audit row with stringified metadata", async () => { + pool.query.mockResolvedValue({ rows: [] }) + + await service.log( + 7, + AuditAction.AUTH_LOGIN_FAILURE, + { reason: "invalid_password" }, + "1.2.3.4", + ) + + expect(pool.query).toHaveBeenCalledWith( + "INSERT INTO audit_logs (user_id, action, metadata, ip) VALUES ($1, $2, $3, $4)", + [ + 7, + AuditAction.AUTH_LOGIN_FAILURE, + JSON.stringify({ reason: "invalid_password" }), + "1.2.3.4", + ], + ) + }) + + it("throws on database errors — callers on primary paths must use logSafely", async () => { + pool.query.mockRejectedValue(new Error("connection refused")) + + await expect( + service.log(null, AuditAction.AUTH_LOGIN_FAILURE, {}, "1.2.3.4"), + ).rejects.toThrow("connection refused") + }) + }) + + describe("logSafely (fail-open policy)", () => { + it("resolves without throwing when the audit INSERT fails", async () => { + pool.query.mockRejectedValue(new Error("connection refused")) + + await expect( + service.logSafely( + 1, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: "a@b.c" }, + "203.0.113.7", + ), + ).resolves.toBeUndefined() + }) + + it("logs the failed write with the action, user, and IP that would have been recorded", async () => { + pool.query.mockRejectedValue(new Error("connection refused")) + const errorSpy = jest + .spyOn(Logger.prototype, "error") + .mockImplementation(() => undefined) + + try { + await service.logSafely( + 42, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: "a@b.c" }, + "203.0.113.7", + ) + + const [message] = errorSpy.mock.calls[0] + expect(String(message)).toContain("action=AUTH_LOGIN_SUCCESS") + expect(String(message)).toContain("userId=42") + expect(String(message)).toContain("ip=203.0.113.7") + } finally { + errorSpy.mockRestore() + } + }) + + it("increments the audit write failure counter with the action label", async () => { + pool.query.mockRejectedValue(new Error("timeout")) + + await service.logSafely( + 1, + AuditAction.AUTH_REGISTER_SUCCESS, + {}, + "1.2.3.4", + ) + + expect(metrics.auditLogWriteFailuresTotal.inc).toHaveBeenCalledWith({ + action: AuditAction.AUTH_REGISTER_SUCCESS, + }) + }) + + it("performs a single write on success — no retry, no double-write, no counter bump", async () => { + pool.query.mockResolvedValue({ rows: [] }) + + await service.logSafely( + 1, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: "a@b.c" }, + "1.2.3.4", + ) + + expect(pool.query).toHaveBeenCalledTimes(1) + expect(metrics.auditLogWriteFailuresTotal.inc).not.toHaveBeenCalled() + }) + + it("tolerates a missing metrics service (unit-test construction)", async () => { + const bare = makeService(pool) + pool.query.mockRejectedValue(new Error("down")) + + await expect( + bare.logSafely(1, AuditAction.AUTH_LOGIN_SUCCESS, {}, "1.2.3.4"), + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/api/src/audit/audit.service.ts b/api/src/audit/audit.service.ts index 65a0ffb..48038fd 100644 --- a/api/src/audit/audit.service.ts +++ b/api/src/audit/audit.service.ts @@ -1,15 +1,41 @@ -import { Inject, Injectable } from "@nestjs/common" +import { Inject, Injectable, Logger, Optional } from "@nestjs/common" import { Pool } from "pg" -import { PG_POOL } from "../database/database.module" + import { AuditAction } from "./audit-action.enum" +import { PG_POOL } from "../database/database.module" +import { MetricsService } from "../metrics/metrics.service" +/** + * Audit logging for security-relevant actions. + * + * Fail-open policy (issue #530): audit writes are an observability + * concern, not a trust boundary for the audited action. A failed + * INSERT must never fail the request being audited — a DB hiccup on + * the audit table must not turn a valid login into a 503. Primary + * request paths MUST go through {@link logSafely}, which absorbs the + * failure (structured log + `audit_log_write_failures_total` metric) + * and lets the audited action proceed. The raw {@link log} method + * stays fail-closed (it throws) so callers can still observe write + * failures directly; it is intentionally not awaited from request + * handlers. + */ @Injectable() export class AuditService { - constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + private readonly logger = new Logger(AuditService.name) + + constructor( + @Inject(PG_POOL) private readonly pool: Pool, + @Optional() private readonly metricsService?: MetricsService, + ) {} /** * Persist an audit log entry. * + * Fail-closed primitive: throws on any database error. Do NOT await + * this from a primary request path — use {@link logSafely} instead + * so an audit write failure cannot fail the audited action + * (fail-open policy, issue #530). + * * @param userId - the authenticated user, or `null` for anonymous events. * @param action - a value from {@link AuditAction}; never a free-form string. * @param metadata - structured context for the action (e.g. `{ email, reason }`). @@ -28,6 +54,37 @@ export class AuditService { ) } + /** + * Fail-open wrapper around {@link log} for primary request paths. + * + * Deliberate trade-off (see module comment): if the audit INSERT + * fails (DB down, constraint, timeout), the failure is logged with + * the action, user, and IP that would have been recorded and counted + * in `audit_log_write_failures_total`, then swallowed — the audited + * action proceeds. Audit coverage degrades visibly during a DB + * outage instead of taking login/register down with it. + * + * Never throws. Safe to await anywhere. On success behaves exactly + * like {@link log} — a single write, no retry, no duplicate row. + */ + async logSafely( + userId: number | null, + action: AuditAction, + metadata: Record, + ip: string, + ): Promise { + try { + await this.log(userId, action, metadata, ip) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.logger.error( + `audit log write failed (action=${action}, userId=${userId}, ip=${ip}): ${message}`, + err instanceof Error ? err.stack : undefined, + ) + this.metricsService?.auditLogWriteFailuresTotal.inc({ action }) + } + } + async findAll(page = 1, limit = 20) { const offset = (page - 1) * limit const totalResult = await this.pool.query( diff --git a/api/src/auth/auth-rate-limit.integration.spec.ts b/api/src/auth/auth-rate-limit.integration.spec.ts index 203583a..8dbab52 100644 --- a/api/src/auth/auth-rate-limit.integration.spec.ts +++ b/api/src/auth/auth-rate-limit.integration.spec.ts @@ -13,12 +13,12 @@ import { Test, TestingModule } from "@nestjs/testing" import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler" import request from "supertest" -import { AuditService } from "../audit/audit.service" import { AuthController } from "./auth.controller" import { AuthService } from "./auth.service" import { PasswordResetService } from "./password-reset.service" import { TokenDenylistService } from "./token-denylist.service" import { UsersRepository } from "./users.repository" +import { AuditService } from "../audit/audit.service" describe("Auth Rate Limiting (Integration)", () => { let app: INestApplication @@ -51,6 +51,7 @@ describe("Auth Rate Limiting (Integration)", () => { const mockAuditService = { log: jest.fn().mockResolvedValue(undefined), + logSafely: jest.fn().mockResolvedValue(undefined), } beforeEach(async () => { diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 81d227e..2a0d664 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -5,6 +5,9 @@ import * as bcrypt from "bcrypt" import { AuthService } from "./auth.service" import { TokenDenylistService } from "./token-denylist.service" import { User, UsersRepository } from "./users.repository" +import { AuditAction } from "../audit/audit-action.enum" + +import type { AuditService } from "../audit/audit.service" jest.mock("bcrypt", () => ({ hash: jest.fn(), @@ -39,6 +42,11 @@ interface MockTokenDenylistService { isRevoked: jest.Mock> } +interface MockAuditService { + log: jest.Mock> + logSafely: jest.Mock> +} + function mockJwtService(): MockJwtService { return { sign: jest.fn(), @@ -69,6 +77,7 @@ function makeService( users: MockUsersRepository, passwordReset: MockPasswordResetService, tokenDenylist: MockTokenDenylistService, + audit: MockAuditService, ): AuthService { return new AuthService( refreshJwt as unknown as JwtService, @@ -77,8 +86,7 @@ function makeService( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PasswordResetService is typed separately via the mock interface passwordReset as unknown as any, tokenDenylist as unknown as TokenDenylistService, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Logger mock only needs `log` - { log: jest.fn() } as any, + audit as unknown as AuditService, ) } @@ -105,6 +113,7 @@ describe("AuthService", () => { let users: MockUsersRepository let passwordReset: MockPasswordResetService let tokenDenylist: MockTokenDenylistService + let audit: MockAuditService let service: AuthService beforeEach(() => { @@ -112,17 +121,15 @@ describe("AuthService", () => { refreshJwt = mockJwtService() users = mockUsersRepository() passwordReset = mockPasswordResetService() - tokenDenylist = { - revoke: jest.fn(), - decodeJti: jest.fn(), - isRevoked: jest.fn(), - } + tokenDenylist = { revoke: jest.fn() } + audit = { log: jest.fn(), logSafely: jest.fn() } service = makeService( accessJwt, refreshJwt, users, passwordReset, tokenDenylist, + audit, ) jest.clearAllMocks() }) @@ -150,7 +157,7 @@ describe("AuthService", () => { const result = await service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(users.findByEmail).toHaveBeenCalledWith(dto.email) expect(users.findByUsername).toHaveBeenCalledWith(dto.username) @@ -159,6 +166,13 @@ describe("AuthService", () => { dto.email, "$2b$10$hashed", ) + expect(audit.logSafely).toHaveBeenCalledWith( + null, + AuditAction.AUTH_REGISTER_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + expect(audit.log).not.toHaveBeenCalled() expect(accessJwt.sign).toHaveBeenCalledWith({ sub: 1, email: dto.email, @@ -191,7 +205,7 @@ describe("AuthService", () => { service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test, // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(ConflictException) expect(users.create).not.toHaveBeenCalled() }) @@ -207,7 +221,7 @@ describe("AuthService", () => { service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test, // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(ConflictException) expect(users.create).not.toHaveBeenCalled() }) @@ -224,7 +238,7 @@ describe("AuthService", () => { await service.register(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(bcrypt.hash).toHaveBeenCalledWith(dto.password, 12) const [storedUsername, storedEmail, storedHash] = @@ -244,9 +258,51 @@ describe("AuthService", () => { email: "dup@x.com", password: "someOtherPassword", }, - { ip: "127.0.0.1", headers: { "user-agent": "test" } } as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test - ), // eslint-disable-line @typescript-eslint/no-explicit-any + { ip: "127.0.0.1", headers: { "user-agent": "test" } } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ), + ).rejects.toThrow(ConflictException) + }) + + it("still resolves with the token pair when the audit log write fails (fail-open)", async () => { + users.findByEmail.mockResolvedValue(null) + users.findByUsername.mockResolvedValue(null) + users.create.mockResolvedValue( + dummyUser({ email: dto.email, username: dto.username }), + ) + accessJwt.sign.mockReturnValue("jwt.token.here") + refreshJwt.sign.mockReturnValue("refresh.token.here") + ;(bcrypt.hash as jest.Mock).mockResolvedValue("$2b$10$hashed") + + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + const result = await service.register(dto, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(result.accessToken).toBe("jwt.token.here") + expect(result.refreshToken).toBe("refresh.token.here") + expect(audit.logSafely).toHaveBeenCalledWith( + null, + AuditAction.AUTH_REGISTER_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + }) + + it("still throws ConflictException (not 503) when a conflict-path audit write fails", async () => { + users.findByEmail.mockResolvedValue(dummyUser({ email: dto.email })) + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + service.register(dto, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(ConflictException) + expect(users.create).not.toHaveBeenCalled() }) }) @@ -298,13 +354,20 @@ describe("AuthService", () => { const result = await service.login(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(users.findByEmail).toHaveBeenCalledWith(dto.email) expect(bcrypt.compare).toHaveBeenCalledWith( dto.password, user.password_hash, ) + expect(audit.logSafely).toHaveBeenCalledWith( + user.id, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + expect(audit.log).not.toHaveBeenCalled() expect(accessJwt.sign).toHaveBeenCalledWith({ sub: user.id, email: user.email, @@ -337,7 +400,53 @@ describe("AuthService", () => { service.login(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test, // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any + ).rejects.toThrow(UnauthorizedException) + expect(accessJwt.sign).not.toHaveBeenCalled() + }) + + it("still resolves with the token pair when the audit log write fails (fail-open)", async () => { + const user = dummyUser({ email: dto.email }) + users.findByEmail.mockResolvedValue(user) + ;(bcrypt.compare as jest.Mock).mockResolvedValue(true) + accessJwt.sign.mockReturnValue("jwt.token.here") + refreshJwt.sign.mockReturnValue("refresh.token.here") + + // The raw audit INSERT is mocked to reject (DB hiccup). The auth + // flow must route through logSafely and never await the raw + // write, so a valid login still returns tokens. The swallow + // behaviour of logSafely itself is unit-tested in + // audit.service.spec.ts. + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + const result = await service.login(dto, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(result.accessToken).toBe("jwt.token.here") + expect(result.refreshToken).toBe("refresh.token.here") + expect(audit.logSafely).toHaveBeenCalledWith( + user.id, + AuditAction.AUTH_LOGIN_SUCCESS, + { email: dto.email }, + "127.0.0.1", + ) + }) + + it("still throws UnauthorizedException (not 503) when a failure-path audit write fails", async () => { + const user = dummyUser({ email: dto.email }) + users.findByEmail.mockResolvedValue(user) + ;(bcrypt.compare as jest.Mock).mockResolvedValue(false) + audit.log.mockRejectedValue(new Error("audit db unavailable")) + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test + service.login({ email: dto.email, password: "wrong" }, { + ip: "127.0.0.1", + headers: { "user-agent": "test" }, + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(UnauthorizedException) expect(accessJwt.sign).not.toHaveBeenCalled() }) @@ -352,7 +461,7 @@ describe("AuthService", () => { service.login({ email: dto.email, password: "wrongPassword" }, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test, // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any), // eslint-disable-line @typescript-eslint/no-explicit-any ).rejects.toThrow(UnauthorizedException) expect(accessJwt.sign).not.toHaveBeenCalled() @@ -366,7 +475,7 @@ describe("AuthService", () => { .login({ email: "no@user.com", password: "any" }, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any .catch((e) => e) expect(e1).toBeInstanceOf(UnauthorizedException) @@ -378,7 +487,7 @@ describe("AuthService", () => { .login({ email: dto.email, password: "bad" }, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any .catch((e) => e) expect(e2).toBeInstanceOf(UnauthorizedException) @@ -396,7 +505,7 @@ describe("AuthService", () => { await service.login(dto, { ip: "127.0.0.1", headers: { "user-agent": "test" }, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any -- partial request stub for test + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any expect(bcrypt.compare).toHaveBeenCalledWith( dto.password, diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 0d03ec8..1df0b71 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -15,7 +15,7 @@ import { LoginDto } from "./dto/login.dto" import { RegisterDto } from "./dto/register.dto" import { ResetPasswordDto } from "./dto/reset-password.dto" import { PasswordResetService } from "./password-reset.service" -import { TokenDenylistService, TokenJti } from "./token-denylist.service" +import { TokenDenylistService } from "./token-denylist.service" import { User, UsersRepository } from "./users.repository" import { AuditAction } from "../audit/audit-action.enum" import { AuditService } from "../audit/audit.service" @@ -65,7 +65,7 @@ export class AuthService { const emailExists = await this.usersRepository.findByEmail(dto.email) if (emailExists) { - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_REGISTER_FAILURE, { reason: "email_conflict", email: dto.email }, @@ -78,7 +78,7 @@ export class AuthService { dto.username, ) if (usernameExists) { - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_REGISTER_FAILURE, { reason: "username_conflict", username: dto.username }, @@ -95,7 +95,7 @@ export class AuthService { passwordHash, ) - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_REGISTER_SUCCESS, { email: dto.email }, @@ -121,7 +121,7 @@ export class AuthService { const user = await this.usersRepository.findByEmail(dto.email) if (!user) { - await this.auditService.log( + await this.auditService.logSafely( null, AuditAction.AUTH_LOGIN_FAILURE, { reason: "user_not_found", email: dto.email }, @@ -132,7 +132,7 @@ export class AuthService { const valid = await bcrypt.compare(dto.password, user.password_hash) if (!valid) { - await this.auditService.log( + await this.auditService.logSafely( user.id, AuditAction.AUTH_LOGIN_FAILURE, { reason: "invalid_password", email: dto.email }, @@ -141,7 +141,7 @@ export class AuthService { throw new UnauthorizedException("invalid email or password") } - await this.auditService.log( + await this.auditService.logSafely( user.id, AuditAction.AUTH_LOGIN_SUCCESS, { email: dto.email }, diff --git a/api/src/config/jwt-secret-validator.spec.ts b/api/src/config/jwt-secret-validator.spec.ts index 00e67f4..a3740e9 100644 --- a/api/src/config/jwt-secret-validator.spec.ts +++ b/api/src/config/jwt-secret-validator.spec.ts @@ -23,7 +23,12 @@ describe("validateJwtSecret (Issue #318)", () => { }) it("returns ok when no secret is configured", () => { - const result = validateJwtSecret(undefined, "production") + // Pass an empty string, not `undefined`: the function's default + // parameter (`secret = process.env.JWT_SECRET`) means an explicit + // `undefined` argument falls back to the environment, which is set + // in CI — so `undefined` would not exercise the "no secret" branch. + // An empty string is falsy and hits the same early return. + const result = validateJwtSecret("", "production") expect(result.ok).toBe(true) expect(exitSpy).not.toHaveBeenCalled() }) diff --git a/api/src/contract-provider.spec.ts b/api/src/contract-provider.spec.ts index 40f467e..07d6394 100644 --- a/api/src/contract-provider.spec.ts +++ b/api/src/contract-provider.spec.ts @@ -167,7 +167,13 @@ describe("Contract provider verification (api)", () => { }, { provide: UsersRepository, useClass: InMemoryUsersRepository }, { provide: PasswordResetService, useValue: {} }, - { provide: AuditService, useValue: { log: async () => undefined } }, + { + provide: AuditService, + useValue: { + log: async () => undefined, + logSafely: async () => undefined, + }, + }, ], }).compile() diff --git a/api/src/metrics/metrics.service.ts b/api/src/metrics/metrics.service.ts index 590a691..fa29085 100644 --- a/api/src/metrics/metrics.service.ts +++ b/api/src/metrics/metrics.service.ts @@ -38,6 +38,16 @@ export class MetricsService implements OnModuleInit { registers: [this.registry], }) + // Issue #530: audit writes are fail-open — a failed INSERT is logged + // and counted instead of failing the audited request. This counter + // makes audit gaps during DB outages visible to operators. + readonly auditLogWriteFailuresTotal = new Counter({ + name: "audit_log_write_failures_total", + help: "Total number of audit log writes that failed and were absorbed by the fail-open policy", + labelNames: ["action"], + registers: [this.registry], + }) + // Issue #328: PostgreSQL connection pool metrics readonly dbPoolActive = new Gauge({ name: "db_pool_active_connections", diff --git a/api/src/streams/streams.controller.spec.ts b/api/src/streams/streams.controller.spec.ts index d436993..801ff93 100644 --- a/api/src/streams/streams.controller.spec.ts +++ b/api/src/streams/streams.controller.spec.ts @@ -98,6 +98,7 @@ describe("StreamsController", () => { expect(mockService.create).toHaveBeenCalledWith({ userId: 7, name: dto.name, + description: dto.description, visibility: undefined, }) }) diff --git a/api/src/streams/streams.controller.ts b/api/src/streams/streams.controller.ts index f87e006..ef1a7c2 100644 --- a/api/src/streams/streams.controller.ts +++ b/api/src/streams/streams.controller.ts @@ -191,18 +191,17 @@ export class StreamsController { ) { const page = query.page ?? 1 const limit = query.limit ?? 20 - const result = await this.streamsService.list(page, limit, req.auth!.userId, { + const paged = await this.streamsService.list(page, limit, req.auth!.userId, { status: query.status, visibility: query.visibility, ownerOnly: query.ownerOnly, }) - // Single-stream endpoints serialize ids to strings via - // `toStreamResponse`; the list endpoint must do the same so the - // wire shape is consistent across the whole API (the shared - // `@xstreamroll/types#Stream` contract declares string ids). + // Serialize ids to strings at the API boundary, exactly like the + // single-stream endpoints — `GET /streams` must not leak the + // numeric Postgres ids (contract: `@xstreamroll/types#Stream`). return { - ...result, - data: result.data.map(toStreamResponse), + ...paged, + data: paged.data.map(toStreamResponse), } }