feat(freight-api): add non-PII auth context to request log line

This commit is contained in:
Nathnael
2026-08-12 11:35:36 +00:00
parent 2a816912fc
commit 0f1bac8080
2 changed files with 132 additions and 9 deletions

View File

@@ -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();
});

View File

@@ -20,7 +20,39 @@ interface LoggedRequest {
headers: Record<string, string | string[] | undefined>;
ip?: string;
query?: Record<string, unknown>;
user?: Record<string, unknown> | 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<string, unknown> => {
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: