/** * Who acted, and does this API audit them? * * The staff/customer split reuses the exact discriminator the permission guards * already apply (`freight-permission.guard.ts`): `userType === 'employee'` is * backoffice, `individual` / `external_organization` are customers. Restating * the rule instead of importing it would let the two drift apart silently. */ const EMPLOYEE_USER_TYPE = 'employee'; const SUPER_ADMIN_ROLE = 'super_admin'; /** The subset of the JWT payload this module reads. */ export interface AuditActorSource { id?: string; sub?: string; userType?: string; username?: string; name?: string | { en?: string; am?: string }; firstName?: string; lastName?: string; email?: string; roles?: { key?: string; name?: string }[]; employee?: unknown; } export interface AuditActor { userId: string | null; userName: string | null; userRole: string | null; } function isSuperAdmin(user: AuditActorSource): boolean { return Boolean(user.roles?.some((role) => role.key === SUPER_ADMIN_ROLE)); } /** * Is this caller a backoffice user whose actions are audited? * * Only employees qualify. Customers are excluded by request, and unauthenticated * callers are excluded too — which means failed logins, OTP sends and password * resets produce no audit rows. That was a deliberate call: those endpoints are * not backoffice actions. Note the trade-off, since failed-auth attempts are * often what an incident review looks for first. */ export function isAuditableActor(user: AuditActorSource | null | undefined): boolean { if (!user) return false; // Super admins may not carry an `employee` userType on every token, but are // unambiguously staff — the permission guards treat them the same way. return user.userType === EMPLOYEE_USER_TYPE || isSuperAdmin(user); } /** Best-effort display name, tolerating the several shapes tokens use. */ function resolveUserName(user: AuditActorSource): string | null { if (typeof user.name === 'string' && user.name.trim()) return user.name.trim(); if (user.name && typeof user.name === 'object') { const localized = user.name.en ?? user.name.am; if (localized?.trim()) return localized.trim(); } const composed = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); if (composed) return composed; return user.username?.trim() || user.email?.trim() || null; } /** * Snapshot the actor at the moment of the action. * * Name and role are copied, never referenced: resolving them from IAM at read * time would rewrite history whenever someone is renamed, changes role or is * deleted. An audit row from last year must still say who acted and with what * authority *then*. */ export function resolveAuditActor(user: AuditActorSource): AuditActor { const roleKey = user.roles?.[0]?.key ?? user.roles?.[0]?.name ?? null; return { userId: user.id ?? user.sub ?? null, userName: resolveUserName(user), userRole: roleKey, }; }