mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
feat(freight-api): add non-PII auth context to request log line
This commit is contained in:
@@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => {
|
|||||||
originalUrl: "/api/bookings/1/submit?dry=1",
|
originalUrl: "/api/bookings/1/submit?dry=1",
|
||||||
baseUrl: "/api/bookings",
|
baseUrl: "/api/bookings",
|
||||||
route: { path: "/:id/submit" },
|
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",
|
ip: "10.0.0.1",
|
||||||
query: { dry: "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 = {
|
const res = {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
@@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => {
|
|||||||
bookingId: "b-1",
|
bookingId: "b-1",
|
||||||
booking: { outcome: "REJECTED" },
|
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");
|
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,39 @@ interface LoggedRequest {
|
|||||||
headers: Record<string, string | string[] | undefined>;
|
headers: Record<string, string | string[] | undefined>;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
query?: Record<string, unknown>;
|
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 {
|
interface LoggedResponse {
|
||||||
@@ -35,13 +67,48 @@ const header = (req: LoggedRequest, name: string): string | undefined => {
|
|||||||
return Array.isArray(value) ? value[0] : value;
|
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;
|
const user = req.user;
|
||||||
if (!user) return undefined;
|
const position = user?.employee?.position;
|
||||||
const id = user.id ?? user.sub ?? user.userId;
|
return {
|
||||||
return typeof id === "string" || typeof id === "number"
|
authenticated: Boolean(user),
|
||||||
? String(id)
|
hasBearer: header(req, "authorization")?.startsWith("Bearer ") ?? false,
|
||||||
: undefined;
|
// 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,
|
status,
|
||||||
durationMs,
|
durationMs,
|
||||||
userId: userId(req),
|
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,
|
ip: req.ip,
|
||||||
userAgent: header(req, "user-agent"),
|
userAgent: header(req, "user-agent"),
|
||||||
query:
|
query:
|
||||||
|
|||||||
Reference in New Issue
Block a user