Files
edr-platform/apps/edr-freight-api/src/modules/exports/exports.controller.ts
Nathnael 01ea05f013 fix(auth): keep secondary positions in permission checks
IAM lets an employee hold several positions, but the vendored JwtGuard
collapses employee.positions[] down to a single employee.position and
drops the rest. Non-delegate secondary positions vanished entirely, so
staff on two posts resolved to one post's permissions and every check
on the other rejected them.

FreightJwtGuard re-attaches the full list from the same session
snapshot the parent guard already read, so nothing extra is fetched
per request beyond a cached session lookup. employee.position is left
untouched, keeping audit logging and delegation unaffected.
collectPermissionKeys and collectPositionTypeKeys now union across
every position, and /me returns them all.

Verified against a real two-position user (djibouti-gl-director +
djibouti-gl-chief) on the local dev database:

  /me positions                     1   -> 2
  /me permissionKeys                17  -> 28
  GET /api/interchange-documents    403 -> 200
  GET /api/trains                   403 -> 200

11 permissions recovered, none lost. Six single-position users return
byte-identical payloads before and after.
2026-08-25 12:00:43 +00:00

157 lines
6.0 KiB
TypeScript

import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CurrentUser } from '@edr/api-common';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { resolveFilterOptions } from './export-filter.util';
import {
EXPORT_MIME,
formatRowCap,
pickByKey,
resolveExportFormat,
resolveRowLimit,
} from './export-request.util';
import { ExportRunnerService } from './export-runner.service';
import { DATASETS, getDataset } from './export.registry';
import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types';
import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service';
/** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */
type RawExportQuery = Record<string, string | undefined>;
const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
/**
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
* the schema — they never leave the server.
*/
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
key: dataset.key,
title: dataset.title,
description: dataset.description,
group: dataset.group,
groups: dataset.groups,
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
key,
label,
type,
group,
default: isDefault,
})),
filters: dataset.filters,
formats: ['csv', 'xlsx', 'pdf'],
caps: CAPS,
defaultSort: dataset.defaultSort,
});
/**
* Generic table export. One dataset per major table, each describing far more
* fields than its list page shows — including related-entity detail.
*/
@ApiTags('Exports')
@ApiBearerAuth()
@Controller('exports')
@UseGuards(FreightJwtGuard)
export class ExportsController {
constructor(
private readonly runner: ExportRunnerService,
private readonly writer: TabularExportService,
private readonly userTradeAccessService: UserTradeAccessService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@Get()
@ApiOperation({ summary: 'List datasets the caller has permission to export' })
async catalog(@CurrentUser() user: TCurrentUser): Promise<ExportCatalogEntry[]> {
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
return Promise.all(
allowed.map(async (d) => ({
...toCatalogEntry(d),
filters: await resolveFilterOptions(d.filters, this.dataSource),
})),
);
}
@Get(':key/count')
@ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' })
async count(
@Param('key') key: string,
@Query() query: RawExportQuery,
@CurrentUser() user: TCurrentUser,
): Promise<{ total: number; caps: typeof CAPS }> {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const total = await this.runner.count(dataset, query, directions);
return { total, caps: CAPS };
}
@Get(':key/download')
@ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' })
async download(
@Param('key') key: string,
@Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const fields = this.resolveFields(dataset, query.fields);
const rows = await this.runner.run(dataset, fields, query, directions, {
cap: formatRowCap(format),
limit: resolveRowLimit(format, query.limit),
});
const doc = {
title: dataset.title,
description: dataset.description,
label: `export:${dataset.key}`,
columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })),
rows,
};
const buffer =
format === 'pdf'
? await this.writer.toPdf(doc)
: format === 'csv'
? await this.writer.toCsv(doc)
: await this.writer.toXlsx(doc);
const mime = EXPORT_MIME[format];
const stamp = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`);
res.setHeader('Content-Type', mime.type);
res.send(buffer);
}
/**
* Requested fields, whitelisted against the dataset. No `fields=` means the
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
* all of them on an unparameterised call is nobody's intent.
*/
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
if (raw?.trim()) {
const picked = pickByKey(dataset.fields, raw);
// pickByKey falls back to everything when nothing matched; for a dataset
// the safer read of "all keys unknown" is still the default set.
if (picked.length !== dataset.fields.length) return picked;
}
const defaults = dataset.fields.filter((f) => f.default);
return defaults.length ? defaults : dataset.fields;
}
private resolve(key: string, user: TCurrentUser): ExportDataset {
const dataset = getDataset(key);
if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);
// Export rides the dataset's own list-page view permission: if you may see
// these rows, you may export them.
assertFreightPermission(user, dataset.permission);
return dataset;
}
}