diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js index 7b1924b3f..2526dff2b 100644 --- a/.github/scripts/scan.js +++ b/.github/scripts/scan.js @@ -35,23 +35,50 @@ const CONFIG = { "tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs", + "tailwind.config.mjs", "tailwind.js", "postcss.config.js", + "postcss.config.ts", "postcss.config.mjs", "postcss.config.cjs", "babel.config.js", + "babel.config.ts", + "babel.config.mjs", "babel.config.cjs", "next.config.js", + "next.config.ts", "next.config.mjs", "next.config.cjs", + "eslint.config.js", + "eslint.config.ts", + "eslint.config.mjs", + "eslint.config.cjs", "astro.config.mjs", "astro.config.js", "vite.config.js", "vite.config.ts", + "vite.config.mjs", + "vite.config.cjs", "webpack.config.js", "webpack.mix.js", + "svelte.config.js", + "nuxt.config.ts", ], + // Font files — the campaign appends JS payloads to web fonts, which are + // never executed directly but are fetched by the build and used as a + // staging blob. Binary formats, so they are read as latin1. + fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"], + + // Minimum number of \uXXXX escape sequences in one file before it is + // treated as deliberately obfuscated. Legitimate source files use a + // handful at most; PolinRider samples carry 400–600. + maxLegitUnicodeEscapes: 20, + + // Any single line longer than this inside a config file means code was + // appended past the real export block. + maxLegitConfigLineLength: 1000, + // Files intentionally containing malware indicators for scanner logic/tests. // These filenames are skipped before malware rules are evaluated. ignoredFilenames: ["scan.js"], @@ -187,14 +214,166 @@ const RULES = [ }, }, + { + id: "POLINRIDER-018", + severity: "CRITICAL", + description: + "Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes", + test(content) { + // Only printable-ASCII escapes count. Minified vendor bundles legitimately + // carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics + // and CJK ranges — escaping ASCII that could have been written literally + // has no purpose other than hiding it from a reader. + const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter( + (e) => { + const code = parseInt(e.slice(2), 16); + return code >= 0x20 && code <= 0x7e; + }, + ); + return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes + ? [ + `${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`, + ] + : []; + }, + }, + + { + id: "POLINRIDER-019", + severity: "CRITICAL", + description: + "Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant", + test(_content, _filePath, _lines, decoded) { + const iocs = [ + "0xa322e5f3d311d3080e6f0121063e9adc2490ef1a", + "eth.blockscout.com", + "eth_getBlockByNumber", + "eth_getTransactionCount", + "eth_blockNumber", + "ethereum-rpc.publicnode.com", + "eth-mainnet.public.blastapi.io", + "1rpc.io/eth", + "eth.drpc.org", + ]; + const hay = decoded.toLowerCase(); + return iocs.filter((i) => hay.includes(i)); + }, + }, + + { + id: "POLINRIDER-020", + severity: "CRITICAL", + description: + "Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting", + test(_content, _filePath, _lines, decoded) { + const hits = []; + if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded)) + hits.push('spawn("node", ["-e", ])'); + if (/detached\s*:\s*(!0|true)/.test(decoded)) + hits.push("detached child process"); + if (/stdio\s*:\s*["']ignore["']/.test(decoded)) + hits.push('stdio:"ignore" (output suppressed)'); + if (/\beval\s*\(\s*\w+\s*\+/.test(decoded)) + hits.push("eval() of concatenated remote string"); + if (/x-payload-b64/i.test(decoded)) + hits.push("x-payload-b64 C2 response header"); + // Only report when this is a genuine stager, not an isolated keyword. + return hits.length >= 2 ? hits : []; + }, + }, + + { + id: "POLINRIDER-021", + severity: "CRITICAL", + description: + "Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module", + test(_content, _filePath, _lines, decoded) { + const hits = []; + const marker = decoded.match( + /global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/, + ); + if (marker) hits.push(`campaign marker: "${marker[1]}"`); + if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded)) + hits.push("global. = require"); + if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded)) + hits.push("global. = module"); + return hits; + }, + }, + + { + id: "POLINRIDER-022", + severity: "CRITICAL", + description: + "Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review", + test(content, filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (!CONFIG.fontExtensions.includes(ext)) return []; + + const hits = []; + const magic = content.slice(0, 4); + const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" }; + if (expected[ext] && magic !== expected[ext]) { + hits.push( + `bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`, + ); + } + const codeMarkers = [ + "require(", + "eval(", + "child_process", + "global.", + "createRequire", + "process.env", + ]; + const found = codeMarkers.filter((m) => content.includes(m)); + if (found.length > 0) { + hits.push(`embedded JS markers: ${found.join(", ")}`); + } + return hits; + }, + }, + + { + id: "POLINRIDER-023", + severity: "CRITICAL", + description: + ".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run", + test(content, filePath) { + if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/"))) + return []; + const hits = []; + if (/"runOn"\s*:\s*"folderOpen"/.test(content)) + hits.push('runOn: "folderOpen" (executes without user action)'); + const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/); + if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1])) + hits.push(`command: "${cmd[1]}"`); + return hits.length >= 1 ? hits : []; + }, + }, + + { + id: "POLINRIDER-024", + severity: "HIGH", + description: + ".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them", + test(content, filePath) { + if (path.basename(filePath) !== ".gitignore") return []; + const lines = content.split("\n").map((l) => l.trim()); + return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map( + (a) => `.gitignore hides "${a}"`, + ); + }, + }, + // ── Tier 2: Behavioral / structural indicators ───────────────────────────── { id: "POLINRIDER-009", severity: "HIGH", description: - "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers", - test(content) { + "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers", + test(_content, _filePath, _lines, decoded) { const endpoints = [ "trongrid.io", "aptoslabs.com", @@ -202,7 +381,7 @@ const RULES = [ "bsc-rpc.publicnode.com", "eth_getTransactionByHash", ]; - return endpoints.filter((e) => content.includes(e)); + return endpoints.filter((e) => decoded.includes(e)); }, }, @@ -210,11 +389,10 @@ const RULES = [ id: "POLINRIDER-010", severity: "HIGH", description: - "Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly", - test(content) { - return /windowsHide\s*:\s*true/.test(content) - ? ["windowsHide:true found"] - : []; + "Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0", + test(_content, _filePath, _lines, decoded) { + const m = decoded.match(/windowsHide\s*:\s*(true|!0)/); + return m ? [`windowsHide:${m[1]} found`] : []; }, }, @@ -237,7 +415,10 @@ const RULES = [ severity: "HIGH", description: "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views", - test(content, _filePath, lines) { + test(_content, filePath, lines) { + // Binary assets contain long runs of 0x20 as padding — not an indicator. + if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase())) + return []; const hits = []; lines.forEach((line, i) => { const spaceRun = line.match(/\s{100,}/); @@ -269,6 +450,27 @@ const RULES = [ }, }, + { + id: "POLINRIDER-025", + severity: "HIGH", + description: + "Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file", + test(_content, filePath, lines) { + const base = path.basename(filePath).toLowerCase(); + if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base)) + return []; + const hits = []; + lines.forEach((line, i) => { + if (line.length > CONFIG.maxLegitConfigLineLength) { + hits.push( + `line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`, + ); + } + }); + return hits; + }, + }, + { id: "POLINRIDER-014", severity: "HIGH", @@ -330,25 +532,58 @@ const RULES = [ // ─── Scanner Engine ──────────────────────────────────────────────────────────── +/** + * Resolve \uXXXX and \xXX escapes so string-matching rules see the real API + * calls. The campaign writes every literal as escapes specifically to defeat + * grep, so rules that match on plain text must run against this form. + * The decoded text is appended to the original rather than replacing it, so a + * rule can still match either representation with one pass. + */ +function deobfuscate(content) { + if (!/\\[ux]/.test(content)) return content; + const decoded = content + .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => { + try { + return String.fromCodePoint(parseInt(h, 16)); + } catch { + return m; + } + }) + .replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)), + ) + .replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)), + ); + return content + "\n/* --- deobfuscated --- */\n" + decoded; +} + function scanFile(filePath) { if (shouldIgnoreFile(filePath)) { return { filePath, findings: [], skipped: true }; } + // Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and + // ASCII payload searches both work without mangling the content. + const isBinary = CONFIG.fontExtensions.includes( + path.extname(filePath).toLowerCase(), + ); + let content; try { - content = fs.readFileSync(filePath, "utf8"); + content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8"); } catch (err) { return { filePath, error: err.message, findings: [] }; } const lines = content.split("\n"); + const decoded = deobfuscate(content); const findings = []; for (const rule of RULES) { let matches; try { - matches = rule.test(content, filePath, lines); + matches = rule.test(content, filePath, lines, decoded); } catch (err) { matches = [`[rule error: ${err.message}]`]; } @@ -388,6 +623,7 @@ function walkDir(dir, results = []) { } else if (entry.isFile() && !shouldIgnoreFile(full)) { const ext = path.extname(entry.name).toLowerCase(); const base = entry.name.toLowerCase(); + const rel = full.replace(/\\/g, "/"); // Scan all JS/TS config files + any file matching a targeted name const isTargetedName = CONFIG.targetedFilenames.some( @@ -399,8 +635,18 @@ function walkDir(dir, results = []) { const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( ext, ); + const isFont = CONFIG.fontExtensions.includes(ext); + const isGitignore = base === ".gitignore"; + const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel); - if (isTargetedName || isPersistenceArtifact || isJsLike) { + if ( + isTargetedName || + isPersistenceArtifact || + isJsLike || + isFont || + isGitignore || + isVsCodeTask + ) { results.push(full); } } @@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) { return infected.length > 0; } +// ─── Self-test ───────────────────────────────────────────────────────────────── + +/** + * Runs the rule set against synthetic samples. Guards the two properties that + * matter: the live payload is still caught, and minified vendor bundles that + * legitimately contain \uXXXX escapes are still not flagged. + * Run with: node scan.js --self-test + */ +function selfTest() { + const os = require("os"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-")); + const write = (name, body) => { + const p = path.join(tmp, name); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + return p; + }; + + const esc = (s) => + [...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join(""); + + const cases = []; + + // 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager. + cases.push({ + name: "infected postcss.config.js", + file: write( + "infected/postcss.config.js", + `export default { plugins: {} };` + + " ".repeat(300) + + `global.i="A8-4299";global.r=require;global.m=module;` + + `const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` + + `S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` + + `I="${esc("https://eth.blockscout.com/api")}";` + + `rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` + + `spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`, + ), + expect: true, + }); + + // 2. Clean config — must stay silent. + cases.push({ + name: "clean postcss.config.js", + file: write( + "clean/postcss.config.js", + "export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n", + ), + expect: false, + }); + + // 3. Minified vendor bundle with many non-ASCII escapes — must stay silent. + cases.push({ + name: "minified vendor bundle", + file: write( + "vendor/emoji.min.js", + "var e=[" + + Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") + + "];", + ), + expect: false, + }); + + // 4. Font carrying an appended JS payload. + cases.push({ + name: "trojanised .woff2", + file: write( + "fonts/bad.woff2", + "wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);', + ), + expect: true, + }); + + // 5. Clean font — correct magic, no code markers. + cases.push({ + name: "clean .woff2", + file: write("fonts/good.woff2", "wOF2" + "".repeat(200)), + expect: false, + }); + + // 6. .gitignore hiding persistence artifacts. + cases.push({ + name: ".gitignore with persistence artifacts", + file: write( + "ignore/.gitignore", + "node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n", + ), + expect: true, + }); + + // 7. VS Code task auto-executing on folder open. + cases.push({ + name: ".vscode/tasks.json auto-exec", + file: write( + "vscode/.vscode/tasks.json", + JSON.stringify({ + version: "2.0.0", + tasks: [ + { + label: "build", + command: "node -e require('http')", + runOptions: { runOn: "folderOpen" }, + }, + ], + }), + ), + expect: true, + }); + + let failed = 0; + for (const c of cases) { + const { findings } = scanFile(c.file); + const detected = findings.length > 0; + const ok = detected === c.expect; + if (!ok) failed++; + const ids = findings.map((f) => f.id).join(", ") || "none"; + console.log( + ` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` + + `${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`, + ); + } + + fs.rmSync(tmp, { recursive: true, force: true }); + console.log( + failed === 0 + ? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n` + : `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`, + ); + process.exit(failed === 0 ? 0 : 1); +} + // ─── CLI Entry Point ─────────────────────────────────────────────────────────── function main() { const args = process.argv.slice(2); + if (args.includes("--self-test")) return selfTest(); const jsonFlag = args.includes("--json"); const outputFileIdx = args.indexOf("--output"); const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bdb4c5ff5..f8a1eb68e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,8 +10,16 @@ permissions: contents: read jobs: + # Supply-chain gate. Every other job depends on this, so a malware detection + # blocks the entire deploy before any build, migration or container starts. + malware-scan: + name: Malware gate + uses: ./.github/workflows/malware-scan.yml + secrets: inherit + detect-changes: name: Detect changed services + needs: malware-scan runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} @@ -90,7 +98,7 @@ jobs: deploy: name: Deploy ${{ matrix.service }} - needs: detect-changes + needs: [malware-scan, detect-changes] if: ${{ needs.detect-changes.outputs.matrix != '[]' }} runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 000000000..751118839 --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,161 @@ +name: Malware Scan + +# Supply-chain malware gate for the PolinRider / Famous Chollima campaign. +# +# Runs standalone on every push and pull request, and is also called by +# deploy.yml as a required first job — a detection fails this workflow, which +# blocks every downstream deploy job from starting. + +on: + push: + # dev and staging are already gated through deploy.yml's required + # malware-scan job — no need to scan those pushes twice. + branches-ignore: + - dev + - staging + pull_request: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: false + TELEGRAM_CHAT_ID: + required: false + +permissions: + contents: read + +# A detection on a ref should not be raced by a newer run of the same ref. +concurrency: + group: malware-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan for PolinRider malware + # Plain `self-hosted` — GitHub applies this label to every self-hosted + # runner automatically. The scan is host-agnostic, unlike the deploy jobs + # which pin to a branch-specific runner. + runs-on: self-hosted + outputs: + infected: ${{ steps.scan.outputs.infected }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify scanner rules still work + # Fails if someone weakens a detection rule or introduces a false + # positive against minified vendor bundles. + run: node .github/scripts/scan.js --self-test + + - name: Scan repository + id: scan + run: | + set -uo pipefail + + # Actions runs this with `bash -e`, so the non-zero exit must be + # caught with `||` rather than read back from $? afterwards. + STATUS=0 + node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$? + + if [ "$STATUS" -eq 0 ]; then + echo "infected=false" >> "$GITHUB_OUTPUT" + echo "No malware detected." + exit 0 + fi + + echo "infected=true" >> "$GITHUB_OUTPUT" + + # Human-readable run for the log, so the failure is legible in the UI. + node .github/scripts/scan.js . || true + exit 1 + + - name: Build alert message + id: message + if: failure() && steps.scan.outputs.infected == 'true' + run: | + set -euo pipefail + + FILES=$(jq -r '.results[].filePath' malware-report.json | head -20) + COUNT=$(jq -r '.infectedFiles' malware-report.json) + RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json) + + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Notify Telegram + if: failure() && steps.scan.outputs.infected == 'true' + env: + BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + TEXT: ${{ steps.message.outputs.message }} + run: | + set -uo pipefail + + if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then + echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification." + exit 0 + fi + + # No parse_mode: the payload contains characters Telegram's Markdown + # parser would reject, and a failed notification is worse than plain text. + HTTP=$(curl -sS -o /tmp/tg.out -w '%{http_code}' \ + -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${CHAT_ID}" \ + --data-urlencode "text=${TEXT}" \ + --data-urlencode "disable_web_page_preview=true") || true + + if [ "${HTTP:-000}" != "200" ]; then + echo "::warning::Telegram notification failed (HTTP ${HTTP:-000}): $(cat /tmp/tg.out 2>/dev/null | head -c 300)" + else + echo "Telegram alert sent." + fi + rm -f /tmp/tg.out + + - name: Upload scan report + if: always() && hashFiles('malware-report.json') != '' + uses: actions/upload-artifact@v4 + with: + name: malware-report-${{ github.run_id }} + path: malware-report.json + retention-days: 30 + + - name: Job summary + if: always() + run: | + set -uo pipefail + RESULT="${{ steps.scan.outputs.infected }}" + + if [ "$RESULT" = "true" ]; then + { + echo "## 🚨 Malware detected — deployment blocked" + echo "" + echo '```' + jq -r '.results[] | .filePath, (.findings[] | " [\(.id)] \(.severity) — \(.description)")' \ + malware-report.json 2>/dev/null | head -100 || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + elif [ "$RESULT" = "false" ]; then + echo "## ✅ No malware detected" >> "$GITHUB_STEP_SUMMARY" + else + # The scan step never produced a verdict — treat as inconclusive + # rather than clean, so a broken scanner is never read as a pass. + echo "## ⚠️ Scan did not complete — verdict unknown" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitignore b/.gitignore index 18fdae9c2..63784b865 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml + diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 16ee9cd57..96af26034 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -122,3 +122,7 @@ FAYDA_SESSION_TTL_MINUTES=10 EXPIRATION_TIME=15 ALGORITHM=RS256 EMAIL_QUEUE=email_queue + +# Shared secret for service-to-service calls (payment microservice <-> freight). +# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. +SERVICE_AUTH_TOKEN=change-me diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c032f2feb..c98990d25 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -53,7 +53,6 @@ import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; -import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; import { FreightAuthModule } from "./modules/auth/freight-auth.module"; import { EDR_FREIGHT_APPLICATION, @@ -100,6 +99,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; import { ComplianceModule } from "./modules/compliance/compliance.module"; import { IncidentsModule } from "./modules/incidents/incidents.module"; import { ProcurementModule } from "./modules/procurement/procurement.module"; +import { FacilitiesModule } from "./modules/facilities/facilities.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; @@ -220,7 +220,6 @@ if (!process.env.APPLICATION_NAME) { HealthModule, RuleEngineModule, BackofficeModule, - DemoPermissionsModule, FreightAuthModule, PaymentModule, //New Modules @@ -240,6 +239,7 @@ if (!process.env.APPLICATION_NAME) { ComplianceModule, IncidentsModule, ProcurementModule, + FacilitiesModule, GpsTrackingModule, FirstMileModule, LastMileModule, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index aba4ce495..49bd31c61 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -1,7 +1,11 @@ import { applyDecorators, UseGuards } from '@nestjs/common'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; -import { FreightPermissionGuard } from './freight-permission.guard'; +import { + FreightPermissionGuard, + MixedAudienceGuard, + PortalCustomerGuard, +} from './freight-permission.guard'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; export const BookingStaff = (permission: string | string[]) => @@ -18,8 +22,30 @@ export const BookingStaff = (permission: string | string[]) => * Read-only reference data (yard dropdowns, search filters): any signed-in * staff. Menu/page visibility stays permission-gated in the frontend — this * only lets forms populate their lookups. + * Deprecated for new routes — it never checked the caller was staff. Prefer + * BookingStaff() or MixedAudience(); kept for routes not yet swept. */ -export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); +export const StaffReference = () => + applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([]))); + +/** Portal routes: customer accounts only; ownership scoping stays in services. */ +export const PortalCustomer = () => + applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard)); + +/** + * Routes both audiences call (sign, shared document reads, handover): staff + * need one of the given permissions, customers pass through to the service's + * ownership checks. + */ +export const MixedAudience = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + MixedAudienceGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index 68def6440..db6275c07 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -8,7 +8,19 @@ import { } from '@nestjs/common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { hasFreightPermission } from './freight-permission.util'; +import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; + +// String literals on purpose (same reasoning as login-audience.middleware.ts): +// the values are wire-format constants from iam.users.user_type, and importing +// the vendored enum couples us to its package layout for no gain. +const CUSTOMER_USER_TYPES = ['individual', 'external_organization']; + +const userTypeOf = (user: TCurrentUser): string | undefined => + (user as { userType?: string }).userType; + +/** Staff routes are employee-only; a missing userType (stale session) also fails. */ +const isEmployee = (user: TCurrentUser): boolean => + userTypeOf(user) === 'employee' || isSuperAdmin(user); export function FreightPermissionGuard( permissions: string[], @@ -19,11 +31,14 @@ export function FreightPermissionGuard( const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); const user = request.user; - if (!permissions?.length) return true; if (!user) { throw new UnauthorizedException('Authentication required'); } + if (!isEmployee(user)) { + throw new ForbiddenException('Staff account required'); + } + if (!permissions?.length) return true; if (permissions.some((p) => hasFreightPermission(user, p))) { return true; } @@ -36,3 +51,57 @@ export function FreightPermissionGuard( return FreightPermissionsGuard; } + +/** Portal routes: customer accounts only (individual / external organization). */ +@Injectable() +export class PortalCustomerGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + if (!CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) { + throw new ForbiddenException('Customer account required'); + } + return true; + } +} + +/** + * Routes both audiences legitimately call (contract sign, shared document + * reads, warehouse handover). Staff callers must hold one of the given + * permissions; customer callers pass here and are scoped by the service's + * ownership checks. + */ +export function MixedAudienceGuard(permissions: string[]): Type { + @Injectable() + class MixedAudiencesGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + if (CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) { + return true; + } + if (!isEmployee(user)) { + throw new ForbiddenException('Unrecognized account type'); + } + if ( + !permissions?.length || + permissions.some((p) => hasFreightPermission(user, p)) + ) { + return true; + } + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return MixedAudiencesGuard; +} diff --git a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts index 9165e54d5..2d2863dd5 100644 --- a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts +++ b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts @@ -20,8 +20,12 @@ export class ServiceAuthGuard implements CanActivate { private warned = false; constructor() { - if (!this.token && process.env.NODE_ENV === "production") { - throw new Error("SERVICE_AUTH_TOKEN must be set in production"); + // Fail closed everywhere: a missing secret must never silently open the + // internal payment surface. Local dev can opt out explicitly. + if (!this.token && process.env.ALLOW_UNAUTH_INTERNAL !== "true") { + throw new Error( + "SERVICE_AUTH_TOKEN must be set (or ALLOW_UNAUTH_INTERNAL=true for local dev)", + ); } } @@ -29,7 +33,7 @@ export class ServiceAuthGuard implements CanActivate { if (!this.token) { if (!this.warned) { this.logger.warn( - "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", + "ALLOW_UNAUTH_INTERNAL=true — internal endpoints are UNGUARDED (dev only)", ); this.warned = true; } diff --git a/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts new file mode 100644 index 000000000..10c3d4799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Audit trail for wagon status flips (Available ⇄ Maintenance and any other + * bulk-status change): who moved which wagon from what to what, when, and why. + * Written inside the same transaction as the status update itself. + */ +export class WagonStatusLogs3310000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_status_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL REFERENCES freight.wagons(id), + from_status varchar(30) NOT NULL, + to_status varchar(30) NOT NULL, + changed_by_user_id uuid, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon + ON freight.wagon_status_logs (wagon_id, created_at DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`); + } +} diff --git a/apps/edr-freight-api/src/modules/ai/ai.controller.ts b/apps/edr-freight-api/src/modules/ai/ai.controller.ts index eb87fe97d..d71795066 100644 --- a/apps/edr-freight-api/src/modules/ai/ai.controller.ts +++ b/apps/edr-freight-api/src/modules/ai/ai.controller.ts @@ -1,15 +1,13 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Public } from '@edr/api-common'; +import { BookingStaff } from '../../common/booking-guards'; import { AiBookingRequestDto } from './dto/ai-booking-request.dto'; import { AiBookingResult } from './types/ai-booking-result.type'; import { MockAiService } from './mock-ai.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -// @Public() — TODO: swap for real guard when this leaves dev/testing. -// Safe while public: extracts + validates text only, never creates or -// dispatches anything. -@Public() +@BookingStaff(FREIGHT_PERMS.bookings.view) @ApiTags('AI Assistant (mock)') @Controller('ai') export class AiController { diff --git a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts index e7fbfd771..e2f06f065 100644 --- a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts @@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ListUsersService } from './list-users.service'; -import { StaffReference } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @ApiTags('auth') @Controller('staff/users') @@ -12,7 +13,7 @@ export class ListUsersController { constructor(private readonly service: ListUsersService) {} @Get() - @StaffReference() + @BookingStaff(FREIGHT_PERMS.staff.users.view) @ApiOperation({ summary: 'List IAM users (paginated) for backoffice pickers', }) diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index b3305ba68..d25fb74ba 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -10,18 +10,19 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { BackofficeService } from "./backoffice.service"; import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; @ApiTags("backoffice") @Controller("backoffice") -@FreightAdmin() export class BackofficeController { constructor(private readonly backofficeService: BackofficeService) {} @Post("organizations/:orgId/users") + @BookingStaff([FREIGHT_PERMS.staff.employeeRegistration.create, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Create an organization user without assigning positions" }) createOrganizationUser( @Param("orgId", ParseUUIDPipe) organizationId: string, @@ -31,6 +32,7 @@ export class BackofficeController { } @Get("organizations/:orgId/employees") + @BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Get deduplicated organization employees for backoffice" }) getOrganizationEmployees( @Param("orgId", ParseUUIDPipe) organizationId: string, @@ -44,6 +46,7 @@ export class BackofficeController { } @Get("organizations/:orgId/employee-users/:userId/roles") + @BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) getEmployeeUserRoles( @Param("orgId", ParseUUIDPipe) organizationId: string, @@ -53,6 +56,7 @@ export class BackofficeController { } @Put("organizations/:orgId/employee-users/:userId/roles") + @BookingStaff([FREIGHT_PERMS.staff.roleAssignment.replace, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" }) replaceEmployeeUserRoles( @Param("orgId", ParseUUIDPipe) organizationId: string, diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 49772316a..d72528310 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -12,14 +12,15 @@ import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { BookingView } from "../../common/booking-guards"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@BookingView() +@BookingStaff(FREIGHT_PERMS.invoices.view) @ApiBearerAuth() export class BillingController { constructor( @@ -51,6 +52,7 @@ export class BillingController { } @Get("invoices/:id/document") + @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed invoice PDF" }) async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.billingService.document(id); @@ -58,6 +60,7 @@ export class BillingController { } @Get("invoices/:id/receipt") + @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.billingService.receipt(id); diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 981233df0..90f6a32a4 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; +import { PortalCustomer } from "../../common/booking-guards"; import { type AuthUserPayload, resolveAuthUserId, @@ -28,6 +29,7 @@ import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto"; @ApiTags("billing") @ApiBearerAuth() @Controller("billing") +@PortalCustomer() export class PortalBillingController { constructor(private readonly billingService: BillingService) {} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index df81fd5f6..ba6fece8c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -279,7 +279,9 @@ export class BookingPricingService { return { lineItems, - totalAmount: total, + // Grand total is billed in whole currency units — fractional line sums + // (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD. + totalAmount: Math.round(total), currency: booking.paymentCurrency, usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index cad7c6061..b7eb526a3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -15,15 +15,15 @@ import { UnauthorizedException, UploadedFile, UploadedFiles, - UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { BookingStaff, BookingView, + MixedAudience, + PortalCustomer, WagonCancellationView, } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @@ -167,6 +167,7 @@ export class BookingsController { ) {} @Post() + @MixedAudience([]) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @@ -207,6 +208,7 @@ export class BookingsController { } @Patch(":id") + @MixedAudience([]) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -223,6 +225,7 @@ export class BookingsController { } @Get() + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @@ -298,6 +301,7 @@ export class BookingsController { } @Get("my") + @PortalCustomer() @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: @@ -328,6 +332,7 @@ export class BookingsController { } @Get("reference-data") + @MixedAudience([]) @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { @@ -335,6 +340,7 @@ export class BookingsController { } @Get("by-reference/:reference") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Get booking by reference" }) async findByReference( @Param("reference") reference: string, @@ -352,6 +358,7 @@ export class BookingsController { } @Get(":id") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Get booking by ID" }) async findOne( @Param("id", ParseUUIDPipe) id: string, @@ -373,6 +380,7 @@ export class BookingsController { } @Get(':id/available-days') + @MixedAudience([]) @ApiOperation({ summary: 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', @@ -395,6 +403,7 @@ export class BookingsController { } @Get(':id/day-availability') + @MixedAudience([]) @ApiOperation({ summary: 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + @@ -420,6 +429,7 @@ export class BookingsController { } @Get(':id/mile-summary') + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', }) @@ -447,6 +457,7 @@ export class BookingsController { } @Post(':id/customer-truck-assignment') + @PortalCustomer() @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @@ -462,6 +473,7 @@ export class BookingsController { } @Get(':id/customer-truck-assignment/freight-order') + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', @@ -488,6 +500,7 @@ export class BookingsController { } @Get(':id/carriage-acceptance-sheet') + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', @@ -652,6 +665,11 @@ export class BookingsController { } @Get(':id/customer-trucks') + @MixedAudience([ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.operations, + FREIGHT_PERMS.warehouseInventory.view, + ]) @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( @Param('id', ParseUUIDPipe) id: string, @@ -665,6 +683,7 @@ export class BookingsController { } @Post(':id/customer-trucks') + @PortalCustomer() @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) async addCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @@ -679,6 +698,7 @@ export class BookingsController { } @Post(':id/customer-trucks/bulk') + @PortalCustomer() @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) async bulkAddCustomerTrucks( @Param('id', ParseUUIDPipe) id: string, @@ -693,6 +713,7 @@ export class BookingsController { } @Patch(':id/customer-trucks/:assignmentId') + @PortalCustomer() @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @@ -708,6 +729,7 @@ export class BookingsController { } @Delete(':id/customer-trucks/:assignmentId') + @PortalCustomer() @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) async removeCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @@ -722,6 +744,7 @@ export class BookingsController { } @Get(':id/customer-trucks/loadable-containers') + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) async loadableContainers( @Param('id', ParseUUIDPipe) id: string, @@ -735,6 +758,7 @@ export class BookingsController { } @Post(':id/customer-trucks/:assignmentId/load') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) async loadCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @@ -749,6 +773,7 @@ export class BookingsController { } @Post(':id/customer-trucks/:assignmentId/depart') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', }) @@ -766,6 +791,7 @@ export class BookingsController { } @Get(':id/received-pending-grn') + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) async receivedPendingGrn( @Param('id', ParseUUIDPipe) id: string, @@ -779,6 +805,7 @@ export class BookingsController { } @Post(':id/generate-grn') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', @@ -796,6 +823,7 @@ export class BookingsController { } @Get(':id/tracking') + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Shipment tracking timeline for a booking", description: @@ -818,6 +846,7 @@ export class BookingsController { } @Delete(":id") + @MixedAudience([]) @HttpCode(204) @ApiOperation({ summary: "Soft-delete DRAFT booking" }) remove(@Param("id", ParseUUIDPipe) id: string) { @@ -825,6 +854,7 @@ export class BookingsController { } @Post(":id/documents") + @MixedAudience([]) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) @@ -837,6 +867,7 @@ export class BookingsController { } @Post(":id/generate-price") + @MixedAudience([]) @ApiOperation({ summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: @@ -848,6 +879,7 @@ export class BookingsController { } @Post(":id/submit") + @MixedAudience([]) @ApiOperation({ summary: "Customer submit booking", description: @@ -859,6 +891,7 @@ export class BookingsController { } @Post(":id/confirm-submit") + @MixedAudience([]) @ApiOperation({ summary: "Confirm submit after price change", description: @@ -870,6 +903,7 @@ export class BookingsController { } @Post(":id/reject") + @PortalCustomer() @ApiOperation({ summary: "Customer reject price estimate", description: @@ -900,6 +934,7 @@ export class BookingsController { } @Get(':id/clearance') + @MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments]) @ApiOperation({ summary: "Document-clearance grid (required docs + upload + GL review status)", @@ -909,6 +944,7 @@ export class BookingsController { } @Post(":id/clearance/documents") + @PortalCustomer() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -925,7 +961,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Customer requests the operation; GL ET also resubmits here on the + // customer's behalf after operations requests changes (BookingChangesRequestedAlert). @Post(":id/clearance/proceed") + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: "Customer requests operation with a schedule day " + @@ -944,6 +983,7 @@ export class BookingsController { } @Get(":id/export-trains") + @MixedAudience([]) @ApiOperation({ summary: "Export train picker: the day's export trains on the booking's corridor " + @@ -1145,6 +1185,7 @@ export class BookingsController { } @Post(':id/clearance/draft-declaration/accept') + @PortalCustomer() @ApiOperation({ summary: 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', @@ -1155,6 +1196,7 @@ export class BookingsController { } @Post(':id/clearance/draft-declaration/change') + @PortalCustomer() @ApiOperation({ summary: 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', @@ -1181,6 +1223,7 @@ export class BookingsController { } @Post(':id/clearance/duty-slip') + @PortalCustomer() @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) @@ -1332,7 +1375,7 @@ export class BookingsController { } @Post(":id/government-expedite") - @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @BookingStaff(FREIGHT_PERMS.bookings.governmentExpedite) @ApiOperation({ summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", }) @@ -1356,6 +1399,7 @@ export class BookingsController { } @Get(":id/contract/view") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOkResponse({ type: ContractViewDto }) @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( @@ -1367,6 +1411,7 @@ export class BookingsController { } @Get(":id/contract/document") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( @Param("id", ParseUUIDPipe) id: string, @@ -1382,6 +1427,7 @@ export class BookingsController { } @Get(":id/contract") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( @Param("id", ParseUUIDPipe) id: string, @@ -1391,7 +1437,7 @@ export class BookingsController { } @Post(":id/contract/sign") - @UseGuards(JwtGuard) + @MixedAudience(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( @Param("id", ParseUUIDPipe) id: string, @@ -1412,18 +1458,21 @@ export class BookingsController { } @Get(":id/contract/signatures") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "List contract signatures" }) getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } @Get(":id/summary") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Contract summary string for dashboard" }) getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } @Post(":id/customer/sign") + @PortalCustomer() @ApiOperation({ summary: "Customer digital signature (deprecated — use POST contract/sign)", }) @@ -1504,6 +1553,7 @@ export class BookingsController { } @Post(":id/cancel-hold") + @PortalCustomer() @ApiOperation({ summary: "Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " + @@ -1518,18 +1568,21 @@ export class BookingsController { } @Post(":id/consolidation") + @PortalCustomer() @ApiOperation({ summary: "Request freight consolidation" }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } @Delete(":id/consolidation") + @PortalCustomer() @ApiOperation({ summary: "Remove consolidation pairing" }) removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } @Get(":id/consolidation") + @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Get consolidation details" }) getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 3f04bcd7a..658629592 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -25,6 +25,7 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.se import { BookingTransitionService } from './booking-transition.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { BookingAllocationController } from './booking-allocation.controller'; import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; @@ -92,7 +93,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; SignaturesModule, registerExchangeModule(), ], - controllers: [BookingsController], + controllers: [BookingsController, BookingAllocationController], providers: [ BookingsService, BookingsRepository, diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 1c9d75487..810d4d46a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,7 +11,6 @@ import { HttpCode, HttpStatus, UseInterceptors, - UseGuards, UploadedFiles, BadRequestException, NotFoundException, @@ -20,8 +19,7 @@ import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; -import { BookingStaff } from "../../common/booking-guards"; +import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards"; import { assertFreightPermission, hasFreightPermission, @@ -118,6 +116,7 @@ export class CompaniesController { } @Get("getInfo") + @PortalCustomer() @ApiOperation({ summary: "Get company info for the current user" }) async getInfo( @CurrentUser() user: CurrentIamUser, @@ -131,6 +130,7 @@ export class CompaniesController { } @Get("profile") + @PortalCustomer() @ApiOperation({ summary: "Get flattened profile for the settings page" }) async getProfile( @CurrentUser() user: CurrentIamUser, @@ -146,6 +146,7 @@ export class CompaniesController { } @Get("profile/change-request") + @PortalCustomer() @ApiOperation({ summary: "Current user's open profile change request (pending/rejected)", }) @@ -161,6 +162,7 @@ export class CompaniesController { } @Post("company-profiles/:profileId/reapply") + @PortalCustomer() @ApiOperation({ summary: "Resubmit a rejected operational role for approval (→ pending)", }) @@ -176,6 +178,7 @@ export class CompaniesController { } @Get("dashboard") + @PortalCustomer() @ApiOperation({ summary: "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user", @@ -191,6 +194,7 @@ export class CompaniesController { } @Post("fetch-etrade-info") + @PortalCustomer() @ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) async fetchETradeInfo( @CurrentUser() user: CurrentIamUser, @@ -211,6 +215,7 @@ export class CompaniesController { } @Patch("profile") + @PortalCustomer() @ApiOperation({ summary: "Update profile (flattened settings page)" }) async updateProfile( @CurrentUser() user: CurrentIamUser, @@ -220,6 +225,7 @@ export class CompaniesController { } @Post("company-profiles") + @PortalCustomer() @ApiOperation({ summary: "Add operational profile(s) (importer/exporter/forwarder) to the current user's company", @@ -236,6 +242,7 @@ export class CompaniesController { } @Post("onboarding/start") + @PortalCustomer() @ApiOperation({ summary: "Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", @@ -261,6 +268,7 @@ export class CompaniesController { } @Post("company-profile") + @PortalCustomer() @ApiOperation({ summary: "Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", @@ -278,6 +286,7 @@ export class CompaniesController { } @Post("company-profiles/:profileId/license") + @PortalCustomer() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -298,6 +307,7 @@ export class CompaniesController { } @Post("company-profiles/:profileId/license/:fileId/replace") + @PortalCustomer() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -324,6 +334,7 @@ export class CompaniesController { } @Delete("company-profiles/:profileId/license/:fileId") + @PortalCustomer() @ApiOperation({ summary: "Remove a business-license file (staged for review on an approved company).", @@ -341,6 +352,7 @@ export class CompaniesController { } @Get("company-profiles/:profileId/license") + @PortalCustomer() @ApiOperation({ summary: "List business-license documents (with review state) for a profile", }) @@ -352,6 +364,7 @@ export class CompaniesController { } @Get("poa-delegation") + @PortalCustomer() @ApiOperation({ summary: "List the Power of Attorney delegation letter (with review state) for the current user's company", @@ -363,6 +376,7 @@ export class CompaniesController { } @Post("poa-delegation") + @PortalCustomer() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -383,6 +397,7 @@ export class CompaniesController { } @Delete("poa-delegation/:fileId") + @PortalCustomer() @ApiOperation({ summary: "Remove the Power of Attorney delegation letter (staged for review on an approved company).", @@ -395,6 +410,7 @@ export class CompaniesController { } @Post("identity/fayda/complete") + @PortalCustomer() @ApiOperation({ summary: "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + @@ -409,6 +425,7 @@ export class CompaniesController { } @Post("identity/gm/same-as-owner") + @PortalCustomer() @ApiOperation({ summary: "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + @@ -421,6 +438,7 @@ export class CompaniesController { } @Delete("identity/gm") + @PortalCustomer() @ApiOperation({ summary: "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + @@ -433,6 +451,7 @@ export class CompaniesController { } @Delete("identity/fayda/poa") + @PortalCustomer() @ApiOperation({ summary: "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + @@ -445,6 +464,7 @@ export class CompaniesController { } @Patch("onboarding-step") + @PortalCustomer() @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) async setOnboardingStep( @@ -455,6 +475,7 @@ export class CompaniesController { } @Get("onboarding/requirements") + @PortalCustomer() @ApiOperation({ summary: "What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)", @@ -466,6 +487,7 @@ export class CompaniesController { } @Post("onboarding/complete") + @PortalCustomer() @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) async completeOnboarding( @CurrentUser() user: CurrentIamUser, @@ -477,6 +499,7 @@ export class CompaniesController { // Used by portal @Post("create") + @PortalCustomer() @ApiOperation({ summary: "Create a company with its associated external profile (onboarding)", @@ -591,7 +614,11 @@ export class CompaniesController { * permission still needs the applicant's documents. */ @Get(":companyId/documents") - @UseGuards(JwtGuard) + @MixedAudience([ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ]) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -661,6 +688,7 @@ export class CompaniesController { } @Post(":companyId/documents") + @MixedAudience(FREIGHT_PERMS.customers.update) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 2a5715647..0e5949300 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -1,5 +1,7 @@ import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { ComplianceService } from './compliance.service'; import { CreateComplianceRecordDto, @@ -9,10 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity'; @ApiTags('Vehicle Compliance') @Controller('compliance') +@BookingStaff(FREIGHT_PERMS.compliance.view) export class ComplianceController { constructor(private readonly complianceService: ComplianceService) {} @Post() + @BookingStaff(FREIGHT_PERMS.compliance.manage) @ApiOperation({ summary: 'Create a compliance record' }) create(@Body() dto: CreateComplianceRecordDto) { return this.complianceService.create(dto); @@ -40,12 +44,14 @@ export class ComplianceController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.compliance.manage) @ApiOperation({ summary: 'Update a compliance record' }) update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) { return this.complianceService.update(id, dto); } @Delete(':id') + @BookingStaff(FREIGHT_PERMS.compliance.manage) @ApiOperation({ summary: 'Soft-delete a compliance record' }) remove(@Param('id') id: string) { return this.complianceService.remove(id); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 8cf1c5671..6cba17f61 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -10,7 +10,8 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ContractTemplatesService } from "./contract-templates.service"; import { CreateArticleDto, @@ -25,29 +26,44 @@ import { export class ContractTemplatesController { constructor(private readonly service: ContractTemplatesService) {} - // Reads stay open to authenticated staff (the backoffice Templates tab); + // Reads are staff-only (the backoffice Templates tab is the only consumer); // writes are admin-guarded like other freight configuration resources. @Get() + @BookingStaff([ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, + ]) @ApiOperation({ summary: "List the six contract document templates" }) list() { return this.service.list(); } @Get(":code") + @BookingStaff([ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, + ]) @ApiOperation({ summary: "Get one contract template by code" }) getByCode(@Param("code") code: string) { return this.service.getByCode(code); } @Patch(":code") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { return this.service.update(code, dto); } @Post(":code/preview") + @BookingStaff([ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, + ]) @ApiOperation({ summary: "Render an HTML preview of the template against mock contract data", }) @@ -61,21 +77,21 @@ export class ContractTemplatesController { /* ------------------------- article routes ------------------------- */ @Put(":code/articles") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { return this.service.replaceArticles(code, dto.articles); } @Post(":code/articles") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Add an article to the template" }) addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { return this.service.addArticle(code, dto); } @Patch(":code/articles/:articleId") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Update an article's title or body" }) updateArticle( @Param("code") code: string, @@ -86,7 +102,7 @@ export class ContractTemplatesController { } @Delete(":code/articles/:articleId") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Remove an article from the template" }) removeArticle( @Param("code") code: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 706d3cdee..0ff9b7c24 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -14,12 +14,10 @@ import { UnauthorizedException, UploadedFiles, UploadedFile, - UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { @@ -32,7 +30,7 @@ import { } from '@nestjs/swagger'; import { actorLabel } from '../warehouses/current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FREIGHT_PERMS, @@ -127,6 +125,7 @@ export class ContractsController { } @Get('booking-requests/:reqId') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'A single shipment request' }) getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) { return this.bookingRequestService.findOne(reqId); @@ -159,6 +158,7 @@ export class ContractsController { } @Post('booking-requests/:reqId/cancel') + @PortalCustomer() @ApiOperation({ summary: 'Customer cancels their own pending shipment request' }) cancelBookingRequest( @Param('reqId', ParseUUIDPipe) reqId: string, @@ -168,6 +168,7 @@ export class ContractsController { } @Post(':id/booking-requests') + @PortalCustomer() @ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' }) submitBookingRequest( @Param('id', ParseUUIDPipe) id: string, @@ -178,12 +179,14 @@ export class ContractsController { } @Get(':id/booking-requests') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'List the shipment requests on a contract' }) listBookingRequests(@Param('id', ParseUUIDPipe) id: string) { return this.bookingRequestService.listForContract(id); } @Post() + @MixedAudience([]) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' }) @@ -203,6 +206,7 @@ export class ContractsController { } @Get() + @MixedAudience([]) @ApiOperation({ summary: 'List contracts (paginated)' }) async findAll( @Query() filter: FilterContractDto, @@ -247,6 +251,7 @@ export class ContractsController { } @Get('my') + @PortalCustomer() @ApiOperation({ summary: "List the current customer's contracts" }) async findMy( @CurrentUser() user: AuthUserPayload, @@ -301,6 +306,7 @@ export class ContractsController { } @Get(':id') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' }) async findOne( @Param('id', ParseUUIDPipe) id: string, @@ -319,6 +325,7 @@ export class ContractsController { } @Patch(':id') + @MixedAudience([]) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ @@ -337,6 +344,7 @@ export class ContractsController { } @Delete(':id') + @MixedAudience([]) @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT contract' }) remove(@Param('id', ParseUUIDPipe) id: string) { @@ -344,6 +352,7 @@ export class ContractsController { } @Post(':id/documents') + @MixedAudience([]) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' }) @@ -355,18 +364,21 @@ export class ContractsController { } @Post(':id/generate-price') + @MixedAudience([]) @ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' }) generatePrice(@Param('id', ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(':id/submit') + @MixedAudience([]) @ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } @Post(':id/confirm-submit') + @MixedAudience([]) @ApiOperation({ summary: 'Confirm submit after a price change' }) confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); @@ -427,10 +439,7 @@ export class ContractsController { // real boundary: it admits only the approver whose step is currently pending // (edit rights hand off down the chain on each approval). @Put(':id/document/articles') - @BookingStaff([ - FREIGHT_PERMS.contracts.view, - ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), - ]) + @BookingStaff(FREIGHT_PERMS.contracts.editDocument) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', @@ -515,6 +524,7 @@ export class ContractsController { } @Post(':id/cancel') + @PortalCustomer() @ApiOperation({ summary: 'Customer cancels their own contract (blocked while a booking is live)', }) @@ -591,6 +601,7 @@ export class ContractsController { } @Get(':id/contract/view') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' }) async getContractView( @Param('id', ParseUUIDPipe) id: string, @@ -630,6 +641,7 @@ export class ContractsController { } @Get(':id/contract/document') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Download contract PDF' }) async downloadContractDocument( @Param('id', ParseUUIDPipe) id: string, @@ -653,7 +665,7 @@ export class ContractsController { } @Post(':id/contract/send-signing-otp') - @UseGuards(JwtGuard) + @MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff)) @ApiOperation({ summary: "Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", @@ -666,7 +678,7 @@ export class ContractsController { } @Post(':id/contract/sign') - @UseGuards(JwtGuard) + @MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff)) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) async signContract( @Param('id', ParseUUIDPipe) id: string, @@ -691,6 +703,7 @@ export class ContractsController { } @Post(':id/renew') + @PortalCustomer() @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) async renew( @Param('id', ParseUUIDPipe) id: string, @@ -712,12 +725,14 @@ export class ContractsController { // ── Pre-booking clearance (Path B, doc §15.2.1) ──────────────────────────── @Get(':id/clearance') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' }) getClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.getClearanceView(id); } @Post(':id/clearance/documents') + @PortalCustomer() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) @@ -901,6 +916,7 @@ export class ContractsController { } @Post(':id/clearance/duty/dispute') + @PortalCustomer() @ApiOperation({ summary: 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', @@ -914,6 +930,7 @@ export class ContractsController { } @Post(':id/clearance/duty-slip') + @PortalCustomer() @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) @@ -1057,15 +1074,23 @@ export class ContractsController { // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── @Post(':id/bookings') + // Path A is a customer flow — both audiences must reach the service, whose + // assertGate decides per role. Staff still need contracts:create_booking. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', }) - createBooking( + async createBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only book on their own contract. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } // The service decides the execution path from the contract: // Path A (customs disabled) → customer/staff create; status checks apply. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready. @@ -1078,15 +1103,25 @@ export class ContractsController { } @Post(':id/bookings/initiate') + // Customer initiates their own ONE_TIME instance; GL initiates on customs + // contracts — the service's assertGate decides per role, so both audiences + // must reach it. Staff still need contracts:create_booking. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) - initiateBooking( + async initiateBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only initiate on their own contract; the service's + // assertGate then decides what a customer may do on it. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } return this.contractBookingService.initiateUnderContract( id, { contractRouteId: dto?.contractRouteId }, @@ -1096,16 +1131,24 @@ export class ContractsController { } @Post(':id/bookings/:bookingId/complete') + // Customers complete their own initiated (non-customs) instances; the + // service keeps customs completion GL-only via the actor's permissions. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', }) - completeBooking( + async completeBooking( @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only complete bookings on their own contract. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( @@ -1117,6 +1160,7 @@ export class ContractsController { } @Post(':id/validate-shipment') + @MixedAudience([FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.contracts.view]) @ApiOperation({ summary: 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', @@ -1132,6 +1176,7 @@ export class ContractsController { } @Get(':id/capacity') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)', @@ -1144,12 +1189,14 @@ export class ContractsController { // ── Clearance milestones (doc §11.3, §12.2) ──────────────────────────────── @Get(':id/milestones') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' }) listContractMilestones(@Param('id', ParseUUIDPipe) id: string) { return this.milestoneService.listForContract(id); } @Get('bookings/:bookingId/milestones') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' }) listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.milestoneService.listForBooking(bookingId); @@ -1283,7 +1330,7 @@ export class ContractsController { } @Post('bookings/:bookingId/final-invoice') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceRaise) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ @@ -1310,6 +1357,7 @@ export class ContractsController { } @Post('bookings/:bookingId/final-invoice/approve') + @PortalCustomer() @ApiOperation({ summary: 'Customer approves the drafted final invoice — unlocks the payment slip', }) @@ -1324,6 +1372,7 @@ export class ContractsController { } @Post('bookings/:bookingId/final-invoice-slip') + @PortalCustomer() @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) @@ -1335,10 +1384,7 @@ export class ContractsController { } @Post('bookings/:bookingId/final-invoice/confirm') - @BookingStaff([ - FREIGHT_PERMS.contracts.clearanceDjActions, - FREIGHT_PERMS.contracts.clearanceEtActions, - ]) + @BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceConfirm) @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) confirmFinalInvoicePaid( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -1381,6 +1427,7 @@ export class ContractsController { } @Post('bookings/:bookingId/second-duty-slip') + @PortalCustomer() @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) @@ -1406,6 +1453,7 @@ export class ContractsController { } @Post('bookings/:bookingId/duty-slip') + @PortalCustomer() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) @@ -1422,6 +1470,7 @@ export class ContractsController { } @Get('bookings/:bookingId/incidents') + @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.glOperationsService.listIncidents(bookingId); diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts deleted file mode 100644 index 6f425e1bb..000000000 --- a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; - -import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard"; - -@ApiTags("demo-permissions") -@Controller() -export class DemoPermissionsController { - @Get("test_user1") - @ApiOperation({ summary: "Permission demo (can:demo:user1)" }) - @UseGuards(PermissionGuard(["can:demo:user1"])) - testUser1() { - return { ok: true, permission: "can:demo:user1" }; - } - - @Get("test_user2") - @ApiOperation({ summary: "Permission demo (can:demo:user2)" }) - @UseGuards(PermissionGuard(["can:demo:user2"])) - testUser2() { - return { ok: true, permission: "can:demo:user2" }; - } -} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts deleted file mode 100644 index db73ed728..000000000 --- a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from "@nestjs/common"; - -import { DemoPermissionsController } from "./demo-permissions.controller"; - -@Module({ - controllers: [DemoPermissionsController], -}) -export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts index aad904557..aaf552f40 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -14,7 +14,8 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto"; @@ -58,14 +59,14 @@ export class DropdownSettingsController { } @Post() - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Create a new dropdown setting" }) create(@Body() dto: CreateDropdownSettingDto) { return this.service.create(dto); } @Patch(":id") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Update a dropdown setting's metadata" }) update( @Param("id", ParseUUIDPipe) id: string, @@ -75,7 +76,7 @@ export class DropdownSettingsController { } @Delete(":id") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Soft-delete a dropdown setting" }) @HttpCode(HttpStatus.NO_CONTENT) remove(@Param("id", ParseUUIDPipe) id: string) { @@ -85,7 +86,7 @@ export class DropdownSettingsController { /* ------------------------- option routes ------------------------- */ @Put(":id/options") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Replace the full option list for a setting" }) replaceOptions( @Param("id", ParseUUIDPipe) id: string, @@ -95,7 +96,7 @@ export class DropdownSettingsController { } @Post(":id/options") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Append a single option to a setting" }) addOption( @Param("id", ParseUUIDPipe) id: string, @@ -105,7 +106,7 @@ export class DropdownSettingsController { } @Patch("options/:optionId") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Update a single option" }) updateOption( @Param("optionId", ParseUUIDPipe) optionId: string, @@ -115,7 +116,7 @@ export class DropdownSettingsController { } @Delete("options/:optionId") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Soft-delete a single option" }) @HttpCode(HttpStatus.NO_CONTENT) removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) { diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index a6c08a317..001fc90d2 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; import { ExchangeSettingsService } from "./exchange-settings.service"; @@ -14,7 +15,7 @@ export class ExchangeSettingsController { constructor(private readonly service: ExchangeSettingsService) {} @Get() - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Current USD→ETB fallback rate and CBE feed health", }) @@ -32,7 +33,7 @@ export class ExchangeSettingsController { } @Patch() - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 25fbbc365..451c4d03b 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateFacilityDto } from './dto/create-facility.dto'; import { UpdateFacilityDto } from './dto/update-facility.dto'; import { Facility } from './entities/facility.entity'; @@ -8,10 +10,12 @@ import { FacilitiesService } from './facilities.service'; @ApiTags('Facilities') @Controller('facilities') +@BookingStaff(FREIGHT_PERMS.facilities.view) export class FacilitiesController { constructor(private readonly facilitiesService: FacilitiesService) {} @Post() + @BookingStaff(FREIGHT_PERMS.facilities.manage) @ApiOperation({ summary: 'Create a new facility' }) async create(@Body() createFacilityDto: CreateFacilityDto): Promise { return this.facilitiesService.create(createFacilityDto); @@ -30,6 +34,7 @@ export class FacilitiesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.facilities.manage) @ApiOperation({ summary: 'Update a facility' }) async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise { return this.facilitiesService.update(id, updateFacilityDto); @@ -37,6 +42,7 @@ export class FacilitiesController { @Delete(':id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.facilities.manage) @ApiOperation({ summary: 'Delete a facility (soft delete)' }) async remove(@Param('id') id: string): Promise { return this.facilitiesService.remove(id); diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index 661339902..ab4ae98f0 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -13,7 +13,8 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; @@ -53,14 +54,14 @@ export class FileUploadSettingsController { } @Post() - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Create a new file upload setting" }) create(@Body() dto: CreateFileUploadSettingDto) { return this.service.create(dto); } @Patch(":id") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Update a file upload setting's metadata" }) update( @Param("id", ParseUUIDPipe) id: string, @@ -70,7 +71,7 @@ export class FileUploadSettingsController { } @Delete(":id") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Soft-delete a file upload setting" }) @HttpCode(HttpStatus.NO_CONTENT) remove(@Param("id", ParseUUIDPipe) id: string) { @@ -80,7 +81,7 @@ export class FileUploadSettingsController { /* ------------------------- field routes ------------------------- */ @Put(":id/fields") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Replace the full field list for a setting" }) replaceFields( @Param("id", ParseUUIDPipe) id: string, @@ -90,7 +91,7 @@ export class FileUploadSettingsController { } @Post(":id/fields") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Append a single field to a setting" }) addField( @Param("id", ParseUUIDPipe) id: string, @@ -100,7 +101,7 @@ export class FileUploadSettingsController { } @Patch("fields/:fieldId") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Update a single field" }) updateField( @Param("fieldId", ParseUUIDPipe) fieldId: string, @@ -110,7 +111,7 @@ export class FileUploadSettingsController { } @Delete("fields/:fieldId") - @FreightAdmin() + @BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: "Soft-delete a single field" }) @HttpCode(HttpStatus.NO_CONTENT) removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) { diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index fc58a731c..139da9fbd 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -16,6 +16,7 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; +import { MixedAudience } from "../../common/booking-guards"; import { FilesService } from "./files.service"; @ApiTags("files") @@ -25,6 +26,7 @@ export class FilesController { constructor(private readonly filesService: FilesService) {} @Get(":fileId") + @MixedAudience([]) // Authenticated: no @Public, so the global JwtGuard applies. Unguessable file // UUIDs are obscurity, not authorization — raw byte streams must require auth. // Browser inline previews (/