diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts index 403b1a137..e898cb930 100644 --- a/apps/edr-freight-api/src/common/request-log-context.spec.ts +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => { originalUrl: "/api/bookings/1/submit?dry=1", baseUrl: "/api/bookings", route: { path: "/:id/submit" }, - headers: { "user-agent": "jest", "x-request-id": "req-42" }, + headers: { + "user-agent": "jest", + "x-request-id": "req-42", + authorization: "Bearer tok", + "x-client-app": "freight-backoffice", + "current-project-id": "proj-3", + }, ip: "10.0.0.1", query: { dry: "1" }, - user: { id: "u-7" }, + user: { + id: "u-7", + sessionId: "sess-9", + userType: "STAFF", + status: "ACTIVE", + username: "nati", + email: "nati@example.com", + phoneNumber: "0911000000", + name: { en: "Nati" }, + roles: [{ key: "freight_operations" }], + permissions: [{ key: "a" }, { key: "b" }], + employee: { + id: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + position: { + id: "pos-5", + key: "ops_officer", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + positionType: { key: "operations" }, + }, + }, + }, }; const res = { statusCode: 409, @@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => { bookingId: "b-1", booking: { outcome: "REJECTED" }, }); + expect(JSON.parse(lines[0]).auth).toEqual({ + authenticated: true, + hasBearer: true, + clientApp: "freight-backoffice", + userId: "u-7", + sessionId: "sess-9", + userType: "STAFF", + userStatus: "ACTIVE", + roles: ["freight_operations"], + permissionCount: 2, + employeeId: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + positionId: "pos-5", + positionKey: "ops_officer", + positionType: "operations", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + projectId: "proj-3", + }); + // No personal data reaches the line, whatever the token carried. + expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); jest.restoreAllMocks(); }); diff --git a/packages/api-common/src/logging/request-log.middleware.ts b/packages/api-common/src/logging/request-log.middleware.ts index 5d9ead8b0..4144e72c8 100644 --- a/packages/api-common/src/logging/request-log.middleware.ts +++ b/packages/api-common/src/logging/request-log.middleware.ts @@ -20,7 +20,39 @@ interface LoggedRequest { headers: Record; ip?: string; query?: Record; - user?: Record | null; + /** Set by the IAM JwtGuard AFTER this middleware runs — read at emit time. */ + user?: AuthenticatedUser | null; + currentUnitId?: string; +} + +/** + * The subset of `TCurrentUser` (@tria-plc/api-common) the log line reads. + * Everything here is an identifier, a key or a status — the personal fields on + * that type (name, email, username, phoneNumber) are deliberately absent so + * they cannot be picked up by accident. + */ +interface AuthenticatedUser { + id?: string; + sub?: string; + userId?: string; + sessionId?: string; + userType?: string; + status?: string; + roles?: { key?: string }[]; + permissions?: unknown[]; + employee?: { + id?: string; + organizationId?: string; + unitId?: string; + position?: { + id?: string; + key?: string; + employeePositionId?: string; + isDelegate?: boolean; + delegatorId?: string; + positionType?: { key?: string }; + }; + }; } interface LoggedResponse { @@ -35,13 +67,48 @@ const header = (req: LoggedRequest, name: string): string | undefined => { return Array.isArray(value) ? value[0] : value; }; -const userId = (req: LoggedRequest): string | undefined => { +const userId = (req: LoggedRequest): string | undefined => + req.user?.id ?? req.user?.sub ?? req.user?.userId; + +/** + * Who the caller was acting as — WITHOUT any personal data. Ids, role/position + * keys and statuses only: enough to answer "which desk did this", "was it a + * delegate", "which tenant", and to spot an authorization problem, with nothing + * that identifies the human behind the account beyond the opaque user id. + * + * `authenticated: false` with `hasBearer: true` is the signature of a rejected + * token (expired session, bad signature) as opposed to a missing one. + */ +const authContext = (req: LoggedRequest): Record => { const user = req.user; - if (!user) return undefined; - const id = user.id ?? user.sub ?? user.userId; - return typeof id === "string" || typeof id === "number" - ? String(id) - : undefined; + const position = user?.employee?.position; + return { + authenticated: Boolean(user), + hasBearer: header(req, "authorization")?.startsWith("Bearer ") ?? false, + // Which frontend called — /auth/login rejects cross-audience credentials on it. + clientApp: header(req, "x-client-app"), + userId: userId(req), + sessionId: user?.sessionId, + userType: user?.userType, + userStatus: user?.status, + roles: user?.roles?.map((role) => role.key).filter(Boolean), + // Count only: the full grant list is hundreds of keys and would dwarf the line. + permissionCount: user?.permissions?.length, + employeeId: user?.employee?.id, + organizationId: user?.employee?.organizationId, + unitId: user?.employee?.unitId ?? req.currentUnitId, + positionId: position?.id, + positionKey: position?.key, + positionType: position?.positionType?.key, + employeePositionId: position?.employeePositionId, + // Acting on someone else's behalf — the first thing to check when a staff + // action lands under an unexpected desk. + isDelegate: position?.isDelegate, + delegatorId: position?.delegatorId, + // Tenant/scope headers the frontends send alongside the token. + projectId: + header(req, "current-project-id") ?? header(req, "x-current-project-id"), + }; }; /** @@ -101,6 +168,9 @@ export class RequestLogMiddleware implements NestMiddleware { status, durationMs, userId: userId(req), + // Read at emit time on purpose: the guard populates req.user long + // after this middleware handed control on. + auth: authContext(req), ip: req.ip, userAgent: header(req, "user-agent"), query: