Merge pull request #1471 from Tria-plc/freight/fix/quick-fixes

fix: matrix
This commit is contained in:
Nathnael Wondisha
2026-09-01 10:08:20 +03:00
committed by GitHub
19 changed files with 887 additions and 190 deletions

View File

@@ -0,0 +1,148 @@
import {
collectAllPositions,
resolveActiveEmployee,
type SnapshotEmployee,
} from './freight-jwt.guard';
// Shapes and ids taken from the real dev session for `test_dj_gl_director`
// (iam.sessions 800ad793-…), an employee holding two posts on one row.
const CHIEF = {
id: '990189f1-e872-4b8c-9f6a-36259a0df480',
employeePositionId: 'd0d527f6-f344-49aa-ab8b-25a448a770b6',
name: { en: 'Djibouti GL Chief' },
isDelegate: false,
};
const DIRECTOR = {
id: '258a8d82-28c4-401f-bf88-78f58bb6bd0e',
employeePositionId: 'b97aa265-5de8-4ffe-95bf-01f94d38a2df',
name: { en: 'Djibouti GL Director' },
isDelegate: false,
};
const EMPLOYEE_ID = '70545ee5-c7d7-4196-af7e-a7eb7e76b21b';
const oneRow: SnapshotEmployee[] = [
{ id: EMPLOYEE_ID, positions: [CHIEF, DIRECTOR] },
];
describe('resolveActiveEmployee', () => {
it('leaves the parent guard alone when no position header is sent', () => {
const { owner, active } = resolveActiveEmployee(
oneRow,
undefined,
EMPLOYEE_ID,
);
expect(owner).toBe(oneRow[0]);
expect(active).toBeUndefined();
});
it("resolves freight's header value (employeePositionId)", () => {
const { active } = resolveActiveEmployee(
oneRow,
DIRECTOR.employeePositionId,
EMPLOYEE_ID,
);
expect(active).toBe(DIRECTOR);
});
// The regression this guard exists for: the stock IAM guard matches the
// header against employeePositionId only, so Smart Office's position.id
// matched nothing and every request silently ran as positions[0].
it("resolves Smart Office's header value (position.id)", () => {
const { active } = resolveActiveEmployee(oneRow, DIRECTOR.id, EMPLOYEE_ID);
expect(active).toBe(DIRECTOR);
expect(active).not.toBe(CHIEF);
});
it('falls back to the parent row when the header names nothing', () => {
const { owner, active } = resolveActiveEmployee(
oneRow,
'not-a-position-id',
EMPLOYEE_ID,
);
expect(owner).toBe(oneRow[0]);
expect(active).toBeUndefined();
});
describe('when the two posts sit on different employee rows', () => {
const smartOfficeRow: SnapshotEmployee = {
id: 'emp-smart-office',
positions: [CHIEF],
};
const freightRow: SnapshotEmployee = {
id: 'emp-freight',
positions: [DIRECTOR],
};
const twoRows = [smartOfficeRow, freightRow];
it('selects the row that owns the requested position', () => {
const { owner, active } = resolveActiveEmployee(
twoRows,
DIRECTOR.employeePositionId,
// The parent guard matches the header against position.id only, so it
// matched neither row and fell through to the first.
smartOfficeRow.id,
);
expect(owner).toBe(freightRow);
expect(active).toBe(DIRECTOR);
});
it('keeps the parent row when no header is sent', () => {
const { owner } = resolveActiveEmployee(twoRows, undefined, freightRow.id);
expect(owner).toBe(freightRow);
});
it('falls back to the first row when the parent row is unknown', () => {
const { owner } = resolveActiveEmployee(twoRows, undefined, undefined);
expect(owner).toBe(smartOfficeRow);
});
});
});
describe('collectAllPositions', () => {
it('unions posts held across separate employee rows', () => {
// The real shape: IAM keeps one employee row per organization, and "EDR"
// and "EDR Freight" are separate orgs, so a user holding a Smart Office
// post and a freight post owns one row each.
const smartOfficeRow: SnapshotEmployee = {
id: 'emp-edr',
organizationId: 'org-edr',
positions: [CHIEF],
};
const freightRow: SnapshotEmployee = {
id: 'emp-edr-freight',
organizationId: 'org-edr-freight',
positions: [DIRECTOR],
};
expect(collectAllPositions([smartOfficeRow, freightRow])).toEqual([
CHIEF,
DIRECTOR,
]);
});
it('keeps every post when they share one row', () => {
expect(collectAllPositions(oneRow)).toEqual([CHIEF, DIRECTOR]);
});
it('de-duplicates a post repeated across rows', () => {
const rows: SnapshotEmployee[] = [
{ id: 'a', positions: [CHIEF] },
{ id: 'b', positions: [CHIEF, DIRECTOR] },
];
expect(collectAllPositions(rows)).toEqual([CHIEF, DIRECTOR]);
});
it('tolerates rows carrying no positions', () => {
const rows: SnapshotEmployee[] = [{ id: 'a' }, { id: 'b', positions: [] }];
expect(collectAllPositions(rows)).toEqual([]);
});
});

View File

@@ -3,29 +3,124 @@ import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { CURRENT_POSITION_ID } from '@tria-plc/api-common/utils/constants/tenant.constant';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */ /** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
type SnapshotPosition = { id?: string; [key: string]: unknown }; export type SnapshotPosition = {
id?: string;
employeePositionId?: string;
isDelegate?: boolean;
[key: string]: unknown;
};
type SessionUserInfo = { /** One employee row as the snapshot stores it. A user may hold several. */
employee?: { id?: string; positions?: SnapshotPosition[] }[]; export type SnapshotEmployee = {
id?: string;
positions?: SnapshotPosition[];
[key: string]: unknown;
};
type SessionUserInfo = { employee?: SnapshotEmployee[] };
/**
* `x-current-position-id` is sent with two different meanings by two different
* frontends, and the IAM guard reads it both ways in the same function: it
* picks the EMPLOYEE row by `position.id` but the POSITION by
* `employeePositionId`. Freight sends `employeePositionId`, Smart Office sends
* `position.id` — so whichever value arrives, one of the two lookups silently
* matches nothing and falls back to the first entry.
*
* Matching both fields is what makes the header mean one thing again.
*/
const identifies = (position: SnapshotPosition, id: string): boolean =>
position?.id === id || position?.employeePositionId === id;
/**
* Every post the user holds, across every employee row, first occurrence kept.
*
* IAM keeps one employee row per ORGANIZATION, and "EDR" and "EDR Freight" are
* separate organizations — so a user given a freight post and a Smart Office
* post owns two rows, one post on each. Only one row can be the active one, and
* a permission check that reads only that row cannot see the other post at all.
*/
export const collectAllPositions = (
employees: SnapshotEmployee[],
): SnapshotPosition[] => {
const seen = new Set<string>();
const all: SnapshotPosition[] = [];
for (const employee of employees) {
for (const position of employee.positions ?? []) {
const key = position.employeePositionId ?? position.id;
if (key) {
if (seen.has(key)) continue;
seen.add(key);
}
all.push(position);
}
}
return all;
}; };
/** /**
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions. * Which employee row the caller is acting as, and which of its positions the
* request selected. Pure so it can be tested without a session or a token.
* *
* IAM models an employee as holding many positions, and the login snapshot in * `owner` is the row holding the requested position; failing that the row the
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then * parent guard already picked; failing that the first. `active` is undefined
* collapses that to a single `employee.position` — whichever the request * when no header was sent or it names nothing — the caller then leaves the
* headers select, else `positions[0]` — and drops the rest. Non-delegate * parent's choice of `employee.position` alone.
* secondary positions vanish entirely, so staff holding two posts resolve to */
* only one post's permissions and every check on the other one rejects them. export const resolveActiveEmployee = (
employees: SnapshotEmployee[],
requestedId: string | undefined,
parentEmployeeId: string | undefined,
): { owner: SnapshotEmployee | undefined; active: SnapshotPosition | undefined } => {
const owner =
(requestedId &&
employees.find((candidate) =>
(candidate.positions ?? []).some((position) =>
identifies(position, requestedId),
),
)) ||
employees.find(
(candidate) => candidate.id && candidate.id === parentEmployeeId,
) ||
employees[0];
const active = requestedId
? (owner?.positions ?? []).find((position) =>
identifies(position, requestedId),
)
: undefined;
return { owner, active };
};
/**
* Like the IAM JwtGuard, but resolves the caller's position honestly.
* *
* This re-attaches the full list as `employee.positions`. `employee.position` * IAM models an employee as holding many positions — and a user as possibly
* is left exactly as the parent set it, so everything reading the single * holding several employee rows — and the login snapshot in
* position today (audit log, delegation deadline) is unaffected; only the * `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` collapses
* permission utils, which prefer the array, see the difference. * that to a single `employee.position` and drops the rest, so staff holding two
* posts resolve to one post's permissions and every check on the other one
* rejects them.
*
* This guard re-reads the snapshot and fixes three things the parent gets wrong:
*
* 1. re-attaches the full position list as `employee.positions`, which is what
* the permission utils union over;
* 2. selects the employee row that actually owns the requested position, so a
* post held on a second employee row is reachable at all;
* 3. sets `employee.position` to the requested position when the parent's
* one-sided id match missed it, keeping `auditUser` in step.
*
* Every correction is skipped unless the snapshot positively resolves it, so an
* unreadable session degrades to the parent's single-position behaviour rather
* than to no position at all.
*/ */
@Injectable() @Injectable()
export class FreightJwtGuard extends IamJwtGuard implements CanActivate { export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
@@ -35,7 +130,7 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
private static readonly CACHE_MAX_ENTRIES = 5_000; private static readonly CACHE_MAX_ENTRIES = 5_000;
private readonly cache = new Map< private readonly cache = new Map<
string, string,
{ positions: SnapshotPosition[]; expiresAt: number } { employees: SnapshotEmployee[]; expiresAt: number }
>(); >();
constructor( constructor(
@@ -48,44 +143,78 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
if (!(await super.canActivate(context))) return false; if (!(await super.canActivate(context))) return false;
const user = context.switchToHttp().getRequest().user as const request = context.switchToHttp().getRequest();
| TCurrentUser const user = request.user as TCurrentUser | undefined;
| undefined; const employee = user?.employee as SnapshotEmployee | undefined;
const employee = user?.employee;
if (!employee || !user?.sessionId) return true; if (!employee || !user?.sessionId) return true;
const positions = await this.positionsForSession( const employees = await this.employeesForSession(user.sessionId);
user.sessionId, if (!employees.length) return true;
const requestedId = request.headers?.[CURRENT_POSITION_ID] as
| string
| undefined;
const { owner, active } = resolveActiveEmployee(
employees,
requestedId,
employee.id, employee.id,
); );
// Never blank out what the parent resolved: an unreadable session or a
// snapshot without positions must degrade to the single-position const ownerPositions = owner?.positions ?? [];
// behaviour, not to no positions at all. // Never blank out what the parent resolved: a snapshot without positions
if (positions.length) { // must degrade to the single-position behaviour, not to no positions.
(employee as { positions?: SnapshotPosition[] }).positions = positions; if (!ownerPositions.length) return true;
// Carries the owning row's id / unitId / organizationId too, which unit
// scoping downstream reads — a swapped row must be swapped whole.
Object.assign(employee, owner);
// `collectPermissionKeys` / `collectPositionTypeKeys` union over this, and
// a user's posts can span several employee rows (one per organization), so
// it carries every row's — otherwise a freight post is invisible whenever
// another organization's row wins the active slot.
employee.positions = collectAllPositions(employees);
// Delegation stays scoped to the active desk: yard scope widens on
// `delegatedPositions`, and someone standing in on another organization's
// row is not this desk's stand-in.
employee.delegatedPositions = ownerPositions.filter(
(position) => position.isDelegate,
);
// The full set, for `/auth/me` — the position picker has to be able to
// offer a desk on a row that is not the active one.
(user as { employeeRows?: SnapshotEmployee[] }).employeeRows = employees;
if (active) {
employee.position = active;
// The parent already built `auditUser` from the position it guessed.
if (request.auditUser) {
request.auditUser.employeeId = employee.id;
request.auditUser.positionId = active.id;
request.auditUser.employeePositionId = active.employeePositionId;
}
} }
return true; return true;
} }
/** Every position the login snapshot holds for this employee. */ /** Every employee row the login snapshot holds for this session. */
private async positionsForSession( private async employeesForSession(
sessionId: string, sessionId: string,
employeeId: string | undefined, ): Promise<SnapshotEmployee[]> {
): Promise<SnapshotPosition[]> {
const now = Date.now(); const now = Date.now();
const hit = this.cache.get(sessionId); const hit = this.cache.get(sessionId);
if (hit && hit.expiresAt > now) return hit.positions; if (hit && hit.expiresAt > now) return hit.employees;
let positions: SnapshotPosition[] = []; let employees: SnapshotEmployee[] = [];
try { try {
const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query( const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query(
`SELECT "userInfo" FROM iam.sessions WHERE id = $1`, `SELECT "userInfo" FROM iam.sessions WHERE id = $1`,
[sessionId], [sessionId],
); );
const employees = rows[0]?.userInfo?.employee ?? []; employees = rows[0]?.userInfo?.employee ?? [];
const match =
employees.find((e) => e?.id && e.id === employeeId) ?? employees[0];
positions = match?.positions ?? [];
} catch { } catch {
return []; // iam unreachable — caller keeps the parent's single position return []; // iam unreachable — caller keeps the parent's single position
} }
@@ -93,9 +222,9 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES) if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
this.cache.clear(); this.cache.clear();
this.cache.set(sessionId, { this.cache.set(sessionId, {
positions, employees,
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS, expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
}); });
return positions; return employees;
} }
} }

View File

@@ -4,24 +4,17 @@ import {
HttpException, HttpException,
Injectable, Injectable,
NestInterceptor, NestInterceptor,
} from '@nestjs/common'; } from "@nestjs/common";
import { Observable, tap } from 'rxjs'; import { Observable, tap } from "rxjs";
import type { Request, Response } from 'express'; import type { Request, Response } from "express";
import { AuditService } from './audit.service'; import { AuditService } from "./audit.service";
import { import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher";
auditEndpointMatcher, import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor";
type MatchedAuditEndpoint, import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer";
} from './audit-endpoint-matcher';
import {
isAuditableActor,
resolveAuditActor,
type AuditActorSource,
} from './audit-actor';
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
/** Methods that can change state. Everything else is never audited. */ /** Methods that can change state. Everything else is never audited. */
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** `error_message` ceiling — stack traces do not belong in this column. */ /** `error_message` ceiling — stack traces do not belong in this column. */
const MAX_ERROR_LENGTH = 2_000; const MAX_ERROR_LENGTH = 2_000;
@@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request. // Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
if (context.getType() !== 'http') return next.handle(); if (context.getType() !== "http") return next.handle();
const httpContext = context.switchToHttp(); const httpContext = context.switchToHttp();
const request = httpContext.getRequest<RequestWithUser>(); const request = httpContext.getRequest<RequestWithUser>();
@@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor {
const startedAt = Date.now(); const startedAt = Date.now();
// The body is captured up front: handlers are free to mutate the DTO they // The body is captured up front: handlers are free to mutate the DTO they
// are given, so reading it after the fact can record post-mutation values. // are given, so reading it after the fact can record post-mutation values.
const requestPayload = sanitizeRequestPayload( const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file);
request.body,
request.files ?? request.file,
);
return next.handle().pipe( return next.handle().pipe(
tap({ tap({
@@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor {
resourceId: matched.resourceId, resourceId: matched.resourceId,
request: requestPayload, request: requestPayload,
ipAddress: resolveIp(request), ipAddress: resolveIp(request),
userAgent: request.headers['user-agent'] ?? null,
requestId: resolveRequestId(request), requestId: resolveRequestId(request),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}); });
@@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null {
if (error instanceof HttpException) { if (error instanceof HttpException) {
const response = error.getResponse(); const response = error.getResponse();
const message = const message =
typeof response === 'string' typeof response === "string"
? response ? response
: ((response as { message?: unknown })?.message ?? error.message); : ((response as { message?: unknown })?.message ?? error.message);
const text = Array.isArray(message) ? message.join('; ') : String(message); const text = Array.isArray(message) ? message.join("; ") : String(message);
return text.slice(0, MAX_ERROR_LENGTH); return text.slice(0, MAX_ERROR_LENGTH);
} }
@@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null {
* entry (the original client) taken. * entry (the original client) taken.
*/ */
function resolveIp(request: Request): string | null { function resolveIp(request: Request): string | null {
const forwarded = request.headers['x-forwarded-for']; const forwarded = request.headers["x-forwarded-for"];
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const candidate = raw?.split(',')[0]?.trim() || request.ip; const candidate = raw?.split(",")[0]?.trim() || request.ip;
if (!candidate) return null; if (!candidate) return null;
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
// accepts but which reads badly and breaks grouping by address. // accepts but which reads badly and breaks grouping by address.
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate;
} }
/** Correlation id from the proxy/tracing layer, when present. */ /** Correlation id from the proxy/tracing layer, when present. */
function resolveRequestId(request: RequestWithUser): string | null { function resolveRequestId(request: RequestWithUser): string | null {
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"];
const value = Array.isArray(header) ? header[0] : header; const value = Array.isArray(header) ? header[0] : header;
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
} }

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import type { SnapshotEmployee } from '../../common/freight-jwt.guard';
import { import {
collectPermissionKeys, collectPermissionKeys,
isSuperAdmin, isSuperAdmin,
@@ -82,8 +83,26 @@ export class FreightMeService {
? [employeeRecord.position] ? [employeeRecord.position]
: []; : [];
const enrichedPositions = await Promise.all( // IAM keeps one employee row per organization, so a user holding a freight
rawPositions.map(async (position) => { // post and a Smart Office post owns two rows. The backoffice reads
// `employee` as an array and the position picker lists what it finds there
// — returning only the active row hides the other desk and makes it
// unselectable. `FreightJwtGuard` leaves the full set here.
const employeeRows = (user as { employeeRows?: SnapshotEmployee[] })
.employeeRows;
// Active row first: the backoffice reads `employee[0]` for
// unitId/organizationId, so the desk the caller is acting as must lead.
const rows: SnapshotEmployee[] = employeeRows?.length
? [
...employeeRows.filter((row) => row.id === employeeRecord?.id),
...employeeRows.filter((row) => row.id !== employeeRecord?.id),
]
: employeeRecord
? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee]
: [];
const enrichPosition = async (position: TokenPosition) => {
const [positionType, positionTypePermissionKeys] = await Promise.all([ const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(position.id), this.lookupPositionType(position.id),
this.lookupPositionTypePermissions(position.id), this.lookupPositionTypePermissions(position.id),
@@ -114,20 +133,24 @@ export class FreightMeService {
positionType, positionType,
}, },
}; };
}), };
const enrichedRows = await Promise.all(
rows.map(async (row) => ({
row,
positions: await Promise.all(
((row.positions ?? []) as TokenPosition[]).map(enrichPosition),
),
})),
); );
const employee = employeeRecord const employee = enrichedRows.map(({ row, positions }) => ({
? [ id: row.id as string,
{ organizationId: row.organizationId as string,
id: employeeRecord.id, unitId: row.unitId as string,
organizationId: employeeRecord.organizationId, name: row.name,
unitId: employeeRecord.unitId, positions: positions.map((p) => p.position),
name: employeeRecord.name, }));
positions: enrichedPositions.map((p) => p.position),
},
]
: [];
// `collectPermissionKeys` reads the raw token (position-level only), so // `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list // union the type-level grants in — the backoffice prefers this flat list
@@ -135,7 +158,9 @@ export class FreightMeService {
const permissionKeys = [ const permissionKeys = [
...new Set([ ...new Set([
...collectPermissionKeys(user), ...collectPermissionKeys(user),
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys), ...enrichedRows.flatMap(({ positions }) =>
positions.flatMap((p) => p.positionTypePermissionKeys),
),
]), ]),
]; ];

View File

@@ -0,0 +1,78 @@
import 'reflect-metadata';
import { NotificationType, type NotifyInput } from '@edr/types';
import type { ChatConfig } from '../../config/chat.config';
import { ChatBridgeService } from './chat-bridge.service';
import type { MatrixClient } from './matrix.client';
const config: ChatConfig = {
enabled: true,
baseUrl: 'https://matrix.test',
publicBaseUrl: 'https://matrix.test',
webUrl: 'https://chat.test',
serverName: 'matrix.test',
jwtSecret: 'secret',
adminToken: 'syt_whatever',
};
function harness(overrides: Partial<ChatConfig> = {}) {
const matrix = {
ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`),
sendMessage: jest.fn(
async (_roomId: string, _body: string, _html?: string) => undefined,
),
};
const service = new ChatBridgeService(
{ ...config, ...overrides },
matrix as unknown as MatrixClient,
);
return { service, matrix };
}
const notification = (type: NotificationType): NotifyInput =>
({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput;
describe('ChatBridgeService', () => {
it('posts every notification type into #freight-alerts', async () => {
// This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded
// `dept-operation` alias, but the reconcile derives dept aliases from the
// IAM position key (`edr_freight_app/opn` shaped), so nothing it created
// ever matched. The bridge made its own empty room and posted there, where
// no employee was a member.
const { service, matrix } = harness();
for (const type of [
NotificationType.REQUEST_SUBMITTED,
NotificationType.CLEARANCE_REVIEW,
NotificationType.GENERIC,
]) {
await service.bridge(notification(type));
}
expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual(
new Set(['freight-alerts']),
);
expect(matrix.sendMessage).toHaveBeenCalledTimes(3);
});
it('does nothing at all when chat is switched off', async () => {
const { service, matrix } = harness({ enabled: false });
await service.bridge(notification(NotificationType.GENERIC));
expect(matrix.ensureRoom).not.toHaveBeenCalled();
expect(matrix.sendMessage).not.toHaveBeenCalled();
});
it('never lets a chat failure escape into the notification that triggered it', async () => {
// Same contract as NotificationInboxService.notify(): bridging is
// best-effort and must not roll back the caller's transaction.
const { service, matrix } = harness();
matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429'));
await expect(
service.bridge(notification(NotificationType.GENERIC)),
).resolves.toBeUndefined();
});
});

View File

@@ -1,28 +1,11 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { NotificationType, type NotifyInput } from '@edr/types'; import type { NotifyInput } from '@edr/types';
import chatConfig from '../../config/chat.config'; import chatConfig from '../../config/chat.config';
import { ALERTS_ROOM } from './chat-provisioning.service';
import { MatrixClient } from './matrix.client'; import { MatrixClient } from './matrix.client';
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
/**
* Best-effort per-type routing to an existing dept room. Anything not listed
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
* guess at which department a type belongs to. Extend as real usage shows
* which types actually want a dept room instead of the shared feed.
*
* `name` matters only if this bridge is the very first thing to touch that
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
* with the position's real name) — ensureRoom never renames an existing
* room, so this must match what ChatProvisioningService would have used.
*/
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
};
/** /**
* Mirrors BACKOFFICE-audience notifications into chat so staff see them * Mirrors BACKOFFICE-audience notifications into chat so staff see them
* without having the inbox open. Hooked once into * without having the inbox open. Hooked once into
@@ -31,6 +14,15 @@ const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: str
* *
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer) * Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
* notifications, which must never land in an internal staff room. * notifications, which must never land in an internal staff room.
*
* Everything goes to one room. This used to route REQUEST_SUBMITTED and
* CLEARANCE_REVIEW to a hardcoded `dept-operation` alias — but the reconcile
* derives dept aliases from the IAM position key, which is `edr_freight_app/opn`
* shaped, so `#dept-operation` matched nothing it creates. The bridge quietly
* created its own empty room and posted every notification into it, where no
* employee was a member. A single room the reconcile actually populates beats
* per-type routing that silently misses; add routing back when real usage asks
* for it, keyed off the same derivation the reconcile uses.
*/ */
@Injectable() @Injectable()
export class ChatBridgeService { export class ChatBridgeService {
@@ -46,8 +38,9 @@ export class ChatBridgeService {
if (!this.config.enabled) return; if (!this.config.enabled) return;
try { try {
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM; // get-or-create as a safety net only: the reconcile creates this room
const roomId = await this.matrix.ensureRoom(room.alias, room.name); // inside the space and joins every position holder to it.
const roomId = await this.matrix.ensureRoom(ALERTS_ROOM.alias, ALERTS_ROOM.name);
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`; const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${ const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : '' input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''

View File

@@ -5,34 +5,57 @@ import type { DataSource } from 'typeorm';
import { ChatProvisioningService } from './chat-provisioning.service'; import { ChatProvisioningService } from './chat-provisioning.service';
import type { MatrixClient } from './matrix.client'; import type { MatrixClient } from './matrix.client';
const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2';
const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f';
const BOT = '@edrbot:m.test';
interface Holder {
positionKey: string;
positionName: string;
userId: string;
userName: string;
}
const holder = (
userId: string,
userName: string,
positionKey: string,
positionName = positionKey,
): Holder => ({ positionKey, positionName, userId, userName });
/**
* `members` maps a room id to who Matrix currently reports as joined, so a
* test can put a leaver in a room and watch what the reconcile does about it.
*/
function harness(holders: Holder[], members: Record<string, string[]> = {}) {
const matrix = {
mxidFor: jest.fn(
(userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`,
),
whoami: jest.fn(async () => BOT),
ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined),
ensureRoom: jest.fn(
async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`,
),
ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined),
joinedMembers: jest.fn(async (roomId: string) => members[roomId] ?? [BOT]),
kick: jest.fn(async (_roomId: string, _mxid: string, _reason: string) => undefined),
lockUser: jest.fn(async (_mxid: string) => undefined),
};
const dataSource = { query: jest.fn(async () => holders) };
const service = new ChatProvisioningService(
dataSource as unknown as DataSource,
matrix as unknown as MatrixClient,
);
return { service, matrix, dataSource };
}
/** /**
* `joinUserRooms` is the only thing standing between a first sign-in and an * `joinUserRooms` is the only thing standing between a first sign-in and an
* empty Element — the reconcile that would otherwise fill the room list runs * empty Element — the reconcile that would otherwise fill the room list runs
* nightly. Both branches below are outages that actually happened on dev. * nightly.
*/ */
describe('ChatProvisioningService.joinUserRooms', () => { describe('ChatProvisioningService.joinUserRooms', () => {
const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2';
const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f';
function harness(holders: unknown[]) {
const matrix = {
mxidFor: jest.fn(
(userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`,
),
ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined),
ensureRoom: jest.fn(
async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`,
),
ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined),
};
const dataSource = { query: jest.fn(async () => holders) };
const service = new ChatProvisioningService(
dataSource as unknown as DataSource,
matrix as unknown as MatrixClient,
);
return { service, matrix };
}
it('creates nothing for a user holding no current position', async () => { it('creates nothing for a user holding no current position', async () => {
// Super Admin on dev: three iam.employees rows, zero employee_positions. // Super Admin on dev: three iam.employees rows, zero employee_positions.
// Synapse still auto-registers the account on JWT login, so the only // Synapse still auto-registers the account on JWT login, so the only
@@ -46,17 +69,12 @@ describe('ChatProvisioningService.joinUserRooms', () => {
expect(matrix.ensureJoined).not.toHaveBeenCalled(); expect(matrix.ensureJoined).not.toHaveBeenCalled();
}); });
it('joins a position holder to the space, #general and their dept room', async () => { it('joins a holder to the space, #general, #freight-alerts and their dept room', async () => {
const { service, matrix } = harness([ const { service, matrix } = harness([
{ holder(NAA, 'naa', 'edr_freight_app/marketer', 'Marketer'),
positionKey: 'edr_freight_app/marketer',
positionName: 'Marketer',
userId: NAA,
userName: 'naa',
},
]); ]);
await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(2); await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(4);
// The account has to exist before the admin join API will touch it — JWT // The account has to exist before the admin join API will touch it — JWT
// auto-registration happens after this runs. // auto-registration happens after this runs.
@@ -65,28 +83,112 @@ describe('ChatProvisioningService.joinUserRooms', () => {
expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([ expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([
'edr-freight', 'edr-freight',
'general', 'general',
'freight-alerts',
'dept-edr_freight_app/marketer', 'dept-edr_freight_app/marketer',
]); ]);
// The space itself is joined, not only the rooms under it: Element shows a // The space itself is joined, not only the rooms under it: Element shows a
// space in the left rail only to its members, so dropping this scatters // space in the left rail only to its members, so dropping this scatters
// every dept room loose into Home. // every dept room loose into Home. #freight-alerts is joined here too, or
// a new hire sees no bridged notification until the nightly reconcile.
expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([ expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([
'!edr-freight:m.test', '!edr-freight:m.test',
'!general:m.test', '!general:m.test',
'!freight-alerts:m.test',
'!dept-edr_freight_app/marketer:m.test', '!dept-edr_freight_app/marketer:m.test',
]); ]);
}); });
it('scopes the position lookup to the one user', async () => { it('scopes the position lookup to the one user', async () => {
const { service } = harness([]); const { service, dataSource } = harness([]);
await service.joinUserRooms(NAA, 'naa'); await service.joinUserRooms(NAA, 'naa');
// Without the third parameter this would reconcile the whole unit on every // Without the third parameter this would reconcile the whole unit on every
// click of "Open EDR Chat". // click of "Open EDR Chat".
const [sql, params] = (service as unknown as { const [sql, params] = dataSource.query.mock.calls[0] as unknown as [
dataSource: { query: jest.Mock }; string,
}).dataSource.query.mock.calls[0]; unknown[],
];
expect(sql).toContain('AND e.user_id = $3'); expect(sql).toContain('AND e.user_id = $3');
expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]); expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]);
}); });
}); });
describe('ChatProvisioningService.reconcile', () => {
it('aborts instead of emptying every room when the holder query returns nothing', async () => {
// Zero holders never means "every employee left at once" — it means the
// query failed, the org/unit keys drifted, or a migration is mid-flight.
// Acting on it would kick every member of every room and lock every
// account, which is exactly the outage this guard exists to prevent.
const { service, matrix } = harness([]);
await expect(service.reconcile()).rejects.toThrow(/no current position holders/i);
expect(matrix.kick).not.toHaveBeenCalled();
expect(matrix.lockUser).not.toHaveBeenCalled();
});
it('locks a departed member rather than deactivating them', async () => {
const leaver = '@gone.999999:m.test';
const { service, matrix } = harness(
[holder(NAA, 'naa', 'marketer', 'Marketer')],
{
'!edr-freight:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
'!general:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
'!freight-alerts:m.test': [BOT, '@naa.03f5eb:m.test'],
'!dept-marketer:m.test': [BOT, '@naa.03f5eb:m.test'],
},
);
const result = await service.reconcile();
expect(matrix.kick.mock.calls.map(([, mxid]) => mxid)).toEqual([leaver, leaver]);
// Locking is reversible; deactivation is not, and on a homeserver with no
// password login it cannot be undone at all.
expect(matrix.lockUser).toHaveBeenCalledTimes(1);
expect(matrix.lockUser).toHaveBeenCalledWith(leaver);
expect(result.locked).toBe(1);
});
it('does not lock someone who only moved between positions', async () => {
const naaMxid = '@naa.03f5eb:m.test';
// naa holds `marketer` now; the room for their old position still lists them.
const { service, matrix } = harness(
[
holder(NAA, 'naa', 'marketer', 'Marketer'),
holder('aaa04914-b7ee-47b3-9c63-4324046a26bd', 'nati', 'opn', 'Operation'),
],
{ '!dept-opn:m.test': [BOT, naaMxid, '@nati.aaa049:m.test'] },
);
const result = await service.reconcile();
expect(matrix.kick).toHaveBeenCalledWith(
'!dept-opn:m.test',
naaMxid,
expect.any(String),
);
// Kicked from one room, still current elsewhere — their account stays open.
expect(matrix.lockUser).not.toHaveBeenCalled();
expect(result.locked).toBe(0);
});
it('refuses to empty a populated room when its desired set is empty', async () => {
// Per-room backstop for the paths the unit-level guard above cannot see.
const { service, matrix } = harness([holder(NAA, 'naa', 'marketer')], {
'!room:m.test': [BOT, '@naa.03f5eb:m.test', '@nati.aaa049:m.test'],
});
const diff = await (
service as unknown as {
syncMembership: (
roomId: string,
desired: Set<string>,
bot: string,
) => Promise<{ joined: number; kicked: string[] }>;
}
).syncMembership('!room:m.test', new Set<string>(), BOT);
expect(diff).toEqual({ joined: 0, kicked: [] });
expect(matrix.kick).not.toHaveBeenCalled();
});
});

View File

@@ -13,6 +13,11 @@ const UNIT_KEY = 'edr_freight_app';
const SPACE_ALIAS = 'edr-freight'; const SPACE_ALIAS = 'edr-freight';
const GENERAL_ALIAS = 'general'; const GENERAL_ALIAS = 'general';
/** Where ChatBridgeService mirrors backoffice notifications. Provisioned here,
* with every position holder in it, so bridged messages land somewhere staff
* actually are — the bridge only ever get-or-creates it as a safety net. */
export const ALERTS_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
interface PositionHolder { interface PositionHolder {
positionKey: string; positionKey: string;
positionName: string; positionName: string;
@@ -24,7 +29,8 @@ export interface ReconcileResult {
rooms: number; rooms: number;
joined: number; joined: number;
kicked: number; kicked: number;
deactivated: number; /** Departed accounts locked — reversible. See {@link MatrixClient.lockUser}. */
locked: number;
} }
/** /**
@@ -55,7 +61,7 @@ export class ChatProvisioningService {
const result = await this.reconcile(); const result = await this.reconcile();
this.logger.log( this.logger.log(
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
`${result.kicked} kicked, ${result.deactivated} deactivated`, `${result.kicked} kicked, ${result.locked} locked`,
); );
} catch (err) { } catch (err) {
// Never throws into the scheduler — chat provisioning must not be able // Never throws into the scheduler — chat provisioning must not be able
@@ -122,6 +128,14 @@ export class ChatProvisioningService {
parentSpaceId: spaceId, parentSpaceId: spaceId,
}); });
await this.matrix.ensureJoined(generalRoomId, mxid); await this.matrix.ensureJoined(generalRoomId, mxid);
// Without this a new hire sees no bridged notification until the nightly
// reconcile puts them in the alerts room.
const alertsRoomId = await this.matrix.ensureRoom(
ALERTS_ROOM.alias,
ALERTS_ROOM.name,
{ parentSpaceId: spaceId },
);
await this.matrix.ensureJoined(alertsRoomId, mxid);
for (const position of positions) { for (const position of positions) {
const roomId = await this.matrix.ensureRoom( const roomId = await this.matrix.ensureRoom(
@@ -132,10 +146,10 @@ export class ChatProvisioningService {
await this.matrix.ensureJoined(roomId, mxid); await this.matrix.ensureJoined(roomId, mxid);
} }
return positions.length + 1; return positions.length + 3; // space + general + alerts
} }
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ /** Force-joins additions, kicks users no longer entitled to this room. */
private async syncMembership( private async syncMembership(
roomId: string, roomId: string,
desiredUserIds: Set<string>, desiredUserIds: Set<string>,
@@ -144,6 +158,19 @@ export class ChatProvisioningService {
const current = await this.matrix.joinedMembers(roomId); const current = await this.matrix.joinedMembers(roomId);
const currentSet = new Set(current.filter((id) => id !== botMxid)); const currentSet = new Set(current.filter((id) => id !== botMxid));
// An empty desired set against a populated room is not "everyone left" —
// it is a query that failed, a key that drifted, or a migration caught
// mid-flight. Acting on it would clear the room and then lock every
// account that was in it. {@link reconcile} guards the same shape at the
// unit level; this is the per-room backstop for the paths it cannot see.
if (desiredUserIds.size === 0 && currentSet.size > 0) {
this.logger.warn(
`Refusing to empty room ${roomId}: desired membership is empty while ` +
`${currentSet.size} member(s) are joined. Left untouched.`,
);
return { joined: 0, kicked: [] };
}
let joined = 0; let joined = 0;
for (const userId of desiredUserIds) { for (const userId of desiredUserIds) {
if (!currentSet.has(userId)) { if (!currentSet.has(userId)) {
@@ -165,6 +192,17 @@ export class ChatProvisioningService {
async reconcile(): Promise<ReconcileResult> { async reconcile(): Promise<ReconcileResult> {
const holders = await this.currentHolders(); const holders = await this.currentHolders();
// The desired state for the whole unit. Empty means the IAM query failed,
// the org/unit keys drifted, or a migration is mid-flight — it never means
// every employee left at once. Continuing would kick every member of every
// room and lock every account, so refuse the run and keep yesterday's
// state, which is wrong at worst by a day.
if (holders.length === 0) {
throw new Error(
`Chat reconcile aborted: no current position holders for ${ORG_KEY}/${UNIT_KEY}. ` +
'Refusing to read that as "remove everyone".',
);
}
const botMxid = await this.matrix.whoami(); const botMxid = await this.matrix.whoami();
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
@@ -173,6 +211,11 @@ export class ChatProvisioningService {
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId, parentSpaceId: spaceId,
}); });
const alertsRoomId = await this.matrix.ensureRoom(
ALERTS_ROOM.alias,
ALERTS_ROOM.name,
{ parentSpaceId: spaceId },
);
const allUserIds = new Set( const allUserIds = new Set(
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)), holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
@@ -189,12 +232,12 @@ export class ChatProvisioningService {
await this.matrix.ensureUser(mxid, h.userName); await this.matrix.ensureUser(mxid, h.userName);
} }
let rooms = 2; // space + general let rooms = 3; // space + general + alerts
let joined = 0; let joined = 0;
let kicked = 0; let kicked = 0;
// A user kicked from anything while holding zero current positions // A user kicked from anything while holding zero current positions
// anywhere in the unit (allUserIds spans every position) is a full // anywhere in the unit (allUserIds spans every position) is a full
// leaver, not just moved between positions — deactivate their account. // leaver, not just moved between positions — lock their account.
const kickedUserIds = new Set<string>(); const kickedUserIds = new Set<string>();
// Space membership follows the org tree exactly like room membership — // Space membership follows the org tree exactly like room membership —
@@ -210,6 +253,11 @@ export class ChatProvisioningService {
kicked += generalDiff.kicked.length; kicked += generalDiff.kicked.length;
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const alertsDiff = await this.syncMembership(alertsRoomId, allUserIds, botMxid);
joined += alertsDiff.joined;
kicked += alertsDiff.kicked.length;
alertsDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const byPosition = new Map<string, { name: string; userIds: Set<string> }>(); const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
for (const h of holders) { for (const h of holders) {
const entry = byPosition.get(h.positionKey) ?? { const entry = byPosition.get(h.positionKey) ?? {
@@ -232,19 +280,19 @@ export class ChatProvisioningService {
diff.kicked.forEach((uid) => kickedUserIds.add(uid)); diff.kicked.forEach((uid) => kickedUserIds.add(uid));
} }
let deactivated = 0; let locked = 0;
for (const userId of kickedUserIds) { for (const userId of kickedUserIds) {
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
try { try {
await this.matrix.deactivateUser(userId); await this.matrix.lockUser(userId);
deactivated += 1; locked += 1;
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`, `Failed to lock departed user ${userId}: ${(err as Error).message}`,
); );
} }
} }
return { rooms, joined, kicked, deactivated }; return { rooms, joined, kicked, locked };
} }
} }

View File

@@ -121,6 +121,68 @@ describe('MatrixClient.verifyServerAdmin', () => {
}); });
}); });
describe('MatrixClient.ensureUser', () => {
it('lifts the lock on a returning employee', async () => {
// A previous reconcile locked them as a leaver. Force-joining them back
// into rooms while they still cannot log in is a silent half-restore.
fetchMock
.mockResolvedValueOnce(
response(200, { name: '@naa.03f5eb:matrix.test', locked: true }),
)
.mockResolvedValueOnce(response(200, {}));
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[1] as [string, { body: string }];
expect(String(url)).toContain('/_synapse/admin/v2/users/');
expect(JSON.parse(init.body)).toEqual({ locked: false });
});
it('leaves an account that is not locked alone', async () => {
fetchMock.mockResolvedValueOnce(
response(200, { name: '@naa.03f5eb:matrix.test', locked: false }),
);
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('MatrixClient rate limiting', () => {
it('retries a 429 after the delay Synapse asks for', async () => {
// The dev outage: a reconcile is a burst of writes, Synapse throttled an
// m.space.child PUT, and one un-retried 429 threw the whole run away.
fetchMock
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
.mockResolvedValueOnce(
response(429, {
errcode: 'M_LIMIT_EXCEEDED',
error: 'Too Many Requests',
retry_after_ms: 1,
}),
)
.mockResolvedValueOnce(response(200, { users: [] }));
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('gives up rather than hanging on a homeserver that only ever 429s', async () => {
fetchMock.mockResolvedValue(
response(429, { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1 }),
);
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check.ok).toBe(false);
expect(check.error).toContain('429');
});
});
describe('MatrixClient.adminCheck', () => { describe('MatrixClient.adminCheck', () => {
it('does not re-hit Synapse on every readiness probe', async () => { it('does not re-hit Synapse on every readiness probe', async () => {
fetchMock fetchMock

View File

@@ -57,6 +57,9 @@ export class MatrixClient implements OnApplicationBootstrap {
/** The token is a deploy-time fact and the readiness probe runs every few /** The token is a deploy-time fact and the readiness probe runs every few
* seconds, so {@link adminCheck} memoises for this long. */ * seconds, so {@link adminCheck} memoises for this long. */
private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000; private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000;
/** Enough to ride out Synapse's limiter; short enough that a genuinely
* wedged homeserver still fails the run rather than hanging it. */
private static readonly MAX_RATE_LIMIT_RETRIES = 5;
private adminCheckCache?: { at: number; result: AdminCheck }; private adminCheckCache?: { at: number; result: AdminCheck };
constructor( constructor(
@@ -95,13 +98,43 @@ export class MatrixClient implements OnApplicationBootstrap {
return this.config.enabled; return this.config.enabled;
} }
/**
* Synapse answers a burst of writes with 429 + `retry_after_ms`, and a
* reconcile is nothing but a burst of writes — one run creates the space,
* #general and a room per position, then force-joins every holder into each.
* The first run against dev tripped the limiter on an `m.space.child` PUT,
* and because nothing retried, that single 429 threw the whole reconcile
* away mid-flight. On the sign-in path ChatSsoService swallows the throw, so
* the only visible symptom was an empty Element.
*
* Honour the delay Synapse asks for rather than guessing at one.
*/
private async fetchWithRetry(
url: string,
init: Parameters<typeof fetch>[1],
): Promise<Awaited<ReturnType<typeof fetch>>> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429 || attempt >= MatrixClient.MAX_RATE_LIMIT_RETRIES) {
return res;
}
// Body is discarded either way — this response is being retried.
const body = (await res.json().catch(() => ({}))) as {
retry_after_ms?: number;
};
await new Promise((resolve) =>
setTimeout(resolve, (Number(body.retry_after_ms) || 1000) + 100),
);
}
}
private async request<T>( private async request<T>(
method: string, method: string,
path: string, path: string,
body?: unknown, body?: unknown,
token: string = this.config.adminToken, token: string = this.config.adminToken,
): Promise<T> { ): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, { const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -125,7 +158,7 @@ export class MatrixClient implements OnApplicationBootstrap {
path: string, path: string,
body: unknown, body: unknown,
): Promise<T> { ): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, { const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -145,7 +178,7 @@ export class MatrixClient implements OnApplicationBootstrap {
path: string, path: string,
token?: string, token?: string,
): Promise<T | null> { ): Promise<T | null> {
const res = await fetch(`${this.config.baseUrl}${path}`, { const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
method, method,
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
}); });
@@ -333,11 +366,18 @@ export class MatrixClient implements OnApplicationBootstrap {
* ("User not found") on an account that doesn't exist yet. * ("User not found") on an account that doesn't exist yet.
*/ */
async ensureUser(userId: string, displayName?: string): Promise<void> { async ensureUser(userId: string, displayName?: string): Promise<void> {
const existing = await this.requestOrNull<{ name: string }>( const existing = await this.requestOrNull<{ name: string; locked?: boolean }>(
'GET', 'GET',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
); );
if (existing) return; if (existing) {
// A returning employee is still locked from the reconcile that saw them
// leave. Force-joining them into rooms while they cannot log in is a
// silent half-restore, and this is the one call that already knows the
// flag — so undo it here rather than making the caller ask again.
if (existing.locked) await this.setLocked(userId, false);
return;
}
await this.request( await this.request(
'PUT', 'PUT',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
@@ -377,12 +417,30 @@ export class MatrixClient implements OnApplicationBootstrap {
); );
} }
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ /**
deactivateUser(userId: string): Promise<void> { * Lock a departed employee out of chat — reversible, unlike deactivation.
*
* This used to call `/_synapse/admin/v1/deactivate`. That revokes sessions
* the same way but cannot be undone in any useful sense on this deployment:
* reactivation wants a password, and `password_config.enabled: false` means
* there is none to set. Room memberships do not come back either. One bad
* reconcile — a half-applied IAM migration, a renamed org key — would have
* destroyed every staff account that way, permanently.
*
* Locking blocks exactly the same access (Synapse rejects the account's
* tokens with M_USER_LOCKED and refuses new logins) and is undone with a
* single PUT — see {@link ensureUser}, which lifts it automatically when
* someone comes back.
*/
lockUser(userId: string): Promise<void> {
return this.setLocked(userId, true);
}
private setLocked(userId: string, locked: boolean): Promise<void> {
return this.request( return this.request(
'POST', 'PUT',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
{ erase: false }, { locked },
); );
} }

View File

@@ -1,3 +1,5 @@
import { POSITION_COOKIE } from "@/shared/utils/positionCookie";
const DEFAULT_PATH = "/"; const DEFAULT_PATH = "/";
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
@@ -32,6 +34,8 @@ export const clearSessionCookies = () => {
AUTH_TOKEN_COOKIE, AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE,
AUTH_USER_COOKIE, AUTH_USER_COOKIE,
POSITION_COOKIE,
// Pre-rename name, still cleared so a stale value cannot outlive logout.
"current-position-id", "current-position-id",
"selected-position-id", "selected-position-id",
].forEach(clearCookie); ].forEach(clearCookie);

View File

@@ -174,6 +174,10 @@ export function DiffRow({
from: string; from: string;
to: string; to: string;
}) { }) {
// No real "before" (field went from unset straight to a value, e.g. the
// onboarding wizard's first save) — show the value alone rather than a
// fake "— → value" that implies a prior state that never existed.
const hadBefore = from !== "—";
const changed = from !== to; const changed = from !== to;
return ( return (
<Stack gap={2}> <Stack gap={2}>
@@ -181,24 +185,24 @@ export function DiffRow({
{label} {label}
</Text> </Text>
<Group gap={8} wrap="nowrap" align="center"> <Group gap={8} wrap="nowrap" align="center">
<Text {hadBefore && (
size="sm" <Text
c="dimmed" size="sm"
td={changed ? "line-through" : undefined} c="dimmed"
style={{ wordBreak: "break-word" }} td={changed ? "line-through" : undefined}
> style={{ wordBreak: "break-word" }}
{from} >
</Text> {from}
{changed && ( </Text>
<>
<Text size="sm" c="edr-muted">
</Text>
<Text size="sm" fw={600} c="edr-text">
{to}
</Text>
</>
)} )}
{hadBefore && changed && (
<Text size="sm" c="edr-muted">
</Text>
)}
<Text size="sm" fw={changed ? 600 : undefined} c={changed ? "edr-text" : "dimmed"}>
{to}
</Text>
</Group> </Group>
</Stack> </Stack>
); );

View File

@@ -22,6 +22,7 @@ import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton"; import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
import { PositionSelect } from "@/record-management/components/positionSelection";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types"; import type { PageMeta } from "./types";
@@ -111,6 +112,10 @@ const FreightDashboardHeader = ({
renders only during a review phase that still has undecided renders only during a review phase that still has undecided
requests, so it never competes for space otherwise. */} requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center"> <Group gap={10} wrap="nowrap" align="center">
{/* Staff holding two posts switch desks here. Renders nothing for the
single-position majority, so it costs the header no space. */}
<PositionSelect />
<DocReviewAlertButton /> <DocReviewAlertButton />
<Tooltip label="Language" withArrow openDelay={300}> <Tooltip label="Language" withArrow openDelay={300}>

View File

@@ -18,6 +18,10 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/shared/common/ui/tooltip"; } from "@/shared/common/ui/tooltip";
import { PositionName } from "../dto/delegation/delegationDto"; import { PositionName } from "../dto/delegation/delegationDto";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
interface BasePosition { interface BasePosition {
id: string; id: string;
employeePositionId: string; employeePositionId: string;
@@ -55,7 +59,7 @@ export const PositionSelect = () => {
? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? []) ? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? [])
: []; : [];
const selectablePositions = allPositions ?? []; const selectablePositions = allPositions ?? [];
const currentPositionCookie = Cookies.get("current-position-id"); const currentPositionCookie = getPositionCookie();
const delegatedPositionCookie = Cookies.get("delegatedPositionId"); const delegatedPositionCookie = Cookies.get("delegatedPositionId");
const activePositionId = const activePositionId =
selectedPositionId || currentPositionCookie || delegatedPositionCookie; selectedPositionId || currentPositionCookie || delegatedPositionCookie;
@@ -85,15 +89,18 @@ export const PositionSelect = () => {
// (useAuthUser already self-heals this cookie for the same reason.) // (useAuthUser already self-heals this cookie for the same reason.)
if ( if (
currentPosition.employeePositionId && currentPosition.employeePositionId &&
Cookies.get("current-position-id") !== currentPosition.employeePositionId getPositionCookie() !== currentPosition.employeePositionId
) { ) {
Cookies.set("current-position-id", currentPosition.employeePositionId); setPositionCookie(currentPosition.employeePositionId);
} }
applyDelegationCookie(currentPosition); applyDelegationCookie(currentPosition);
}, [currentPosition, isLoading, selectedPositionId, setSelectedPositionId]); }, [currentPosition, isLoading, selectedPositionId, setSelectedPositionId]);
if (isLoading || selectablePositions.length === 0) return null; // Below two desks there is nothing to switch between. This now sits in the
// main freight header, so a one-option dropdown would show for every
// single-desk staff member.
if (isLoading || selectablePositions.length < 2) return null;
const handleChange = (value: string) => { const handleChange = (value: string) => {
const selected = selectablePositions.find((pos) => pos.id === value); const selected = selectablePositions.find((pos) => pos.id === value);
@@ -102,7 +109,7 @@ export const PositionSelect = () => {
// dropdown's own value is position.id, which the API does not match on. // dropdown's own value is position.id, which the API does not match on.
setSelectedPositionId(selected?.employeePositionId ?? value); setSelectedPositionId(selected?.employeePositionId ?? value);
if (selected?.employeePositionId) { if (selected?.employeePositionId) {
Cookies.set("current-position-id", selected.employeePositionId); setPositionCookie(selected.employeePositionId);
} }
applyDelegationCookie(selected); applyDelegationCookie(selected);

View File

@@ -12,9 +12,11 @@ import {
} from "@/record-management/dto/userRecords/teetersAndSignatureDto"; } from "@/record-management/dto/userRecords/teetersAndSignatureDto";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { getPositionCookie } from "@/shared/utils/positionCookie";
export const withHeaders = (passPosId: boolean = false) => { export const withHeaders = (passPosId: boolean = false) => {
const unitId = Cookies.get("unit-id"); const unitId = Cookies.get("unit-id");
const positionId = Cookies.get("current-position-id"); const positionId = getPositionCookie();
const projectId = Cookies.get("current-project-id"); const projectId = Cookies.get("current-project-id");
const delegatedPositionId = Cookies.get("delegatedPositionId"); const delegatedPositionId = Cookies.get("delegatedPositionId");

View File

@@ -1,9 +1,11 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { getPositionCookie } from "@/shared/utils/positionCookie";
export const withHeaders = () => { export const withHeaders = () => {
const tenantKey = Cookies.get("tenant-key"); const tenantKey = Cookies.get("tenant-key");
const unitId = Cookies.get("unit-id"); const unitId = Cookies.get("unit-id");
const positionId = Cookies.get("current-position-id"); const positionId = getPositionCookie();
const projectId = Cookies.get("current-project-id"); const projectId = Cookies.get("current-project-id");
const delegatedPositionId = Cookies.get("delegatedPositionId"); const delegatedPositionId = Cookies.get("delegatedPositionId");
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};

View File

@@ -30,6 +30,10 @@ import {
persistRememberMePreference, persistRememberMePreference,
setAuthCookies, setAuthCookies,
} from "@/shared/utils/authPersistence"; } from "@/shared/utils/authPersistence";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage"; import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
interface LoginPayload { interface LoginPayload {
@@ -46,7 +50,7 @@ export const useAuthUser = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { handleError } = useErrorHandler(t); const { handleError } = useErrorHandler(t);
const delegatedPositionId = Cookies.get("delegatedPositionId"); const delegatedPositionId = Cookies.get("delegatedPositionId");
const currentPositionId = Cookies.get("current-position-id"); const currentPositionId = getPositionCookie();
const { const {
setUser, setUser,
@@ -96,7 +100,7 @@ export const useAuthUser = () => {
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId; userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) { if (firstPositionId) {
setSelectedPositionId(firstPositionId); setSelectedPositionId(firstPositionId);
Cookies.set("current-position-id", firstPositionId, cookieOptions); setPositionCookie(firstPositionId, cookieOptions);
} }
} }
@@ -141,7 +145,7 @@ export const useAuthUser = () => {
if (fallbackId) { if (fallbackId) {
setSelectedPositionId(fallbackId); setSelectedPositionId(fallbackId);
Cookies.set("current-position-id", fallbackId); setPositionCookie(fallbackId);
} }
} else if (selectedPositionId) { } else if (selectedPositionId) {
// Self-heal stale cookies that were set to position.id instead of // Self-heal stale cookies that were set to position.id instead of
@@ -157,10 +161,7 @@ export const useAuthUser = () => {
if (matchingPosition?.employeePositionId) { if (matchingPosition?.employeePositionId) {
setSelectedPositionId(matchingPosition.employeePositionId); setSelectedPositionId(matchingPosition.employeePositionId);
Cookies.set( setPositionCookie(matchingPosition.employeePositionId);
"current-position-id",
matchingPosition.employeePositionId,
);
} }
} }
} }

View File

@@ -8,6 +8,7 @@ import {
getAuthCookieOptions, getAuthCookieOptions,
setAuthCookies, setAuthCookies,
} from "@/shared/utils/authPersistence"; } from "@/shared/utils/authPersistence";
import { setPositionCookie } from "@/shared/utils/positionCookie";
import type { VerifiedCitizen } from "@/complaints/types/complaint.types"; import type { VerifiedCitizen } from "@/complaints/types/complaint.types";
function unwrapApiData<T>(payload: T | { data?: T }): T { function unwrapApiData<T>(payload: T | { data?: T }): T {
@@ -138,7 +139,7 @@ export async function persistFaydaRegistrationAuth(
const firstPositionId = const firstPositionId =
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId; userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) { if (firstPositionId) {
Cookies.set("current-position-id", firstPositionId, cookieOptions); setPositionCookie(firstPositionId, cookieOptions);
} }
return userDetails; return userDetails;

View File

@@ -0,0 +1,39 @@
import Cookies from "js-cookie";
type CookieOptions = NonNullable<Parameters<typeof Cookies.set>[2]>;
/**
* Freight's own active-position cookie.
*
* Smart Office is a separate app on the same IAM and it also writes a cookie
* named `current-position-id` — but it stores `position.id` where freight
* stores `employeePositionId`. The two are not interchangeable, so on a shared
* domain each app's login silently overwrote the other's desk selection and the
* loser fell back to `positions[0]`. Freight keeps its own cookie name so both
* can hold a selection at once.
*/
export const POSITION_COOKIE = "freight-current-position-id";
/**
* The pre-rename, shared-with-Smart-Office name. Still read so a session that
* is live across the deploy keeps its desk, and cleared on every write so the
* colliding cookie does not linger.
*/
const LEGACY_POSITION_COOKIE = "current-position-id";
/** The active `employeePositionId`, or undefined when no desk is selected. */
export const getPositionCookie = (): string | undefined =>
Cookies.get(POSITION_COOKIE) ?? Cookies.get(LEGACY_POSITION_COOKIE);
export const setPositionCookie = (
value: string,
options?: CookieOptions,
): void => {
Cookies.set(POSITION_COOKIE, value, options);
Cookies.remove(LEGACY_POSITION_COOKIE);
};
export const clearPositionCookie = (): void => {
Cookies.remove(POSITION_COOKIE);
Cookies.remove(LEGACY_POSITION_COOKIE);
};