Compare commits
1 Commits
main
..
9d2ec1e626
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d2ec1e626 |
@@ -1,65 +0,0 @@
|
||||
// `apf-portal` devcontainer — VSCode Dev Containers spec.
|
||||
//
|
||||
// Pinned Node + pnpm via corepack via the official typescript-node image
|
||||
// (Bookworm base). The container has access to the host docker daemon
|
||||
// through the docker socket (`docker-outside-of-docker` feature), so
|
||||
// `infra/local/dev.sh up` on the HOST creates the postgres/redis/otel
|
||||
// containers and this container connects to them through the shared
|
||||
// `apf-portal-dev` network.
|
||||
//
|
||||
// Workflow:
|
||||
// 1. On the host: ./infra/local/dev.sh up (creates the network)
|
||||
// 2. In VSCode: F1 → "Dev Containers: Reopen in Container"
|
||||
// 3. Inside the container: pnpm exec nx serve portal-bff (connects to postgres:5432)
|
||||
{
|
||||
"name": "apf-portal",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"installZsh": false,
|
||||
"configureZshAsDefaultShell": false
|
||||
}
|
||||
},
|
||||
// `initializeCommand` runs on the HOST before the container starts.
|
||||
// Fail fast if the infra network isn't up — otherwise the BFF would
|
||||
// get connection-refused on every Postgres query inside the container.
|
||||
"initializeCommand": "docker network inspect apf-portal-dev > /dev/null 2>&1 || { echo '❌ Bring up infra first: ./infra/local/dev.sh up' >&2 ; exit 1 ; }",
|
||||
// Attach to the same Compose network as postgres/redis/otel so DNS
|
||||
// resolution (`postgres:5432`) works from inside the container.
|
||||
"runArgs": ["--network=apf-portal-dev"],
|
||||
"forwardPorts": [4200, 4300, 3000, 8081, 16686, 4317, 4318],
|
||||
"portsAttributes": {
|
||||
"4200": { "label": "portal-shell", "onAutoForward": "notify" },
|
||||
"4300": { "label": "portal-admin", "onAutoForward": "notify" },
|
||||
"3000": { "label": "portal-bff", "onAutoForward": "notify" },
|
||||
"8081": { "label": "pgweb", "onAutoForward": "silent" },
|
||||
"16686": { "label": "jaeger UI", "onAutoForward": "silent" },
|
||||
"4317": { "label": "otel gRPC", "onAutoForward": "silent" },
|
||||
"4318": { "label": "otel HTTP", "onAutoForward": "silent" }
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"remoteUser": "node",
|
||||
"containerEnv": {
|
||||
// Nx + Angular CLI memory ceiling — 4 GB. Bumping past the default
|
||||
// 2 GB avoids OOM in `nx build` on large monorepos.
|
||||
"NODE_OPTIONS": "--max-old-space-size=4096"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"Angular.ng-template",
|
||||
"Prisma.prisma",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"EditorConfig.EditorConfig"
|
||||
],
|
||||
"settings": {
|
||||
"terminal.integrated.defaultProfile.linux": "bash",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs once, on the container's first start. Idempotent.
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Enabling corepack (pnpm shim from package.json#packageManager)…"
|
||||
corepack enable
|
||||
|
||||
echo "▶ pnpm install --frozen-lockfile…"
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
echo "✓ Devcontainer ready."
|
||||
echo " Suggested next: pnpm exec nx run-many -t lint test --parallel=3"
|
||||
@@ -44,15 +44,6 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm ci:check
|
||||
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation".
|
||||
# Cheap (parses ~workspace .ts files via the TypeScript
|
||||
# compiler API, ~1s on a warm cache) so it rides the same
|
||||
# `check` job rather than spinning up a new one. The
|
||||
# script's own unit tests run first — if they fail the gate
|
||||
# itself is broken and the actual catalogue check would be
|
||||
# noise.
|
||||
- run: pnpm ci:catalogue-drift:test
|
||||
- run: pnpm ci:catalogue-drift
|
||||
|
||||
scan:
|
||||
runs-on: [self-hosted, on-prem]
|
||||
|
||||
@@ -31,15 +31,6 @@ pnpm-debug.log*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Tenant-private Entra group GUIDs per ADR-0025 (commit only the .example.json)
|
||||
infra/*-tenant.entra.json
|
||||
!infra/*-tenant.entra.example.json
|
||||
|
||||
# Tenant-private Entra per-persona oids consumed by prisma/seed.ts
|
||||
# (ADR-0026 §"Seed personas"). Same pattern — commit only the example.
|
||||
infra/*-tenant.personas.json
|
||||
!infra/*-tenant.personas.example.json
|
||||
|
||||
# OS / editor scrap
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
# Per ADR-0028 (CI/CD migration from Gitea Actions to GitLab CE).
|
||||
# Thin YAML — orchestration lives in package.json scripts (ci:check,
|
||||
# ci:catalogue-drift, ci:audit, ci:commits, ci:perf, ci:gzip-budgets)
|
||||
# and Nx targets. Any change to gate behaviour belongs in those
|
||||
# scripts, not in this file — see ADR-0015 §"Thin pipeline YAML…"
|
||||
# (principle preserved by ADR-0028 at the migration boundary).
|
||||
#
|
||||
# Phase 2 of ADR-0028: ships alongside .gitea/workflows/ci.yml. Both
|
||||
# pipelines must remain in parity until cutover (Phase 3), at which
|
||||
# point the Gitea workflow gets deleted.
|
||||
|
||||
include:
|
||||
# GitLab built-in scanners — replace the manual Trivy + gitleaks
|
||||
# install in .gitea/workflows/ci.yml. Both add their own jobs to
|
||||
# the pipeline; their default stage is `test`, which is mapped in
|
||||
# `stages:` below.
|
||||
- template: Jobs/SAST.gitlab-ci.yml
|
||||
- template: Jobs/Secret-Detection.gitlab-ci.yml
|
||||
|
||||
stages:
|
||||
- check
|
||||
- test
|
||||
- commits
|
||||
- perf
|
||||
- a11y
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
# MR pipelines — covers GitLab MR review flow once it lands.
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
# Direct pushes — covers post-merge runs on main today, and Phase 2
|
||||
# parity testing on feature branches mirrored from Gitea. To be
|
||||
# tightened to `$CI_COMMIT_BRANCH == "main"` at the Phase 3 cutover
|
||||
# once GitLab is the source of truth.
|
||||
- if: $CI_PIPELINE_SOURCE == "push"
|
||||
|
||||
# Shared shape for jobs that need Node + pnpm. Hidden (`.` prefix) so
|
||||
# GitLab does not try to execute it. Inheriting jobs `extends: .node-job`.
|
||||
.node-job:
|
||||
image: node:24-bookworm
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- pnpm-lock.yaml
|
||||
paths:
|
||||
- .pnpm-store/
|
||||
before_script:
|
||||
- corepack enable
|
||||
- corepack prepare pnpm@10.33.4 --activate
|
||||
- pnpm config set store-dir "$CI_PROJECT_DIR/.pnpm-store"
|
||||
|
||||
check:
|
||||
extends: .node-job
|
||||
stage: check
|
||||
# `nx affected` needs a base SHA to diff against. Mirrors the
|
||||
# equivalent step in .gitea/workflows/ci.yml — same logic, GitLab
|
||||
# variable names. Replaces nrwl/nx-set-shas (GitHub-only).
|
||||
script:
|
||||
- |
|
||||
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
|
||||
git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --depth=100
|
||||
export NX_BASE=$(git merge-base HEAD "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
|
||||
else
|
||||
export NX_BASE="HEAD~1"
|
||||
fi
|
||||
export NX_HEAD="HEAD"
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm ci:check
|
||||
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation". The
|
||||
# script's own unit tests run first — if they fail the gate itself
|
||||
# is broken and the actual catalogue check would be noise.
|
||||
- pnpm ci:catalogue-drift:test
|
||||
- pnpm ci:catalogue-drift
|
||||
|
||||
audit:
|
||||
extends: .node-job
|
||||
stage: test
|
||||
# npm-advisory check against pnpm-lock.yaml. Trivy's dependency-vuln
|
||||
# role from the Gitea workflow is now covered by the SAST include
|
||||
# (which includes Dependency Scanning analyzers); `pnpm audit` stays
|
||||
# in-pipeline as defense in depth against the npm advisory DB
|
||||
# specifically.
|
||||
script:
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm ci:audit
|
||||
|
||||
commits:
|
||||
extends: .node-job
|
||||
stage: commits
|
||||
# MRs opened by Renovate (apf-portal-bot) carry commit messages from
|
||||
# a vetted Conventional-Commits template — running commitlint on
|
||||
# them is tautological. Per ADR-0017 amendment.
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
|
||||
variables:
|
||||
# Default `GIT_DEPTH: 20` is too shallow to diff against
|
||||
# origin/main on long-running branches. Full clone so commitlint
|
||||
# can walk all the way back.
|
||||
GIT_DEPTH: 0
|
||||
script:
|
||||
- git fetch origin main
|
||||
- pnpm install --frozen-lockfile
|
||||
- COMMIT_LINT_FROM=origin/main pnpm ci:commits
|
||||
|
||||
perf:
|
||||
extends: .node-job
|
||||
stage: perf
|
||||
# Lighthouse CI drives a real Chrome via chrome-launcher, which
|
||||
# probes the PATH / CHROME_PATH for a standard Chrome/Chromium
|
||||
# binary. The Playwright image bundles Chromium under /ms-playwright
|
||||
# with a non-standard name chrome-launcher cannot discover, so we
|
||||
# stay on the base Node image and apt-install Debian's `chromium`
|
||||
# (lands at /usr/bin/chromium). The future ADR-0016 axe-core e2e job
|
||||
# — which drives Playwright directly — will use the Playwright image
|
||||
# instead: the two tools want different things, so they get
|
||||
# different images rather than one image that serves both poorly.
|
||||
image: node:24-bookworm
|
||||
variables:
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
before_script:
|
||||
- !reference [.node-job, before_script]
|
||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium
|
||||
# Skip on Renovate MRs (same rationale as commits + the perf signal
|
||||
# on a dep bump is essentially zero). Push events on `main` still
|
||||
# run perf — we catch regressions immediately post-merge, not
|
||||
# pre-merge. Per ADR-0017 amendment.
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
|
||||
script:
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm ci:perf
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- .lighthouseci/
|
||||
expire_in: 30 days
|
||||
|
||||
a11y:
|
||||
extends: .node-job
|
||||
stage: a11y
|
||||
# Placeholder until the e2e a11y suite (axe-core via Playwright, per
|
||||
# ADR-0016) is wired with the first real screens. The job exists so
|
||||
# branch protection can require it from day one — currently no-ops
|
||||
# with a clear message.
|
||||
script:
|
||||
- echo "a11y gate placeholder - axe-core via Playwright wires up with the first real screens (ADR-0016)."
|
||||
|
||||
# Place SAST + Secret Detection in the `test` stage to mirror the
|
||||
# Gitea workflow's grouping (where Trivy + gitleaks + audit all
|
||||
# lived in the `scan` job). Override does not change behaviour —
|
||||
# only stage placement. The included templates set sensible defaults
|
||||
# (run on default branch + MRs, full repo on default branch / diff
|
||||
# on MRs); revisited in Phase 3 if pipeline duration becomes a pain.
|
||||
sast:
|
||||
stage: test
|
||||
|
||||
secret_detection:
|
||||
stage: test
|
||||
@@ -1,30 +0,0 @@
|
||||
<!--
|
||||
MR title format — becomes the squash-merge subject on main, validated by commitlint.
|
||||
|
||||
<type>(<scope>): <short description>
|
||||
|
||||
Examples:
|
||||
feat(portal-shell): add user-preferences panel skeleton
|
||||
fix(portal-bff): correct env var bracket access
|
||||
docs(decisions): add ADR-0018 for security baseline
|
||||
chore(deps): bump @nx/* to 22.7.2
|
||||
|
||||
Imperative mood, lowercase, no trailing period, target ≤ 70 chars.
|
||||
See docs/development.md §7 for the full convention (types, scopes).
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
## Motivation
|
||||
|
||||
## Implementation notes
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] `pnpm ci:check` green locally
|
||||
- [ ] `pnpm ci:audit` green (or pre-existing drift acknowledged)
|
||||
- [ ] Tested manually:
|
||||
- [ ] Architecture diagram updated (if `docs/architecture.md` was affected)
|
||||
- [ ] ADR amended or added (if a decision changed)
|
||||
|
||||
## Related
|
||||
@@ -5,8 +5,3 @@
|
||||
/.nx/workspace-data
|
||||
.nx/self-healing
|
||||
.angular
|
||||
|
||||
# Generated gRPC TypeScript stubs (output of `pnpm run grpc:codegen`).
|
||||
# Hand-formatting is not productive — overwritten on next regen — and
|
||||
# ts-proto's output is internally consistent.
|
||||
apps/portal-bff/src/grpc/gen/
|
||||
|
||||
@@ -43,7 +43,7 @@ The structural, security, observability, and quality choices are recorded as ADR
|
||||
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md).
|
||||
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md).
|
||||
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md).
|
||||
- **CI/CD:** **Gitea Actions today, migrating to GitLab CE on `vm-gitlab` per [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md)** (decision-only ADR accepted; 4-phase rollout in follow-up PRs — the architectural principles below carry over unchanged from [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md), only the host / pipeline file / runner type / scan tooling change). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and `perf` (per ADR-0017). Self-hosted runners on-prem (`act_runner` today, GitLab Runner with Docker executor post-migration). Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits required on `main` once the migration cutover lands (ADR-0028's revisit of ADR-0015's "recommended" stance).
|
||||
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md).
|
||||
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
||||
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
||||
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
|
||||
@@ -52,34 +52,24 @@ The structural, security, observability, and quality choices are recorded as ADR
|
||||
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
||||
- **Documentation site:** `docs/**/*.md` rendered as a separate static site via **VitePress** (Vite-based, Node-only toolchain, Markdown-first). Mermaid diagrams via `vitepress-plugin-mermaid`. Deployed on its own hostname behind the shared reverse-proxy; CI hook on `docs/` changes rebuilds + publishes. Decoupled from the apps — content lives in `docs/`, no in-app Markdown viewer — see [ADR-0022](docs/decisions/0022-docs-site-vitepress.md).
|
||||
- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `<title>`/`<desc>`, `<details>` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
|
||||
- **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
|
||||
- **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement:<structure-code> / delegation:<dept> / region:<insee> / siege / unrestricted`, see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) for the `Structure.code` semantics). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md).
|
||||
- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 2–3-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md).
|
||||
- **Runtime:** Node.js latest LTS major.
|
||||
- **Local dev environment:** three coexisting run modes — native `pnpm nx serve`, the VSCode devcontainer, and a Docker Compose `apps` profile that boots all three Nx dev servers without a native Node/pnpm toolchain (`./infra/local/dev.sh up apps`). Dev-only (production images deferred to the ADR-0028 Container Registry work); shared `Dockerfile.dev`, repo bind-mounted for hot reload, `node_modules`/Nx cache in named volumes, BFF entrypoint runs `prisma generate` + `migrate deploy` — see [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md).
|
||||
|
||||
## Repository status
|
||||
|
||||
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR.
|
||||
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the four lib roots (`libs/feature/`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` on every PR.
|
||||
|
||||
ADRs 0001 → 0028 plus ADR-0030 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, portal-side identity + organisational hierarchy, CI/CD platform migration, and dockerised local-dev mode choices. ADR-0028 supersedes only ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged; the 4-phase migration (mirror → parallel pipelines → cutover → cleanup) ships in follow-up PRs. ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition — its number is reserved ahead of ADR-0030, which was written first. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **Shipped on `main`:**
|
||||
ADRs 0001 → 0023 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, and charts choices. **Shipped on `main`:**
|
||||
|
||||
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
|
||||
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
|
||||
- **Phase-3a admin app** — `portal-admin` SPA with brand tokens, routing, user-list reader (`/admin/users`), and audit-log viewer with statistics and integrated charts (`/admin/audit`). CMS for static pages and menu management not yet implemented.
|
||||
- **AI relay surface + live consumer** — vendored protos + `AiClientModule` (gRPC clients, Principal mapper, metadata builder) + `AiBridgeController` exposing `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models` (see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)). Chatbot widget live in `portal-shell` at `apps/portal-shell/src/app/features/chatbot/`.
|
||||
- **Docs static site** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — VitePress + Mermaid renderer at `docs/.vitepress/`, dedicated `docs-site.yml` workflow that rebuilds + publishes on every `docs/**` change.
|
||||
- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — `libs/shared/charts/` with `BarChart`, `DonutChart`, `StackedBarChart` (D3 + Observable Plot, headless / a11y-baked-in), integrated into the `/admin/audit` page for daily-volume + outcome-breakdown + event-type-over-time views.
|
||||
- **Authorization model + guards** ([ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)) — `libs/shared/auth/` exporting the closed catalogues (4 privileges, 24 functional roles, 6 scope kinds), `Principal` shape, pure matchers, and `EntraGroupToRoleResolver`. BFF-side `PrincipalBuilder` composing the three axes at sign-in from Entra `roles` + `groups` claims + a stub `ScopeResolver`. `@RequirePrivilege` / `@RequireRole` / `@RequireScope` route decorators + guards with the ADR-0021 structured-error envelope on denial; `AdminRoleGuard` migrated to read `principal.privileges`. CI drift gate (`scripts/check-catalogue-drift.mjs`) asserting every decorator literal is in the catalogue.
|
||||
- **Phase-3a admin app skeleton** — `portal-admin` SPA exists with brand tokens and routing; business modules (CMS, menu management, user list, audit log viewer) not yet implemented.
|
||||
|
||||
**Still on the roadmap:**
|
||||
|
||||
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — module scaffolded (`obo.strategy`, `signed-assertion.strategy`, JWKS publisher, encrypted token cache); no v1 consumer yet. Wires in when the first business route needs an Entra-protected API.
|
||||
- `@RequireMfa()` step-up consumer routes ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md)) — guard + decorator shipped; awaiting first sensitive route that needs explicit freshness enforcement beyond the Conditional Access baseline.
|
||||
- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. Implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (accepted: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0029](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas.
|
||||
- **Proto-drift CI gate for the AI relay** ([ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)) — asserts the vendored `apf-ai-service` proto files stay in lockstep with the upstream contract.
|
||||
- **Admin app — CMS & menu management** ([ADR-0020](docs/decisions/0020-portal-admin-app.md)) — multilingual static-page editor + navigation menu builder. The user-list + audit-log-viewer modules already exist; the CMS/menu pair is the remaining v1 module scope.
|
||||
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — no v1 consumer yet; will land when the first business route needs an Entra-protected API.
|
||||
- `@RequireMfa()` / `@RequireAdmin()` guards ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md), [ADR-0020](docs/decisions/0020-portal-admin-app.md)) — designed-in, awaiting first consumer route.
|
||||
- **Docs static-site implementation** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — ADR accepted, chantier (VitePress install + `.vitepress/config.ts` + `docs-site.yml` workflow) lands next.
|
||||
- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — ADR accepted; chantier next: `libs/shared/charts/` foundations + 3 starter components (bar, donut, stacked-bar), then `/audit`-page integration with daily-volume + outcome-breakdown + event-type-over-time charts.
|
||||
- **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it.
|
||||
|
||||
## Commands once the workspace exists
|
||||
@@ -109,10 +99,8 @@ pnpm nx format:check
|
||||
|
||||
## Environment conventions
|
||||
|
||||
- **Two development environments.** `local` (Windows-WSL or native macOS / Linux on the workstation) and `development` (Debian 13 VM at `10.100.201.21` — the default for new devs, replaces WSL). A `hybrid` sub-mode runs the IDE + Nx servers on the workstation while reaching the infra services (postgres / redis / otel) on the dev VM through SSH tunnels. Full procedure: [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md). The legacy WSL flow remains documented in [docs/setup/02-wsl-terminal-setup.md](docs/setup/02-wsl-terminal-setup.md).
|
||||
- **Two IDE flows on the dev VM** — VSCode Remote-SSH (default; transparent equivalent of the WSL flow) and Devcontainer (`.devcontainer/devcontainer.json` shipped, Node + pnpm pinned in the image, mounts the docker socket so the host's `apf-portal-dev` Compose network is reachable from inside). Independently of the IDE flow, the apps can run **natively** (`pnpm nx serve`) or as **Docker Compose services** (`./infra/local/dev.sh up apps`, no native toolchain — ADR-0030); the "which mode when" table is in [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md).
|
||||
- **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project.
|
||||
- **On WSL: work inside the WSL filesystem** (`~/Works/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times. On the dev VM the analogous rule is "stay on the VM disk, do not work over SSHFS / network mounts".
|
||||
- **Work inside the WSL filesystem** (`~/dev/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times.
|
||||
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
|
||||
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
|
||||
|
||||
|
||||
@@ -84,8 +84,7 @@
|
||||
"continuous": true,
|
||||
"executor": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"port": 4300,
|
||||
"proxyConfig": "apps/portal-admin/proxy.conf.js"
|
||||
"port": 4300
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -93,12 +92,6 @@
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "portal-admin:build:development"
|
||||
},
|
||||
"https": {
|
||||
"buildTarget": "portal-admin:build:development",
|
||||
"ssl": true,
|
||||
"sslKey": ".secrets/dev-tls.key",
|
||||
"sslCert": ".secrets/dev-tls.pem"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Angular dev-server proxy for portal-admin.
|
||||
//
|
||||
// Mirrors apps/portal-shell/proxy.conf.js — same rationale, same
|
||||
// `/api → ${BFF_TARGET:-http://localhost:3000}` rule. The admin app
|
||||
// talks to the same BFF (ADR-0020 §"Where does the admin app live"),
|
||||
// just at admin-specific paths under `/api/admin/...`; the proxy
|
||||
// match on `/api` covers both surfaces.
|
||||
//
|
||||
// JS form deliberate — only this form can read `process.env` so the
|
||||
// Docker / native target swap (BFF_TARGET in dev.compose.yml) works
|
||||
// without a rebuild.
|
||||
|
||||
const target = process.env['BFF_TARGET'] ?? 'http://localhost:3000';
|
||||
|
||||
module.exports = {
|
||||
'/api': {
|
||||
target,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
},
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { authGuard } from 'feature-auth';
|
||||
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
||||
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
|
||||
const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
|
||||
const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`;
|
||||
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
@@ -24,11 +23,6 @@ export const appRoutes: Route[] = [
|
||||
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
|
||||
title: usersTitle,
|
||||
},
|
||||
{
|
||||
path: 'users/:oid/scopes',
|
||||
loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage),
|
||||
title: userScopesTitle,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
canActivate: [authGuard],
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<section class="user-scopes">
|
||||
<header class="user-scopes-header">
|
||||
<p class="crumbs">
|
||||
<a routerLink="/users">← Back to users</a>
|
||||
</p>
|
||||
<h1 class="title">User scopes</h1>
|
||||
@if (page(); as p) {
|
||||
<p class="intro">
|
||||
Managing scopes for <strong>{{ p.user.displayName }}</strong> ({{ p.user.email ?? '—' }},
|
||||
<code>{{ p.user.oid }}</code>). Every grant emits <code>admin.scope_granted</code> and every
|
||||
revoke emits <code>admin.scope_revoked</code> — scope-management is itself auditable per
|
||||
ADR-0013.
|
||||
</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="status-bar" role="status" aria-live="polite">
|
||||
@if (loading()) {
|
||||
<span class="status-line">Loading…</span>
|
||||
} @else if (error(); as msg) {
|
||||
<span class="status-line status-line--error">{{ msg }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (page(); as p) {
|
||||
<section class="scopes-section" aria-labelledby="current-scopes-h">
|
||||
<h2 id="current-scopes-h" class="section-heading">Current scopes</h2>
|
||||
@if (p.scopes.length === 0) {
|
||||
<p class="empty">No scopes granted to this user yet.</p>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
<table class="scopes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Granted</th>
|
||||
<th scope="col">Expires</th>
|
||||
<th scope="col"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (scope of p.scopes; track scope.id) {
|
||||
<tr>
|
||||
<td class="cell-scope">
|
||||
<code>{{ formatScope(scope.kind, scope.value) }}</code>
|
||||
</td>
|
||||
<td class="cell-source">{{ scope.source }}</td>
|
||||
<td class="cell-timestamp">{{ formatTimestamp(scope.createdAt) }}</td>
|
||||
<td class="cell-timestamp">{{ formatTimestamp(scope.expiresAt) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--danger"
|
||||
(click)="revoke(scope.id, formatScope(scope.kind, scope.value))"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="grant-section" aria-labelledby="grant-h">
|
||||
<h2 id="grant-h" class="section-heading">Grant a new scope</h2>
|
||||
<form class="grant-form" (submit)="$event.preventDefault(); grant()">
|
||||
<div class="grant-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">Kind</span>
|
||||
<select
|
||||
name="kind"
|
||||
[ngModel]="newKind()"
|
||||
(ngModelChange)="newKind.set($event)"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
@for (kind of scopeKinds; track kind) {
|
||||
<option [value]="kind">{{ kind }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" [class.field--hidden]="!needsValue()">
|
||||
<span class="field-label">Value</span>
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
[ngModel]="newValue()"
|
||||
(ngModelChange)="newValue.set($event)"
|
||||
[disabled]="submitting() || !needsValue()"
|
||||
placeholder="0330800013 / 33 / 75"
|
||||
[attr.aria-required]="needsValue() ? 'true' : 'false'"
|
||||
[attr.aria-describedby]="needsValue() ? 'value-hint' : null"
|
||||
/>
|
||||
@if (needsValue()) {
|
||||
<span id="value-hint" class="field-hint">
|
||||
For etablissement: Structure.code. For delegation: dept code (33, 2A). For region: INSEE
|
||||
region code (75).
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Expires at (optional)</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="expiresAt"
|
||||
[ngModel]="newExpiresAt()"
|
||||
(ngModelChange)="newExpiresAt.set($event)"
|
||||
[disabled]="submitting()"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@if (submitError(); as msg) {
|
||||
<p class="submit-error" role="alert">{{ msg }}</p>
|
||||
}
|
||||
<div class="grant-actions">
|
||||
<button type="submit" class="btn btn--primary" [disabled]="submitting()">
|
||||
@if (submitting()) { Granting… } @else { Grant }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
@@ -1,152 +0,0 @@
|
||||
.user-scopes {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-scopes-header {
|
||||
.crumbs {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.intro {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #555);
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.status-line--error {
|
||||
color: var(--color-danger, #b00020);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary, #555);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.scopes-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border, #ddd);
|
||||
}
|
||||
|
||||
.cell-scope code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.grant-section {
|
||||
background: var(--color-bg-surface, #fafafa);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.grant-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&.field--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #555);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
border-radius: 0.25rem;
|
||||
min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */
|
||||
}
|
||||
}
|
||||
|
||||
.submit-error {
|
||||
color: var(--color-danger, #b00020);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.grant-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-brand-accent-500, #de9415);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
color: var(--color-danger, #b00020);
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||
import { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service';
|
||||
|
||||
const BFF_BASE = 'http://bff.test/api';
|
||||
|
||||
const SCOPE: UserScope = {
|
||||
id: 'scope-1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'seed',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
const PAGE: UserScopesPayload = {
|
||||
user: {
|
||||
oid: 'target-oid',
|
||||
userId: 'user-uuid-1',
|
||||
displayName: 'Jane Doe',
|
||||
email: 'jane@apf.example',
|
||||
},
|
||||
scopes: [SCOPE],
|
||||
};
|
||||
|
||||
function setup() {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||
],
|
||||
});
|
||||
return {
|
||||
service: TestBed.inject(UserScopesService),
|
||||
http: TestBed.inject(HttpTestingController),
|
||||
};
|
||||
}
|
||||
|
||||
describe('UserScopesService.list', () => {
|
||||
it('GETs /admin/users/:oid/scopes and returns the page', async () => {
|
||||
const { service, http } = setup();
|
||||
const promise = firstValueFrom(service.list('target-oid'));
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(PAGE);
|
||||
await expect(promise).resolves.toEqual(PAGE);
|
||||
});
|
||||
|
||||
it('URL-encodes the oid', async () => {
|
||||
const { service, http } = setup();
|
||||
void firstValueFrom(service.list('weird/oid'));
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`);
|
||||
req.flush(PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.grant', () => {
|
||||
it('POSTs the input body and returns the created scope', async () => {
|
||||
const { service, http } = setup();
|
||||
const promise = firstValueFrom(
|
||||
service.grant('target-oid', {
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
expiresAt: '2026-12-31T23:59:59.000Z',
|
||||
}),
|
||||
);
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
expiresAt: '2026-12-31T23:59:59.000Z',
|
||||
});
|
||||
req.flush(SCOPE);
|
||||
await expect(promise).resolves.toEqual(SCOPE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.revoke', () => {
|
||||
it('DELETEs the scope by id', async () => {
|
||||
const { service, http } = setup();
|
||||
const promise = firstValueFrom(service.revoke('target-oid', 'scope-1'));
|
||||
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`);
|
||||
expect(req.request.method).toBe('DELETE');
|
||||
req.flush(null);
|
||||
await expect(promise).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* One scope row as the SPA sees it — mirror of `UserScopeDto` on
|
||||
* the BFF side. ISO-string timestamps; the SPA never round-trips
|
||||
* `Date` instances per the audit-page convention.
|
||||
*/
|
||||
export interface UserScope {
|
||||
readonly id: string;
|
||||
readonly kind: string;
|
||||
readonly value: string;
|
||||
readonly source: string;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface UserScopesPayload {
|
||||
readonly user: {
|
||||
readonly oid: string;
|
||||
readonly userId: string;
|
||||
readonly displayName: string;
|
||||
readonly email: string | null;
|
||||
};
|
||||
readonly scopes: ReadonlyArray<UserScope>;
|
||||
}
|
||||
|
||||
export interface GrantUserScopeInput {
|
||||
readonly kind: string;
|
||||
readonly value?: string;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UserScopesService` — Thin HttpClient wrapper around
|
||||
* `/api/admin/users/:oid/scopes`. Same shape convention as
|
||||
* `AdminUsersService` — `providedIn: 'root'` because the single
|
||||
* consumer is the `UserScopesPayload`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserScopesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||
|
||||
list(oid: string): Observable<UserScopesPayload> {
|
||||
return this.http.get<UserScopesPayload>(
|
||||
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
|
||||
);
|
||||
}
|
||||
|
||||
grant(oid: string, input: GrantUserScopeInput): Observable<UserScope> {
|
||||
return this.http.post<UserScope>(
|
||||
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
revoke(oid: string, scopeId: string): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||
import {
|
||||
UserScopesService,
|
||||
type GrantUserScopeInput,
|
||||
type UserScope,
|
||||
type UserScopesPayload,
|
||||
} from './user-scopes.service';
|
||||
import { UserScopesPage } from './user-scopes';
|
||||
|
||||
const SCOPE: UserScope = {
|
||||
id: 'scope-1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'seed',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
const PAGE: UserScopesPayload = {
|
||||
user: {
|
||||
oid: 'target-oid',
|
||||
userId: 'user-uuid-1',
|
||||
displayName: 'Jane Doe',
|
||||
email: 'jane@apf.example',
|
||||
},
|
||||
scopes: [SCOPE],
|
||||
};
|
||||
|
||||
function setup(opts?: {
|
||||
list?: ReturnType<typeof vi.fn>;
|
||||
grant?: ReturnType<typeof vi.fn>;
|
||||
revoke?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE));
|
||||
const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE));
|
||||
const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [UserScopesPage],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
|
||||
{
|
||||
provide: UserScopesService,
|
||||
useValue: { list, grant, revoke },
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(UserScopesPage);
|
||||
return { fixture, list, grant, revoke };
|
||||
}
|
||||
|
||||
describe('UserScopesPage', () => {
|
||||
it('loads the scopes for the route param oid on init', async () => {
|
||||
const { fixture, list } = setup();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(list).toHaveBeenCalledWith('target-oid');
|
||||
});
|
||||
|
||||
it('surfaces a 404 with the seed/sign-in hint', async () => {
|
||||
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 })));
|
||||
const { fixture } = setup({ list });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const html = (fixture.nativeElement as HTMLElement).innerHTML;
|
||||
expect(html).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('forbids a 403 with a friendly error', async () => {
|
||||
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 })));
|
||||
const { fixture } = setup({ list });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const html = (fixture.nativeElement as HTMLElement).innerHTML;
|
||||
expect(html).toMatch(/admin access/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesPage.grant', () => {
|
||||
it('sends value for value-bearing kinds + refreshes the list on success', async () => {
|
||||
let grantPayload: GrantUserScopeInput | null = null;
|
||||
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
|
||||
grantPayload = payload;
|
||||
return of(SCOPE);
|
||||
});
|
||||
const list = vi.fn().mockReturnValue(of(PAGE));
|
||||
const { fixture } = setup({ list, grant });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
newKind: { set: (v: string) => void };
|
||||
newValue: { set: (v: string) => void };
|
||||
grant: () => Promise<void>;
|
||||
};
|
||||
component.newKind.set('etablissement');
|
||||
component.newValue.set('0330800013');
|
||||
await component.grant();
|
||||
expect(grant).toHaveBeenCalledTimes(1);
|
||||
expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' });
|
||||
// List re-fetched after the grant.
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('omits value for valueless kinds', async () => {
|
||||
let grantPayload: GrantUserScopeInput | null = null;
|
||||
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
|
||||
grantPayload = payload;
|
||||
return of(SCOPE);
|
||||
});
|
||||
const { fixture } = setup({ grant });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
newKind: { set: (v: string) => void };
|
||||
grant: () => Promise<void>;
|
||||
};
|
||||
component.newKind.set('unrestricted');
|
||||
await component.grant();
|
||||
expect(grantPayload).toEqual({ kind: 'unrestricted' });
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
type OnInit,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
UserScopesService,
|
||||
type GrantUserScopeInput,
|
||||
type UserScopesPayload,
|
||||
} from './user-scopes.service';
|
||||
|
||||
/**
|
||||
* The six scope kinds from ADR-0025's catalogue. Hardcoded here so
|
||||
* the SPA can dropdown them without importing from `shared-auth`
|
||||
* (which would couple the admin app to the BFF lib path). Drift gate
|
||||
* lives BFF-side; if the catalogue grows, this list grows in lockstep.
|
||||
*/
|
||||
const SCOPE_KINDS = [
|
||||
'self',
|
||||
'etablissement',
|
||||
'delegation',
|
||||
'region',
|
||||
'siege',
|
||||
'unrestricted',
|
||||
] as const;
|
||||
|
||||
const VALUE_BEARING = new Set<string>(['etablissement', 'delegation', 'region']);
|
||||
|
||||
/**
|
||||
* Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md).
|
||||
* Lists every UserScope row for the target user, lets the admin grant
|
||||
* a new one (kind dropdown + value + optional expiresAt), and revoke
|
||||
* existing ones inline.
|
||||
*
|
||||
* Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL
|
||||
* param is the affected user's Entra `oid` — same identifier the
|
||||
* `/admin/users` list page uses, so a "Manage scopes" link from that
|
||||
* list lands here without a lookup.
|
||||
*
|
||||
* A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)):
|
||||
* - Form labels associated via `<label>` wrapping or `for`.
|
||||
* - Live region for status/error messages (`role="status"`).
|
||||
* - Touch targets respect 44×44 min (button padding in the SCSS).
|
||||
* - Revoke buttons confirm via `window.confirm` to avoid accidental
|
||||
* destructive action on a row's keyboard activation.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-user-scopes-page',
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './user-scopes.html',
|
||||
styleUrl: './user-scopes.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class UserScopesPage implements OnInit {
|
||||
private readonly service = inject(UserScopesService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
protected readonly scopeKinds = SCOPE_KINDS;
|
||||
protected readonly oid = signal('');
|
||||
protected readonly page = signal<UserScopesPayload | null>(null);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
// Form state for the "grant new scope" row.
|
||||
protected readonly newKind = signal<string>('self');
|
||||
protected readonly newValue = signal('');
|
||||
protected readonly newExpiresAt = signal('');
|
||||
protected readonly submitting = signal(false);
|
||||
protected readonly submitError = signal<string | null>(null);
|
||||
|
||||
protected readonly needsValue = computed(() => VALUE_BEARING.has(this.newKind()));
|
||||
|
||||
ngOnInit(): void {
|
||||
const oid = this.route.snapshot.paramMap.get('oid') ?? '';
|
||||
this.oid.set(oid);
|
||||
void this.fetch();
|
||||
}
|
||||
|
||||
protected async fetch(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const page = await firstValueFrom(this.service.list(this.oid()));
|
||||
this.page.set(page);
|
||||
} catch (err) {
|
||||
this.error.set(this.translateError(err, 'Could not load scopes for this user.'));
|
||||
this.page.set(null);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async grant(): Promise<void> {
|
||||
if (this.submitting()) return;
|
||||
this.submitting.set(true);
|
||||
this.submitError.set(null);
|
||||
const payload: GrantUserScopeInput = {
|
||||
kind: this.newKind(),
|
||||
...(this.needsValue() ? { value: this.newValue().trim() } : {}),
|
||||
...(this.newExpiresAt() ? { expiresAt: toIso(this.newExpiresAt()) } : {}),
|
||||
};
|
||||
try {
|
||||
await firstValueFrom(this.service.grant(this.oid(), payload));
|
||||
// Reset the form (kind reverts to 'self' as the "least
|
||||
// privileged" default), then refresh the list to show the new
|
||||
// row.
|
||||
this.newKind.set('self');
|
||||
this.newValue.set('');
|
||||
this.newExpiresAt.set('');
|
||||
await this.fetch();
|
||||
} catch (err) {
|
||||
this.submitError.set(this.translateError(err, 'Could not grant the scope.'));
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async revoke(scopeId: string, label: string): Promise<void> {
|
||||
const ok = window.confirm(`Revoke scope "${label}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await firstValueFrom(this.service.revoke(this.oid(), scopeId));
|
||||
await this.fetch();
|
||||
} catch (err) {
|
||||
this.error.set(this.translateError(err, 'Could not revoke the scope.'));
|
||||
}
|
||||
}
|
||||
|
||||
protected formatScope(kind: string, value: string): string {
|
||||
return value === '' ? kind : `${kind}:${value}`;
|
||||
}
|
||||
|
||||
protected formatTimestamp(iso: string | null): string {
|
||||
if (iso === null) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
private translateError(err: unknown, fallback: string): string {
|
||||
if (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'error' in err &&
|
||||
typeof (err as { error?: unknown }).error === 'object' &&
|
||||
(err as { error: { message?: unknown } }).error.message !== undefined
|
||||
) {
|
||||
const msg = (err as { error: { message?: unknown } }).error.message;
|
||||
if (typeof msg === 'string') return msg;
|
||||
if (Array.isArray(msg) && msg.every((m) => typeof m === 'string')) {
|
||||
return msg.join(' • ');
|
||||
}
|
||||
}
|
||||
const status = errorStatus(err);
|
||||
if (status === 403) return 'You do not have admin access on this session.';
|
||||
if (status === 404) return 'User not found — has this persona ever signed in or been seeded?';
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toIso(localValue: string): string {
|
||||
const d = new Date(localValue);
|
||||
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
|
||||
}
|
||||
|
||||
function errorStatus(err: unknown): number | null {
|
||||
if (typeof err === 'object' && err !== null && 'status' in err) {
|
||||
const s = (err as { status: unknown }).status;
|
||||
return typeof s === 'number' ? s : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -107,7 +107,6 @@
|
||||
<th scope="col">First seen</th>
|
||||
<th scope="col">Last seen</th>
|
||||
<th scope="col">OID</th>
|
||||
<th scope="col"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -121,15 +120,6 @@
|
||||
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
|
||||
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
|
||||
<td class="cell-oid">{{ user.oid }}</td>
|
||||
<td class="cell-actions">
|
||||
<a
|
||||
class="btn btn--secondary"
|
||||
[routerLink]="['/users', user.oid, 'scopes']"
|
||||
[attr.aria-label]="'Manage scopes for ' + user.displayName"
|
||||
>
|
||||
Manage scopes
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import {
|
||||
AdminUsersService,
|
||||
@@ -49,7 +48,7 @@ function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }):
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [UsersPage],
|
||||
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
|
||||
providers: [{ provide: AdminUsersService, useValue: { query } }],
|
||||
});
|
||||
const fixture = TestBed.createComponent(UsersPage);
|
||||
return { fixture, query };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
AdminUsersService,
|
||||
@@ -27,7 +26,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-users-page',
|
||||
imports: [FormsModule, RouterLink],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './users.html',
|
||||
styleUrl: './users.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -13,17 +13,13 @@
|
||||
*/
|
||||
export const environment = {
|
||||
/**
|
||||
* Prefix of the BFF HTTP API. Same value as portal-shell — both
|
||||
* SPAs talk to the same BFF (per ADR-0020 §"Where does the admin
|
||||
* app live"). The admin-specific routing happens via the
|
||||
* `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
|
||||
* Origin + prefix of the BFF HTTP API. Same value as portal-shell
|
||||
* — both SPAs talk to the same BFF (per ADR-0020 §"Where does
|
||||
* the admin app live"). The admin-specific routing happens via
|
||||
* the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
|
||||
* `app.config.ts`, not by talking to a different host.
|
||||
*
|
||||
* Relative path: see portal-shell `environment.ts` for the full
|
||||
* rationale. Both SPAs use `proxy.conf.js` to proxy `/api/*` to
|
||||
* the BFF, keeping every call same-origin in the browser.
|
||||
*/
|
||||
bffApiBaseUrl: '/api',
|
||||
bffApiBaseUrl: 'http://localhost:3000/api',
|
||||
|
||||
/**
|
||||
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
<source>Users — APF Portal Admin</source>
|
||||
<target>Utilisateurs — Administration APF Portal</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="route.user-scopes.title" datatype="html">
|
||||
<source>User scopes — APF Portal Admin</source>
|
||||
<target>Périmètres utilisateur — Administration APF Portal</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="route.profile.title" datatype="html">
|
||||
<source>Profile — APF Portal Admin</source>
|
||||
<target>Profil — Administration APF Portal</target>
|
||||
|
||||
@@ -60,23 +60,6 @@ ENTRA_CLIENT_SECRET=replace_with_real_value
|
||||
# User portal — `/api/auth/callback` is the OIDC return URL; the
|
||||
# post-logout URL is where Entra sends the browser after RP-initiated
|
||||
# logout (typically the SPA landing page).
|
||||
#
|
||||
# The four `localhost` values below are the **WSL-native default** —
|
||||
# browser and BFF on the same host, `http://localhost:*` redirect
|
||||
# URIs (which Entra accepts as the only exception to its HTTPS rule).
|
||||
# Leave them here in this file regardless of how the docker `apps`
|
||||
# profile is being run.
|
||||
#
|
||||
# For the ADR-0030 dockerised `apps` profile accessed via a
|
||||
# hostname (e.g. `https://apf-portal.dev-jg.local:4200/`), do NOT
|
||||
# edit these values — instead override them at the compose level
|
||||
# from `infra/local/.env`. Compose's `environment:` block on the
|
||||
# `portal-bff` service interpolates each of these four vars at
|
||||
# parse time and wins over `env_file:`, so the BFF in the container
|
||||
# sees the hostname URIs while native `nx serve` keeps reading
|
||||
# this file's localhost defaults. See
|
||||
# `infra/README.md` → "Switching between dev modes" for the
|
||||
# two-mode toggle.
|
||||
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
|
||||
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
|
||||
@@ -89,28 +72,6 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
|
||||
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
|
||||
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
|
||||
|
||||
# Authorization model (per ADR-0025). Points at the JSON file that
|
||||
# maps tenant-private Entra security-group GUIDs to the closed
|
||||
# catalogue of `apf-role-*` slugs. The BFF loads it at boot through
|
||||
# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the
|
||||
# resolver runs empty — sign-in still succeeds but every user gets
|
||||
# zero functional roles (no `apf-role-*` UI). A WARN is logged at
|
||||
# boot so an operator can spot the missing config. See
|
||||
# `infra/test-tenant.entra.example.json` for the schema; copy to
|
||||
# `infra/test-tenant.entra.json` (gitignored) and fill in the real
|
||||
# GUIDs from the Entra admin centre.
|
||||
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
|
||||
|
||||
# Test-tenant per-persona Entra `oid` map (per ADR-0026 PR 2).
|
||||
# Consumed by `apps/portal-bff/prisma/seed.ts` to upsert Person +
|
||||
# User + UserScope rows for the 19 test personas. Distinct from
|
||||
# `ENTRA_GROUP_MAP_PATH` which points at the 24 functional-role
|
||||
# *group* guids. Unset means `prisma db seed` skips the test-tenant
|
||||
# section (no Person/User/UserScope rows seeded — lazy creation at
|
||||
# first sign-in still works). See
|
||||
# `infra/test-tenant.personas.example.json` for the schema.
|
||||
TEST_TENANT_PERSONAS_PATH=infra/test-tenant.personas.json
|
||||
|
||||
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
|
||||
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
||||
# verifier between the /auth/login redirect and the /auth/callback
|
||||
@@ -245,16 +206,3 @@ CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
|
||||
# BFF_JWKS_KID — wired
|
||||
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration)
|
||||
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)
|
||||
|
||||
# AI service relay (ADR-0024) — the BFF dials apf-ai-service over
|
||||
# native gRPC HTTP/2 and bridges chat streams to SSE for the SPA.
|
||||
# apf-ai-service runs from its own repo (../apf-ai-service); use
|
||||
# that repo's docker-compose.yml to bring it up locally, then point
|
||||
# AI_SERVICE_GRPC_ENDPOINT here at the host-published port. No
|
||||
# JWT/auth on the wire in v1 — the Principal travels in the proto
|
||||
# body (per ADR-0024 §"Sub-decision 4 — POC unsigned principal").
|
||||
AI_SERVICE_GRPC_ENDPOINT=localhost:8080
|
||||
AI_SERVICE_CLIENT_ID=apf-portal-dev
|
||||
# Set to 'true' in preprod/prod (h2 + TLS via the edge proxy);
|
||||
# 'false' in dev for h2c against the local apf-ai-service.
|
||||
AI_SERVICE_GRPC_TLS=false
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
-- Organisational hierarchy (per ADR-0027).
|
||||
--
|
||||
-- Region → Delegation → Structure. The portal's ADR-0025 scope-axis
|
||||
-- dereferences these three layers — the BFF guard
|
||||
-- `principalCoversResource` walks etablissement → delegation → region
|
||||
-- to decide whether a scope covers a resource. The schema lives in
|
||||
-- the public schema; ADR-0029 will populate the full APF inventory
|
||||
-- via the cascade sync, additively into the columns defined here.
|
||||
--
|
||||
-- Inline seed at the bottom of this file: just the codes the
|
||||
-- test tenant exercises (Région Nouvelle-Aquitaine, Délégation
|
||||
-- Gironde, two médico-social structures + the APF national siège).
|
||||
-- Superseded — not extended — by ADR-0029's cascade sync once it
|
||||
-- ships.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "regions" (
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "regions_pkey" PRIMARY KEY ("code")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "delegations" (
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"region_code" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "delegations_pkey" PRIMARY KEY ("code")
|
||||
);
|
||||
|
||||
-- Closed-set CHECK on Structure.kind. Mirrors STRUCTURE_KINDS in
|
||||
-- apps/portal-bff/src/structures/structure-kind.ts. The CI drift gate
|
||||
-- (scripts/check-catalogue-drift.mjs) asserts the TS constant matches
|
||||
-- the values used in code; this CHECK constraint provides the
|
||||
-- equivalent guarantee at the DB layer for any code path that
|
||||
-- bypasses the type system.
|
||||
CREATE TABLE "structures" (
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"finess" TEXT,
|
||||
"siret" TEXT,
|
||||
"code_paie" TEXT,
|
||||
"delegation_code" TEXT,
|
||||
|
||||
CONSTRAINT "structures_pkey" PRIMARY KEY ("code"),
|
||||
CONSTRAINT "structures_kind_check" CHECK (
|
||||
"kind" IN (
|
||||
'medico_social',
|
||||
'antenne',
|
||||
'dispositif',
|
||||
'entreprise_adaptee',
|
||||
'mouvement',
|
||||
'administratif',
|
||||
'siege'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "delegations_region_code_idx" ON "delegations"("region_code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "structures_finess_key" ON "structures"("finess");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "structures_siret_key" ON "structures"("siret");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "structures_code_paie_key" ON "structures"("code_paie");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "structures_kind_idx" ON "structures"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "structures_delegation_code_idx" ON "structures"("delegation_code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "delegations" ADD CONSTRAINT "delegations_region_code_fkey"
|
||||
FOREIGN KEY ("region_code") REFERENCES "regions"("code")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
-- ON DELETE SET NULL because delegationCode is optional in the Prisma
|
||||
-- schema (Structure.delegation Delegation?). Matches Prisma's default
|
||||
-- for nullable relations; the drift gate would otherwise flag this
|
||||
-- on every `prisma migrate dev`.
|
||||
ALTER TABLE "structures" ADD CONSTRAINT "structures_delegation_code_fkey"
|
||||
FOREIGN KEY ("delegation_code") REFERENCES "delegations"("code")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Test-tenant reference seed (per ADR-0027 §"Seeding posture").
|
||||
--
|
||||
-- Region Nouvelle-Aquitaine + Délégation Gironde + the structures
|
||||
-- referenced by the 19 personas in notes/test-tenant-role-assignments.md:
|
||||
-- - 0330800013 (APF Bordeaux, FINESS = code)
|
||||
-- - 0330800021 (Complexe Mérignac, FINESS = code)
|
||||
-- - 'siege' (APF national headquarters)
|
||||
--
|
||||
-- Cascade-sync (ADR-0029) supersedes this seed entirely when it
|
||||
-- ships — the cleanup migration responsible for that truncates and
|
||||
-- repopulates from cascade's authoritative inventory.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
INSERT INTO "regions" ("code", "name") VALUES
|
||||
('75', 'Nouvelle-Aquitaine');
|
||||
|
||||
INSERT INTO "delegations" ("code", "name", "region_code") VALUES
|
||||
('33', 'Gironde', '75');
|
||||
|
||||
INSERT INTO "structures" ("code", "name", "kind", "finess", "delegation_code") VALUES
|
||||
('0330800013', 'APF Bordeaux', 'medico_social', '0330800013', '33'),
|
||||
('0330800021', 'Complexe Mérignac', 'medico_social', '0330800021', '33');
|
||||
|
||||
-- Siège has no delegation parent and no FINESS/SIRET/codePaie (those
|
||||
-- columns stay NULL — the unique indexes tolerate multiple NULLs).
|
||||
INSERT INTO "structures" ("code", "name", "kind") VALUES
|
||||
('siege', 'Siège APF France handicap', 'siege');
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
-- Rename `users` -> `user_directory_entries`.
|
||||
--
|
||||
-- Prepares for ADR-0026 PR 1 which introduces a NEW `User` model
|
||||
-- (UUID PK + FK to `Person` + `lastSignInAt`) with materially
|
||||
-- different semantics. The existing model (ADR-0020 sign-in cache,
|
||||
-- Entra `oid` as PK) keeps its role but gets a more precise name
|
||||
-- that won't collide.
|
||||
--
|
||||
-- Mechanical refactor only — same columns, same indexes, same
|
||||
-- constraints. ADR-0026 PR 1 lands the new `users` table with
|
||||
-- the new semantics.
|
||||
|
||||
ALTER TABLE "users" RENAME TO "user_directory_entries";
|
||||
|
||||
-- Rename the PK constraint + indexes so they track the new table name.
|
||||
-- Postgres doesn't auto-rename these on ALTER TABLE RENAME.
|
||||
ALTER INDEX "users_pkey" RENAME TO "user_directory_entries_pkey";
|
||||
ALTER INDEX "users_last_seen_at_idx" RENAME TO "user_directory_entries_last_seen_at_idx";
|
||||
ALTER INDEX "users_username_idx" RENAME TO "user_directory_entries_username_idx";
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
-- Identity model (per ADR-0026 PR 1).
|
||||
--
|
||||
-- Person golden record + User portal-account overlay (1-to-0-or-1)
|
||||
-- + UserScope (the table behind the ADR-0025 scope axis). Distinct
|
||||
-- from `user_directory_entries` (ADR-0020 sign-in cache, renamed in
|
||||
-- the previous migration) — those keep their role; these are new.
|
||||
--
|
||||
-- No seed data — the test-tenant scope rows ship in ADR-0026 PR 2
|
||||
-- via `prisma/seed.ts`, after this schema is in place.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "persons" (
|
||||
"id" UUID NOT NULL,
|
||||
"first_name" TEXT NOT NULL,
|
||||
"last_name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"source" TEXT NOT NULL,
|
||||
"external_id" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "persons_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL,
|
||||
"person_id" UUID NOT NULL,
|
||||
"entra_oid" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"last_sign_in_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_scopes" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL DEFAULT '',
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "user_scopes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Person indexes
|
||||
CREATE INDEX "persons_source_idx" ON "persons"("source");
|
||||
|
||||
CREATE INDEX "persons_external_id_idx" ON "persons"("external_id");
|
||||
|
||||
CREATE INDEX "persons_email_idx" ON "persons"("email");
|
||||
|
||||
-- User indexes — both unique constraints back btree indexes.
|
||||
CREATE UNIQUE INDEX "users_person_id_key" ON "users"("person_id");
|
||||
|
||||
CREATE UNIQUE INDEX "users_entra_oid_key" ON "users"("entra_oid");
|
||||
|
||||
-- UserScope indexes — the unique constraint backs the composite,
|
||||
-- the plain index supports the read-by-userId hot path on sign-in.
|
||||
CREATE UNIQUE INDEX "user_scopes_user_id_kind_value_key" ON "user_scopes"("user_id", "kind", "value");
|
||||
|
||||
CREATE INDEX "user_scopes_user_id_idx" ON "user_scopes"("user_id");
|
||||
|
||||
-- Foreign keys.
|
||||
--
|
||||
-- User.person_id is REQUIRED → ON DELETE RESTRICT (Prisma default for
|
||||
-- required relations). Removing a Person with a portal User must be
|
||||
-- an explicit two-step in the admin path; never an accidental cascade.
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_person_id_fkey"
|
||||
FOREIGN KEY ("person_id") REFERENCES "persons"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- UserScope.user_id has explicit `onDelete: Cascade` in the Prisma
|
||||
-- schema — revoking a User wipes their scope rows in one delete,
|
||||
-- which is the desired admin-UI semantic (deactivating an account
|
||||
-- should not leave dangling authorisation rows).
|
||||
ALTER TABLE "user_scopes" ADD CONSTRAINT "user_scopes_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -67,12 +67,6 @@ enum AuditOutcome {
|
||||
// (combined with the salted hash) — never the BFF's primary actor
|
||||
// identifier elsewhere.
|
||||
//
|
||||
// **Distinct from the upcoming ADR-0026 `User` model.** That one
|
||||
// (UUID PK + FK to `Person` + lazy-created at OIDC callback) is the
|
||||
// portal-account overlay on a `Person` golden record. This
|
||||
// `UserDirectoryEntry` is the ADR-0020 sign-in cache — different
|
||||
// semantics, kept apart so neither concept overloads the other.
|
||||
//
|
||||
// **No PII redaction on read.** Per ADR-0013 the audit module
|
||||
// hashes the actor id to defend against an audit-log dump leaking
|
||||
// who-did-what. This table is the *deliberate* PII storage: an
|
||||
@@ -80,7 +74,7 @@ enum AuditOutcome {
|
||||
// usernames. The trust boundary is the admin role gate
|
||||
// (ADR-0020 §"Auth — `admin` role claim").
|
||||
|
||||
model UserDirectoryEntry {
|
||||
model User {
|
||||
// Entra `oid` — stable per-user identifier inside the tenant.
|
||||
// Used as the natural primary key. Per-tenant uniqueness is
|
||||
// sufficient: the dual-audience design (ADR-0008) currently
|
||||
@@ -100,212 +94,12 @@ model UserDirectoryEntry {
|
||||
// without scanning audit.events.
|
||||
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("user_directory_entries")
|
||||
@@map("users")
|
||||
@@schema("public")
|
||||
@@index([lastSeenAt(sort: Desc)])
|
||||
@@index([username])
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Identity model (per ADR-0026)
|
||||
// ============================================================
|
||||
//
|
||||
// `Person` golden record + `User` portal-account overlay (one-to-zero-
|
||||
// or-one). Distinct from `UserDirectoryEntry` above — that one is the
|
||||
// ADR-0020 sign-in cache keyed on Entra `oid`; these are the portal's
|
||||
// stable identity model keyed on UUIDs and form the basis for the
|
||||
// `@RequireScope` Prisma resolver landing in ADR-0026 PR 2.
|
||||
//
|
||||
// `Person` exists whether or not the human ever signs in (workforce
|
||||
// pre-provisioning, dossier bénéficiaires, alumni). `User` is the
|
||||
// portal-access overlay, lazy-created at first OIDC callback by
|
||||
// `PersonAndUserProvisioner.ensureUser`. `UserScope` backs the ADR-0025
|
||||
// scope axis with opaque `value` strings referencing ADR-0027's
|
||||
// `Structure.code` / `Delegation.code` / `Region.code` — no FK at the
|
||||
// DB level so historical scope rows survive structure decommissioning.
|
||||
|
||||
model Person {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
// PII — firstName + lastName are subject to ADR-0013 redaction rules
|
||||
// when emitted to logs.
|
||||
firstName String @map("first_name")
|
||||
lastName String @map("last_name")
|
||||
// Primary contact email. Indexed for operator lookup but NOT unique
|
||||
// — two distinct humans genuinely can share emails (shared family
|
||||
// alias, generic info@ at a small partner organisation, error in an
|
||||
// upstream feed). ADR-0029's reconciliation flow surfaces candidate
|
||||
// duplicates for operator confirmation rather than auto-merging.
|
||||
email String?
|
||||
// Closed-set catalogue, drift-gated. See
|
||||
// `apps/portal-bff/src/users/person-source.ts` for the legal values.
|
||||
// v1: 'self-signin' / 'admin-ui' / 'seed'. ADR-0029 adds 'pleiades'
|
||||
// and 'acteurs-plus'.
|
||||
source String
|
||||
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
|
||||
// Nullable in v1 — populated by ADR-0029's syncs.
|
||||
externalId String? @map("external_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
// Back-ref. NULL when the Person has no portal account (workforce
|
||||
// pre-provisioning, dossier bénéficiaire, alumni).
|
||||
user User?
|
||||
|
||||
@@map("persons")
|
||||
@@schema("public")
|
||||
@@index([source])
|
||||
@@index([externalId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
// 1-to-1 with Person. The unique constraint on personId enforces
|
||||
// "at most one User per Person"; back-ref `Person.user` is the
|
||||
// other half.
|
||||
personId String @unique @map("person_id") @db.Uuid
|
||||
person Person @relation(fields: [personId], references: [id])
|
||||
// Entra object id — stable per-user identifier inside the tenant.
|
||||
// Unique because two separate Person+User pairs cannot share an
|
||||
// Entra identity.
|
||||
entraOid String @unique @map("entra_oid")
|
||||
// Entra tenant id. Stored alongside the oid so a future dual-
|
||||
// audience activation (ADR-0008) can disambiguate.
|
||||
tenantId String @map("tenant_id")
|
||||
// Refreshed by `PersonAndUserProvisioner.ensureUser` on every
|
||||
// sign-in. Surfaced in the future /admin/users/:id screen.
|
||||
lastSignInAt DateTime? @map("last_sign_in_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
scopes UserScope[]
|
||||
|
||||
@@map("users")
|
||||
@@schema("public")
|
||||
}
|
||||
|
||||
model UserScope {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
// CASCADE so revoking a User wipes their scope rows in one delete.
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// One of the six ADR-0025 scope kinds: 'self' / 'etablissement' /
|
||||
// 'delegation' / 'region' / 'siege' / 'unrestricted'. Not pulled
|
||||
// from a Prisma enum because the value's semantics belong to the
|
||||
// `shared-auth` catalogue, not the database; the drift gate
|
||||
// already enforces the closed set at the call sites.
|
||||
kind String
|
||||
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
|
||||
// the value is a Structure.code; for 'delegation' it is a
|
||||
// Delegation.code; for 'region' it is a Region.code. Empty string
|
||||
// for 'self' / 'siege' / 'unrestricted'. NO FK to ADR-0027 tables —
|
||||
// historical scope rows survive structure decommissioning; the
|
||||
// admin-UI write path (ADR-0026 PR 2) validates at insert time.
|
||||
value String @default("")
|
||||
// Provenance, same catalogue posture as Person.source. v1:
|
||||
// 'admin-ui' / 'seed'. ADR-0029 adds upstream-sync values and a
|
||||
// reconciliation policy.
|
||||
source String
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// When non-null and past, the row is ignored at sign-in. Supports
|
||||
// interim-director-for-two-months patterns and admin UI scope
|
||||
// revocations.
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("user_scopes")
|
||||
@@schema("public")
|
||||
@@unique([userId, kind, value])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Organisational hierarchy (per ADR-0027)
|
||||
// ============================================================
|
||||
//
|
||||
// Region → Delegation → Structure. The portal's scope-axis
|
||||
// dereferences these three layers — the BFF guard
|
||||
// `principalCoversResource` walks etablissement → delegation →
|
||||
// region to decide whether a scope covers a resource (per
|
||||
// ADR-0025).
|
||||
//
|
||||
// Population in v1: a small inline seed in the
|
||||
// `add_org_hierarchy` migration (the codes the test tenant
|
||||
// exercises). The full APF inventory ships with ADR-0029's
|
||||
// cascade sync, which writes additively into these columns —
|
||||
// no schema churn at sync time.
|
||||
|
||||
model Region {
|
||||
// INSEE region code (2 digits — '75' Nouvelle-Aquitaine,
|
||||
// '11' Île-de-France). Externally meaningful and stable
|
||||
// across reorgs; doubles as primary key.
|
||||
code String @id
|
||||
name String
|
||||
|
||||
delegations Delegation[]
|
||||
|
||||
@@map("regions")
|
||||
@@schema("public")
|
||||
}
|
||||
|
||||
model Delegation {
|
||||
// French department code (2-3 chars — '33' Gironde, '2A',
|
||||
// '971'). Same rationale as Region.code.
|
||||
code String @id
|
||||
name String
|
||||
|
||||
regionCode String @map("region_code")
|
||||
region Region @relation(fields: [regionCode], references: [code])
|
||||
|
||||
structures Structure[]
|
||||
|
||||
@@map("delegations")
|
||||
@@schema("public")
|
||||
@@index([regionCode])
|
||||
}
|
||||
|
||||
model Structure {
|
||||
// Portal-internal stable code, externally meaningful. For
|
||||
// medico-social structures: code = FINESS (9 digits). For
|
||||
// non-medico-social: APF-internal slug ('siege',
|
||||
// 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type
|
||||
// level; matching is string equality, not parsing.
|
||||
code String @id
|
||||
name String
|
||||
|
||||
// Closed set, drift-gated. Legal values are tracked in
|
||||
// `apps/portal-bff/src/structures/structure-kind.ts` and
|
||||
// mirrored by a Postgres CHECK constraint on this column
|
||||
// (see the migration). `scripts/check-catalogue-drift.mjs`
|
||||
// asserts the TS constant matches the values used in code.
|
||||
kind String
|
||||
|
||||
// FINESS (9 digits). NULL for non-medico-social structures.
|
||||
// Unique when present. Cascade's StructureSourceFiness is
|
||||
// the long-term authoritative carrier; the portal denormalises
|
||||
// it inline here for v1 scope-axis checks. ADR-0029's sync
|
||||
// owns the write path.
|
||||
finess String? @unique
|
||||
|
||||
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure
|
||||
// is not SIRENE-registered (most antennes, dispositifs).
|
||||
// Unique when present.
|
||||
siret String? @unique
|
||||
|
||||
// Pléiades payroll code (6 chars). NULL in v1 — populated by
|
||||
// ADR-0029's Pléiades sync once it ships.
|
||||
codePaie String? @unique @map("code_paie")
|
||||
|
||||
// Parent delegation. NULL for structures not attached to one
|
||||
// (siège, mouvement national, …).
|
||||
delegationCode String? @map("delegation_code")
|
||||
delegation Delegation? @relation(fields: [delegationCode], references: [code])
|
||||
|
||||
@@map("structures")
|
||||
@@schema("public")
|
||||
@@index([kind])
|
||||
@@index([delegationCode])
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* Test-tenant seed per ADR-0026 PR 2.
|
||||
*
|
||||
* Run via:
|
||||
* pnpm exec prisma db seed
|
||||
*
|
||||
* Idempotent: re-running this script preserves existing rows. The
|
||||
* checks for "row already there" run against the unique constraints:
|
||||
* - User.entraOid (skipping the cold path of
|
||||
* PersonAndUserProvisioner if the row exists),
|
||||
* - UserScope.(userId, kind, value).
|
||||
*
|
||||
* The seed creates Person + User + UserScope rows for the 19 personas
|
||||
* provisioned in the `apfrd.onmicrosoft.com` test tenant. The
|
||||
* persona matrix below is transcribed from
|
||||
* `notes/test-tenant-role-assignments.md` (gitignored) — that file
|
||||
* remains the human-readable reference; this constant is the source
|
||||
* of truth for the seed.
|
||||
*
|
||||
* **Test-tenant Entra `oid`s** are read from a JSON file pointed at
|
||||
* by `TEST_TENANT_PERSONAS_PATH` (or `infra/test-tenant.personas.json`
|
||||
* by default). The file is gitignored — copy
|
||||
* `infra/test-tenant.personas.example.json` and fill in real values
|
||||
* from the Entra admin centre. Missing file → seed skips the
|
||||
* test-tenant section cleanly (no error, just a log line).
|
||||
*
|
||||
* **`Person.source = 'seed'`** for every row created here, per the
|
||||
* PERSON_SOURCES catalogue. Differentiates these rows from
|
||||
* 'self-signin' (lazy-created at first sign-in) and 'admin-ui' (future
|
||||
* scope-seeding admin screen, ADR-0026 PR 2b).
|
||||
*
|
||||
* **Privileges + functional roles are NOT seeded** — those come from
|
||||
* Entra at sign-in (Entra `roles` claim + `groups` claim resolved by
|
||||
* `EntraGroupToRoleResolver`). Only scopes are portal-side data.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
interface PersonaSeed {
|
||||
readonly slug: string;
|
||||
readonly email: string;
|
||||
readonly displayName: string;
|
||||
/**
|
||||
* Scope tuples per `notes/test-tenant-role-assignments.md`. The
|
||||
* `value` is omitted for valueless kinds (self / siege /
|
||||
* unrestricted) — the seed writes an empty string to the DB
|
||||
* `value` column, matching the column's default.
|
||||
*/
|
||||
readonly scopes: ReadonlyArray<{ readonly kind: string; readonly value?: string }>;
|
||||
}
|
||||
|
||||
const TENANT_DOMAIN = 'apfrd.onmicrosoft.com';
|
||||
|
||||
const PERSONAS: ReadonlyArray<PersonaSeed> = [
|
||||
{
|
||||
slug: 'admin',
|
||||
email: `admin@${TENANT_DOMAIN}`,
|
||||
displayName: 'Admin User',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'directeur-bordeaux',
|
||||
email: `directeur-bordeaux@${TENANT_DOMAIN}`,
|
||||
displayName: 'Directeur Bordeaux',
|
||||
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||
},
|
||||
{
|
||||
slug: 'directeur-complexe',
|
||||
email: `directeur-complexe@${TENANT_DOMAIN}`,
|
||||
displayName: 'Directeur Complexe',
|
||||
scopes: [
|
||||
{ kind: 'etablissement', value: '0330800013' },
|
||||
{ kind: 'etablissement', value: '0330800021' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'rh-aquitaine',
|
||||
email: `rh-aquitaine@${TENANT_DOMAIN}`,
|
||||
displayName: 'RH Aquitaine',
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
},
|
||||
{
|
||||
slug: 'rh-siege',
|
||||
email: `rh-siege@${TENANT_DOMAIN}`,
|
||||
displayName: 'RH Siège',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'collaborateur-simple',
|
||||
email: `collaborateur-simple@${TENANT_DOMAIN}`,
|
||||
displayName: 'Collaborateur Simple',
|
||||
scopes: [{ kind: 'self' }],
|
||||
},
|
||||
{
|
||||
slug: 'tresorier-bordeaux',
|
||||
email: `tresorier-bordeaux@${TENANT_DOMAIN}`,
|
||||
displayName: 'Trésorier Bordeaux',
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
},
|
||||
{
|
||||
slug: 'dpo',
|
||||
email: `dpo@${TENANT_DOMAIN}`,
|
||||
displayName: 'DPO',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'it',
|
||||
email: `it@${TENANT_DOMAIN}`,
|
||||
displayName: 'IT',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'benevole-aquitaine',
|
||||
email: `benevole-aquitaine@${TENANT_DOMAIN}`,
|
||||
displayName: 'Bénévole Aquitaine',
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
},
|
||||
{
|
||||
slug: 'chef-equipe-bordeaux',
|
||||
email: `chef-equipe-bordeaux@${TENANT_DOMAIN}`,
|
||||
displayName: 'Chef Equipe Bordeaux',
|
||||
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||
},
|
||||
{
|
||||
slug: 'chef-service-bordeaux',
|
||||
email: `chef-service-bordeaux@${TENANT_DOMAIN}`,
|
||||
displayName: 'Chef Service Bordeaux',
|
||||
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||
},
|
||||
{
|
||||
slug: 'directeur-territorial-aquitaine',
|
||||
email: `directeur-territorial-aquitaine@${TENANT_DOMAIN}`,
|
||||
displayName: 'Directeur Territorial Aquitaine',
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
},
|
||||
{
|
||||
slug: 'juriste-siege',
|
||||
email: `juriste-siege@${TENANT_DOMAIN}`,
|
||||
displayName: 'Juriste Siège',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'rssi',
|
||||
email: `rssi@${TENANT_DOMAIN}`,
|
||||
displayName: 'RSSI',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'communication-siege',
|
||||
email: `communication-siege@${TENANT_DOMAIN}`,
|
||||
displayName: 'Communication Siège',
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
},
|
||||
{
|
||||
slug: 'elu-ca-national',
|
||||
email: `elu-ca-national@${TENANT_DOMAIN}`,
|
||||
displayName: 'Elu CA National',
|
||||
scopes: [{ kind: 'siege' }],
|
||||
},
|
||||
{
|
||||
slug: 'president-cd-aquitaine',
|
||||
email: `president-cd-aquitaine@${TENANT_DOMAIN}`,
|
||||
displayName: 'Président CD Aquitaine',
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
},
|
||||
{
|
||||
slug: 'secretaire-cd-aquitaine',
|
||||
email: `secretaire-cd-aquitaine@${TENANT_DOMAIN}`,
|
||||
displayName: 'Secrétaire CD Aquitaine',
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
},
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await seedTestTenant(prisma);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function seedTestTenant(prisma: PrismaClient): Promise<void> {
|
||||
const personasPath = resolve(
|
||||
process.env['TEST_TENANT_PERSONAS_PATH'] ?? 'infra/test-tenant.personas.json',
|
||||
);
|
||||
if (!existsSync(personasPath)) {
|
||||
console.log(
|
||||
`[seed] TEST_TENANT_PERSONAS_PATH (${personasPath}) not found — skipping test-tenant seed.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entraOidBySlug: Record<string, string>;
|
||||
try {
|
||||
const raw = readFileSync(personasPath, 'utf8');
|
||||
entraOidBySlug = JSON.parse(raw) as Record<string, string>;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[seed] could not parse ${personasPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tenantId = process.env['ENTRA_TENANT_ID'] ?? TENANT_DOMAIN;
|
||||
const counts = { personsCreated: 0, usersCreated: 0, scopesCreated: 0, skipped: 0 };
|
||||
|
||||
for (const persona of PERSONAS) {
|
||||
const entraOid = entraOidBySlug[persona.slug];
|
||||
if (entraOid === undefined || typeof entraOid !== 'string' || entraOid === '') {
|
||||
console.warn(`[seed] persona "${persona.slug}" missing oid in ${personasPath} — skipping.`);
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { userId, created } = await ensurePersonAndUser(prisma, persona, entraOid, tenantId);
|
||||
if (created) {
|
||||
counts.personsCreated += 1;
|
||||
counts.usersCreated += 1;
|
||||
}
|
||||
for (const scope of persona.scopes) {
|
||||
const seededNew = await ensureUserScope(prisma, userId, scope.kind, scope.value ?? '');
|
||||
if (seededNew) counts.scopesCreated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[seed] test-tenant complete — created ${counts.personsCreated} Person + ${counts.usersCreated} User + ${counts.scopesCreated} UserScope rows; skipped ${counts.skipped} personas (missing oid).`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePersonAndUser(
|
||||
prisma: PrismaClient,
|
||||
persona: PersonaSeed,
|
||||
entraOid: string,
|
||||
tenantId: string,
|
||||
): Promise<{ userId: string; personId: string; created: boolean }> {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { entraOid },
|
||||
select: { id: true, personId: true },
|
||||
});
|
||||
if (existing) {
|
||||
return { userId: existing.id, personId: existing.personId, created: false };
|
||||
}
|
||||
const [firstName, lastName] = splitDisplayName(persona.displayName);
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
entraOid,
|
||||
tenantId,
|
||||
person: {
|
||||
create: {
|
||||
firstName,
|
||||
lastName,
|
||||
email: persona.email,
|
||||
source: 'seed',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true, personId: true },
|
||||
});
|
||||
return { userId: created.id, personId: created.personId, created: true };
|
||||
}
|
||||
|
||||
async function ensureUserScope(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
kind: string,
|
||||
value: string,
|
||||
): Promise<boolean> {
|
||||
const existing = await prisma.userScope.findUnique({
|
||||
where: { userId_kind_value: { userId, kind, value } },
|
||||
});
|
||||
if (existing) return false;
|
||||
await prisma.userScope.create({
|
||||
data: { userId, kind, value, source: 'seed' },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitDisplayName(displayName: string): [string, string] {
|
||||
const trimmed = displayName.trim();
|
||||
const space = trimmed.indexOf(' ');
|
||||
if (space < 0) return [trimmed, ''];
|
||||
return [trimmed.slice(0, space), trimmed.slice(space + 1).trim()];
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error('[seed] failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { Principal } from 'shared-auth';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
||||
|
||||
@@ -13,7 +12,6 @@ interface SessionStub {
|
||||
amr: readonly string[];
|
||||
roles: readonly string[];
|
||||
};
|
||||
principal?: Principal;
|
||||
}
|
||||
|
||||
function makeRequest(opts: {
|
||||
@@ -40,22 +38,6 @@ function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
|
||||
};
|
||||
}
|
||||
|
||||
function principalWithPrivileges(privileges: readonly string[]): Principal {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-oid',
|
||||
personId: 'user-oid',
|
||||
entraOid: 'user-oid',
|
||||
tenantId: 't',
|
||||
displayName: 'Jane',
|
||||
},
|
||||
privileges: privileges as Principal['privileges'],
|
||||
roles: [],
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: ['pwd', 'mfa'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminRoleGuard', () => {
|
||||
it('throws 401 when the request has no session at all', async () => {
|
||||
const audit = makeAuditStub();
|
||||
@@ -65,7 +47,7 @@ describe('AdminRoleGuard', () => {
|
||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 401 when the session exists but no principal nor legacy user is bound', async () => {
|
||||
it('throws 401 when the session exists but no user is bound to it', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(makeRequest({ session: {} }));
|
||||
@@ -73,12 +55,21 @@ describe('AdminRoleGuard', () => {
|
||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 403 + records admin.access_denied when the principal has no privileges', async () => {
|
||||
it('throws 403 + records admin.access_denied when the user has no roles at all', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: { principal: principalWithPrivileges([]) },
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd'],
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
method: 'GET',
|
||||
originalUrl: '/api/admin/me',
|
||||
}),
|
||||
@@ -91,12 +82,21 @@ describe('AdminRoleGuard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
|
||||
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd'],
|
||||
roles: ['editor', 'auditor'],
|
||||
},
|
||||
},
|
||||
method: 'POST',
|
||||
originalUrl: '/api/admin/cms/pages',
|
||||
}),
|
||||
@@ -105,27 +105,45 @@ describe('AdminRoleGuard', () => {
|
||||
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'POST /api/admin/cms/pages',
|
||||
rolesHeld: ['Portal.Auditor'],
|
||||
rolesHeld: ['editor', 'auditor'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
|
||||
);
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true when admin is one of several privileges', async () => {
|
||||
it('returns true and does not audit when the user has the admin role', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: {
|
||||
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [ADMIN_ROLE],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true when admin is one of several roles', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['editor', ADMIN_ROLE, 'auditor'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -137,63 +155,24 @@ describe('AdminRoleGuard', () => {
|
||||
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
|
||||
};
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } }));
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd'],
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The 403 path goes through audit first; if audit throws, the
|
||||
// guard surfaces the underlying error rather than the
|
||||
// ForbiddenException — the caller sees a 500 and the audit
|
||||
// invariant is preserved.
|
||||
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
|
||||
});
|
||||
|
||||
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
|
||||
it('synthesises a principal from session.user when session.principal is absent', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [ADMIN_ROLE, 'Other.Role'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
|
||||
const audit = makeAuditStub();
|
||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||
const ctx = makeContext(
|
||||
makeRequest({
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd'],
|
||||
roles: ['editor', 'auditor'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// The legacy bridge filters the raw `roles` claim down to
|
||||
// `Portal.*` values for the audit row's `rolesHeld` field —
|
||||
// `editor` and `auditor` are not privileges, so the held
|
||||
// list is empty.
|
||||
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/admin/me',
|
||||
rolesHeld: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { readSessionPrincipal } from '../auth/principal-extractor';
|
||||
|
||||
/**
|
||||
* Single Entra app role that gates the entire `/api/admin/*` surface
|
||||
@@ -21,11 +20,6 @@ export const ADMIN_ROLE = 'Portal.Admin';
|
||||
/**
|
||||
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
|
||||
*
|
||||
* Reads from `principal.privileges` per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md)
|
||||
* — the migration thinned the implementation but kept the
|
||||
* `@RequireAdmin()` public API and the `admin.access_denied`
|
||||
* audit event type unchanged.
|
||||
*
|
||||
* Contract
|
||||
* --------
|
||||
* - **No session → 401.** The user is not authenticated at all; the
|
||||
@@ -34,21 +28,19 @@ export const ADMIN_ROLE = 'Portal.Admin';
|
||||
* unauthenticated 401 is normal traffic the absolute-timeout
|
||||
* middleware would surface anyway.
|
||||
*
|
||||
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
|
||||
* The user *is* authenticated; they just are not authorised
|
||||
* for admin. This is the privilege-escalation attempt audit
|
||||
* signal — every denial lands in `audit.events` with
|
||||
* `outcome=denied`, the attempted route as `subject`, and the
|
||||
* `Portal.*` privileges the principal did hold in the payload's
|
||||
* `rolesHeld` field (kept named `rolesHeld` for backward
|
||||
* compatibility with the existing audit shape; the values are
|
||||
* privileges per ADR-0025's renaming of the axis). Per
|
||||
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
|
||||
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
|
||||
* *is* authenticated; they just are not authorised for admin.
|
||||
* This is the privilege-escalation attempt audit signal — every
|
||||
* denial lands in `audit.events` with `outcome=denied`, the
|
||||
* attempted route as `subject`, and the roles the user did hold
|
||||
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
|
||||
* action — if the audit write fails, the request fails too
|
||||
* (consistent with the existing audit call sites in
|
||||
* `AuthController`).
|
||||
*
|
||||
* - **Session with `Portal.Admin` privilege → pass through.**
|
||||
* Downstream controllers see `req.session.user` *and*
|
||||
* `req.session.principal` populated and can rely on the gate
|
||||
* having happened.
|
||||
* - **Session with `Portal.Admin` role → pass through.** Downstream
|
||||
* controllers see `req.session.user` populated and can rely on
|
||||
* the role check having happened.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminRoleGuard implements CanActivate {
|
||||
@@ -56,17 +48,17 @@ export class AdminRoleGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const principal = readSessionPrincipal(req);
|
||||
const user = req.session?.user;
|
||||
|
||||
if (principal === null) {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (!principal.privileges.includes(ADMIN_ROLE)) {
|
||||
if (!user.roles.includes(ADMIN_ROLE)) {
|
||||
await this.audit.adminAccessDenied({
|
||||
actor: { oid: principal.user.entraOid },
|
||||
actor: { oid: user.oid },
|
||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||
rolesHeld: [...principal.privileges],
|
||||
rolesHeld: user.roles,
|
||||
});
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PrismaService } from 'nestjs-prisma';
|
||||
import { AdminUsersReader } from './admin-users-reader.service';
|
||||
|
||||
interface MockPrisma {
|
||||
userDirectoryEntry: {
|
||||
user: {
|
||||
count: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
};
|
||||
@@ -14,7 +14,7 @@ function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma {
|
||||
const count = jest.fn().mockResolvedValue(opts?.count ?? 0);
|
||||
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
|
||||
return {
|
||||
userDirectoryEntry: { count, findMany },
|
||||
user: { count, findMany },
|
||||
// The reader calls $transaction with the promises returned by
|
||||
// `count()` + `findMany()` — the operations have already fired
|
||||
// by the time $transaction sees them. The mock just resolves
|
||||
@@ -48,8 +48,8 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const reader = await createSubject(prisma);
|
||||
const page = await reader.findUsers({});
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.userDirectoryEntry.count).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.userDirectoryEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.count).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(page.total).toBe(5);
|
||||
expect(page.items).toHaveLength(1);
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const prisma = buildPrisma();
|
||||
const reader = await createSubject(prisma);
|
||||
await reader.findUsers({});
|
||||
const findManyArgs = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
||||
const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as {
|
||||
orderBy: ReadonlyArray<Record<string, string>>;
|
||||
};
|
||||
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
|
||||
@@ -83,7 +83,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const prisma = buildPrisma();
|
||||
const reader = await createSubject(prisma);
|
||||
await reader.findUsers({ username: 'jane' });
|
||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
||||
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
||||
where: { username?: { startsWith?: string } };
|
||||
};
|
||||
expect(args.where.username).toEqual({ startsWith: 'jane' });
|
||||
@@ -93,7 +93,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const prisma = buildPrisma();
|
||||
const reader = await createSubject(prisma);
|
||||
await reader.findUsers({ displayName: 'doe' });
|
||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
||||
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
||||
where: { displayName?: { contains?: string; mode?: string } };
|
||||
};
|
||||
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
|
||||
@@ -106,7 +106,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
|
||||
lastSeenAtTo: '2026-05-14T23:59:59.999Z',
|
||||
});
|
||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
||||
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
||||
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
|
||||
};
|
||||
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
|
||||
@@ -119,7 +119,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const prisma = buildPrisma();
|
||||
const reader = await createSubject(prisma);
|
||||
const page = await reader.findUsers({});
|
||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
||||
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
||||
take: number;
|
||||
skip: number;
|
||||
};
|
||||
@@ -133,7 +133,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const prisma = buildPrisma();
|
||||
const reader = await createSubject(prisma);
|
||||
await reader.findUsers({ limit: 1000 });
|
||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { take: number };
|
||||
const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number };
|
||||
expect(args.take).toBe(200);
|
||||
});
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('AdminUsersReader.findUsers', () => {
|
||||
const prisma = buildPrisma();
|
||||
const reader = await createSubject(prisma);
|
||||
await reader.findUsers({});
|
||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: object };
|
||||
const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object };
|
||||
expect(args.where).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ import { PrismaService } from 'nestjs-prisma';
|
||||
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto';
|
||||
|
||||
/**
|
||||
* SPA-facing projection of a row from `public.user_directory_entries`.
|
||||
* Mirrors the Prisma model but exposes ISO-string timestamps so the
|
||||
* SPA never has to know about `Date` serialisation conventions.
|
||||
* SPA-facing projection of a row from `public.users`. Mirrors the
|
||||
* Prisma model but exposes ISO-string timestamps so the SPA never
|
||||
* has to know about `Date` serialisation conventions.
|
||||
*/
|
||||
export interface AdminUserDto {
|
||||
readonly oid: string;
|
||||
@@ -26,15 +26,14 @@ export interface AdminUsersPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* `AdminUsersReader` — query side of the
|
||||
* `public.user_directory_entries` directory per ADR-0020 §"v1 scope
|
||||
* — User list (read-only)". Drives the `GET /api/admin/users` admin
|
||||
* endpoint.
|
||||
* `AdminUsersReader` — query side of the `public.users` directory
|
||||
* per ADR-0020 §"v1 scope — User list (read-only)". Drives the
|
||||
* `GET /api/admin/users` admin endpoint.
|
||||
*
|
||||
* Uses Prisma's typed client directly — unlike `AuditReader`, no
|
||||
* `SET LOCAL ROLE` is needed because `public.user_directory_entries`
|
||||
* has no role-based privilege gate (it's a regular business table;
|
||||
* the trust boundary is the `@RequireAdmin` guard on the controller).
|
||||
* `SET LOCAL ROLE` is needed because `public.users` has no
|
||||
* role-based privilege gate (it's a regular business table; the
|
||||
* trust boundary is the `@RequireAdmin` guard on the controller).
|
||||
*
|
||||
* Default order: `last_seen_at DESC` — the "most recently active"
|
||||
* sort the admin UI most often wants. Falls back to `oid ASC` as
|
||||
@@ -56,8 +55,8 @@ export class AdminUsersReader {
|
||||
// otherwise produce an off-by-one between the count and the
|
||||
// items.
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.userDirectoryEntry.count({ where }),
|
||||
this.prisma.userDirectoryEntry.findMany({
|
||||
this.prisma.user.count({ where }),
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }],
|
||||
take: limit,
|
||||
@@ -74,8 +73,8 @@ export class AdminUsersReader {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput {
|
||||
const where: Prisma.UserDirectoryEntryWhereInput = {};
|
||||
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserWhereInput {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (filters.username !== undefined) {
|
||||
where.username = { startsWith: filters.username };
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersReader } from './admin-users-reader.service';
|
||||
import { AuditReader } from './audit-reader.service';
|
||||
import { AuditStatsReader } from './audit-stats.service';
|
||||
import { UserScopesController } from './user-scopes.controller';
|
||||
import { UserScopesService } from './user-scopes.service';
|
||||
|
||||
/**
|
||||
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
|
||||
@@ -36,13 +34,7 @@ import { UserScopesService } from './user-scopes.service';
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule, RedisModule],
|
||||
controllers: [
|
||||
AdminController,
|
||||
AdminAuthController,
|
||||
AdminAuditController,
|
||||
AdminUsersController,
|
||||
UserScopesController,
|
||||
],
|
||||
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
|
||||
controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController],
|
||||
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Request } from 'express';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import { UserScopesController } from './user-scopes.controller';
|
||||
import type { UserScopesService } from './user-scopes.service';
|
||||
|
||||
function makeFixture(): {
|
||||
controller: UserScopesController;
|
||||
service: { list: jest.Mock; grant: jest.Mock; revoke: jest.Mock };
|
||||
audit: { adminScopeGranted: jest.Mock; adminScopeRevoked: jest.Mock };
|
||||
} {
|
||||
const service = {
|
||||
list: jest.fn(),
|
||||
grant: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
};
|
||||
const audit = {
|
||||
adminScopeGranted: jest.fn().mockResolvedValue(undefined),
|
||||
adminScopeRevoked: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const controller = new UserScopesController(
|
||||
service as unknown as UserScopesService,
|
||||
audit as unknown as AuditWriter,
|
||||
);
|
||||
return { controller, service, audit };
|
||||
}
|
||||
|
||||
function makeReq(actorOid: string | undefined): Request {
|
||||
return {
|
||||
session: actorOid === undefined ? {} : { user: { oid: actorOid } },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('UserScopesController.list', () => {
|
||||
it('returns the service payload verbatim — no audit on reads', async () => {
|
||||
const { controller, service, audit } = makeFixture();
|
||||
service.list.mockResolvedValue({
|
||||
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
|
||||
scopes: [],
|
||||
});
|
||||
const page = await controller.list('target-oid');
|
||||
expect(service.list).toHaveBeenCalledWith('target-oid');
|
||||
expect(page.user.oid).toBe('target-oid');
|
||||
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
|
||||
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesController.grant', () => {
|
||||
it('creates the scope and emits admin.scope_granted blocking audit', async () => {
|
||||
const { controller, service, audit } = makeFixture();
|
||||
service.grant.mockResolvedValue({
|
||||
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
|
||||
scope: {
|
||||
id: 'new-scope',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'admin-ui',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
});
|
||||
const result = await controller.grant(makeReq('admin-oid'), 'target-oid', {
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
});
|
||||
expect(service.grant).toHaveBeenCalledWith('target-oid', {
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
});
|
||||
expect(audit.adminScopeGranted).toHaveBeenCalledWith({
|
||||
actor: { oid: 'admin-oid' },
|
||||
target: { oid: 'target-oid' },
|
||||
scopeId: 'new-scope',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(result.id).toBe('new-scope');
|
||||
});
|
||||
|
||||
it('propagates service errors without writing the audit row', async () => {
|
||||
const { controller, service, audit } = makeFixture();
|
||||
service.grant.mockRejectedValue(new Error('boom'));
|
||||
await expect(
|
||||
controller.grant(makeReq('admin-oid'), 'target-oid', { kind: 'self' }),
|
||||
).rejects.toThrow('boom');
|
||||
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips audit when the session has no actor (defensive — guard guarantees it)', async () => {
|
||||
const { controller, service, audit } = makeFixture();
|
||||
service.grant.mockResolvedValue({
|
||||
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
|
||||
scope: {
|
||||
id: 'new-scope',
|
||||
kind: 'self',
|
||||
value: '',
|
||||
source: 'admin-ui',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
});
|
||||
await controller.grant(makeReq(undefined), 'target-oid', { kind: 'self' });
|
||||
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesController.revoke', () => {
|
||||
it('deletes and emits admin.scope_revoked', async () => {
|
||||
const { controller, service, audit } = makeFixture();
|
||||
service.revoke.mockResolvedValue({
|
||||
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
|
||||
revoked: { kind: 'delegation', value: '33' },
|
||||
});
|
||||
await controller.revoke(makeReq('admin-oid'), 'target-oid', 'scope-1');
|
||||
expect(service.revoke).toHaveBeenCalledWith('target-oid', 'scope-1');
|
||||
expect(audit.adminScopeRevoked).toHaveBeenCalledWith({
|
||||
actor: { oid: 'admin-oid' },
|
||||
target: { oid: 'target-oid' },
|
||||
scopeId: 'scope-1',
|
||||
kind: 'delegation',
|
||||
value: '33',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates service NotFound without writing the audit row', async () => {
|
||||
const { controller, service, audit } = makeFixture();
|
||||
service.revoke.mockRejectedValue(new Error('not found'));
|
||||
await expect(controller.revoke(makeReq('admin-oid'), 'target-oid', 'ghost')).rejects.toThrow();
|
||||
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { RequireAdmin } from './require-admin.decorator';
|
||||
import { GrantUserScopeDto, type UserScopeDto, type UserScopesPageDto } from './user-scopes.dto';
|
||||
import { UserScopesService } from './user-scopes.service';
|
||||
|
||||
/**
|
||||
* `/api/admin/users/:oid/scopes` per ADR-0026 PR 2b — admin
|
||||
* scope-management screen for an existing portal User. Each write
|
||||
* emits a typed audit row (blocking per ADR-0013); reads are NOT
|
||||
* audited (low signal, high noise).
|
||||
*
|
||||
* The `:oid` is the affected user's Entra `oid` — same identifier
|
||||
* the v1 user-directory list (ADR-0020) uses, so the SPA can link
|
||||
* directly from the existing `/admin/users` rows.
|
||||
*/
|
||||
@ApiTags('admin (user scopes)')
|
||||
@ApiCookieAuth('portal_admin_session')
|
||||
@Controller('admin/users/:oid/scopes')
|
||||
@RequireAdmin()
|
||||
export class UserScopesController {
|
||||
constructor(
|
||||
private readonly userScopes: UserScopesService,
|
||||
private readonly audit: AuditWriter,
|
||||
) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "List a user's scopes (no audit — read).",
|
||||
})
|
||||
@Get()
|
||||
async list(@Param('oid') oid: string): Promise<UserScopesPageDto> {
|
||||
return this.userScopes.list(oid);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Grant a scope to a user — emits `admin.scope_granted` (blocking).',
|
||||
})
|
||||
@Post()
|
||||
async grant(
|
||||
@Req() req: Request,
|
||||
@Param('oid') oid: string,
|
||||
@Body() body: GrantUserScopeDto,
|
||||
): Promise<UserScopeDto> {
|
||||
const { user, scope } = await this.userScopes.grant(oid, body);
|
||||
const actorOid = req.session.user?.oid;
|
||||
if (actorOid !== undefined) {
|
||||
await this.audit.adminScopeGranted({
|
||||
actor: { oid: actorOid },
|
||||
target: { oid: user.oid },
|
||||
scopeId: scope.id,
|
||||
kind: scope.kind,
|
||||
value: scope.value,
|
||||
expiresAt: scope.expiresAt,
|
||||
});
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Revoke a scope — emits `admin.scope_revoked` (blocking).',
|
||||
})
|
||||
@Delete(':scopeId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async revoke(
|
||||
@Req() req: Request,
|
||||
@Param('oid') oid: string,
|
||||
@Param('scopeId') scopeId: string,
|
||||
): Promise<void> {
|
||||
const { user, revoked } = await this.userScopes.revoke(oid, scopeId);
|
||||
const actorOid = req.session.user?.oid;
|
||||
if (actorOid !== undefined) {
|
||||
await this.audit.adminScopeRevoked({
|
||||
actor: { oid: actorOid },
|
||||
target: { oid: user.oid },
|
||||
scopeId,
|
||||
kind: revoked.kind,
|
||||
value: revoked.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { IsIn, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { SCOPE_KINDS, type ScopeKind } from 'shared-auth';
|
||||
|
||||
/**
|
||||
* Payload accepted by `POST /api/admin/users/:oid/scopes`. Kind is
|
||||
* validated against the closed catalogue; value validation against
|
||||
* `Structure.code` / `Delegation.code` / `Region.code` (ADR-0027 PR 1)
|
||||
* happens server-side in `UserScopesService.grant`. v1 has no
|
||||
* partial-update; a re-grant with a different `expiresAt` requires
|
||||
* delete + add.
|
||||
*/
|
||||
export class GrantUserScopeDto {
|
||||
@IsString()
|
||||
@IsIn(SCOPE_KINDS as ReadonlyArray<string>)
|
||||
kind!: ScopeKind;
|
||||
|
||||
/**
|
||||
* Value tied to `kind`. Required and non-empty for value-bearing
|
||||
* kinds (`etablissement` / `delegation` / `region`); must be empty
|
||||
* (or omitted — coerced to '' in the service) for valueless kinds
|
||||
* (`self` / `siege` / `unrestricted`). The shape match is handled
|
||||
* by `UserScopesService.grant` so the error surface is one place.
|
||||
*/
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@IsOptional()
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* Optional ISO-8601 expiry. When set and past, `PrismaScopeResolver`
|
||||
* filters the row out at sign-in (`expiresAt IS NULL OR > NOW()`)
|
||||
* per ADR-0026 PR 2a — supports interim-director-for-two-months
|
||||
* patterns without a row deletion.
|
||||
*/
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single row in `GET /api/admin/users/:oid/scopes`. Mirrors
|
||||
* `UserScope` columns; timestamps as ISO strings so the SPA never
|
||||
* has to round-trip `Date` instances.
|
||||
*/
|
||||
export interface UserScopeDto {
|
||||
readonly id: string;
|
||||
readonly kind: string;
|
||||
readonly value: string;
|
||||
readonly source: string;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response of `GET /api/admin/users/:oid/scopes`. Includes a
|
||||
* `user` envelope so the SPA can confirm the right target user
|
||||
* without a second round-trip; `displayName` + `email` are pulled
|
||||
* from the linked `Person` row.
|
||||
*/
|
||||
export interface UserScopesPageDto {
|
||||
readonly user: {
|
||||
readonly oid: string;
|
||||
readonly userId: string;
|
||||
readonly displayName: string;
|
||||
readonly email: string | null;
|
||||
};
|
||||
readonly scopes: ReadonlyArray<UserScopeDto>;
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import type { PrismaService } from 'nestjs-prisma';
|
||||
import { UserScopesService } from './user-scopes.service';
|
||||
|
||||
interface PrismaStub {
|
||||
user: { findUnique: jest.Mock };
|
||||
userScope: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
structure: { findUnique: jest.Mock };
|
||||
delegation: { findUnique: jest.Mock };
|
||||
region: { findUnique: jest.Mock };
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
|
||||
log: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
error: jest.Mock;
|
||||
};
|
||||
}
|
||||
|
||||
function makePrisma(overrides?: Partial<PrismaStub>): PrismaStub {
|
||||
return {
|
||||
user: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.user },
|
||||
userScope: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides?.userScope,
|
||||
},
|
||||
structure: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.structure },
|
||||
delegation: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.delegation },
|
||||
region: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.region },
|
||||
};
|
||||
}
|
||||
|
||||
function makeSubject(overrides?: Partial<PrismaStub>): {
|
||||
service: UserScopesService;
|
||||
prisma: PrismaStub;
|
||||
logger: ReturnType<typeof makeLogger>;
|
||||
} {
|
||||
const prisma = makePrisma(overrides);
|
||||
const logger = makeLogger();
|
||||
const service = new UserScopesService(prisma as unknown as PrismaService, logger);
|
||||
return { service, prisma, logger };
|
||||
}
|
||||
|
||||
const FOUND_USER = {
|
||||
id: 'user-uuid-1',
|
||||
person: { firstName: 'Jane', lastName: 'Doe', email: 'jane@apf.example' },
|
||||
};
|
||||
|
||||
describe('UserScopesService.resolveUserByOid', () => {
|
||||
it('returns the User + composed displayName when found', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
});
|
||||
const result = await service.resolveUserByOid('entra-oid');
|
||||
expect(result).toEqual({
|
||||
oid: 'entra-oid',
|
||||
userId: 'user-uuid-1',
|
||||
displayName: 'Jane Doe',
|
||||
email: 'jane@apf.example',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the User does not exist', async () => {
|
||||
const { service } = makeSubject();
|
||||
await expect(service.resolveUserByOid('ghost-oid')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('falls back to "(unnamed)" when both firstName and lastName are empty', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'u',
|
||||
person: { firstName: '', lastName: '', email: null },
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await service.resolveUserByOid('o');
|
||||
expect(result.displayName).toBe('(unnamed)');
|
||||
expect(result.email).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.list', () => {
|
||||
it('returns scopes ordered by kind then value with ISO timestamps', async () => {
|
||||
const { service, prisma } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
userScope: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 's1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'seed',
|
||||
createdAt: new Date('2026-05-26T10:00:00.000Z'),
|
||||
expiresAt: null,
|
||||
},
|
||||
]),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
});
|
||||
const result = await service.list('entra-oid');
|
||||
expect(prisma.userScope.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-uuid-1' },
|
||||
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
|
||||
}),
|
||||
);
|
||||
expect(result.scopes).toEqual([
|
||||
{
|
||||
id: 's1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'seed',
|
||||
createdAt: '2026-05-26T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.grant — value-bearing kinds', () => {
|
||||
it('creates the row when the etablissement value matches an existing Structure', async () => {
|
||||
const { service, prisma } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
|
||||
userScope: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
create: jest.fn().mockResolvedValue({
|
||||
id: 'new-scope',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'admin-ui',
|
||||
createdAt: new Date('2026-05-26T10:00:00.000Z'),
|
||||
expiresAt: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const out = await service.grant('entra-oid', {
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
});
|
||||
expect(prisma.structure.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { code: '0330800013' } }),
|
||||
);
|
||||
expect(prisma.userScope.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-uuid-1',
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
source: 'admin-ui',
|
||||
expiresAt: null,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(out.scope.kind).toBe('etablissement');
|
||||
expect(out.scope.value).toBe('0330800013');
|
||||
expect(out.scope.source).toBe('admin-ui');
|
||||
});
|
||||
|
||||
it('rejects when the value does not match any Structure row', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
structure: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
await expect(
|
||||
service.grant('entra-oid', { kind: 'etablissement', value: 'bogus' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects when the value is empty for a value-bearing kind', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
});
|
||||
await expect(
|
||||
service.grant('entra-oid', { kind: 'delegation', value: '' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('validates delegation values against the Delegation table', async () => {
|
||||
const { service, prisma } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
delegation: { findUnique: jest.fn().mockResolvedValue({ code: '33' }) },
|
||||
userScope: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
create: jest.fn().mockResolvedValue({
|
||||
id: 's',
|
||||
kind: 'delegation',
|
||||
value: '33',
|
||||
source: 'admin-ui',
|
||||
createdAt: new Date(),
|
||||
expiresAt: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await service.grant('entra-oid', { kind: 'delegation', value: '33' });
|
||||
expect(prisma.delegation.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { code: '33' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.grant — valueless kinds', () => {
|
||||
it('writes value="" for unrestricted and rejects non-empty values', async () => {
|
||||
const { service, prisma } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
userScope: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
create: jest.fn().mockResolvedValue({
|
||||
id: 's',
|
||||
kind: 'unrestricted',
|
||||
value: '',
|
||||
source: 'admin-ui',
|
||||
createdAt: new Date(),
|
||||
expiresAt: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await service.grant('entra-oid', { kind: 'unrestricted' });
|
||||
expect(prisma.userScope.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ value: '' }),
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
service.grant('entra-oid', { kind: 'unrestricted', value: 'something' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.grant — duplicate detection', () => {
|
||||
it('translates Prisma P2002 into a 400 with a friendly message', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
|
||||
userScope: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
create: jest.fn().mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
service.grant('entra-oid', { kind: 'etablissement', value: '0330800013' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserScopesService.revoke', () => {
|
||||
it('deletes the row and returns the resolved tuple for audit', async () => {
|
||||
const { service, prisma } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
userScope: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 's1',
|
||||
userId: 'user-uuid-1',
|
||||
kind: 'delegation',
|
||||
value: '33',
|
||||
}),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
const out = await service.revoke('entra-oid', 's1');
|
||||
expect(prisma.userScope.delete).toHaveBeenCalledWith({ where: { id: 's1' } });
|
||||
expect(out.revoked).toEqual({ kind: 'delegation', value: '33' });
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the scope belongs to a different user', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
userScope: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 's1',
|
||||
userId: 'other-user',
|
||||
kind: 'delegation',
|
||||
value: '33',
|
||||
}),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
});
|
||||
await expect(service.revoke('entra-oid', 's1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no scope exists for that id', async () => {
|
||||
const { service } = makeSubject({
|
||||
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
|
||||
});
|
||||
await expect(service.revoke('entra-oid', 'ghost')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -1,266 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { isScopeKind, type ScopeKind } from 'shared-auth';
|
||||
import type { UserScopeDto, UserScopesPageDto } from './user-scopes.dto';
|
||||
|
||||
/**
|
||||
* Kinds that carry a value referencing an ADR-0027 row. The service
|
||||
* resolves each to its matching table at `grant` time.
|
||||
*/
|
||||
const VALUE_BEARING_KINDS = new Set<ScopeKind>(['etablissement', 'delegation', 'region']);
|
||||
|
||||
export interface ResolvedUser {
|
||||
readonly oid: string;
|
||||
readonly userId: string;
|
||||
readonly displayName: string;
|
||||
readonly email: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UserScopesService` — owns the CRUD surface behind the ADR-0026
|
||||
* PR 2b admin scope-management screen.
|
||||
*
|
||||
* Read side reads `user_scopes` joined to `User` + `Person` (so the
|
||||
* SPA gets the affected user's display info in the same round-trip).
|
||||
* Write side validates the scope tuple before insert:
|
||||
*
|
||||
* - `kind` is enforced by the DTO's `IsIn(SCOPE_KINDS)` plus the
|
||||
* type-guard `isScopeKind` defensively.
|
||||
* - For value-bearing kinds (`etablissement` / `delegation` /
|
||||
* `region`), `value` must match an existing row in the
|
||||
* corresponding ADR-0027 table. The lookup is the only DB call
|
||||
* beyond the insert itself.
|
||||
* - For valueless kinds (`self` / `siege` / `unrestricted`), the
|
||||
* service coerces `value` to `''` and rejects any non-empty
|
||||
* attempt.
|
||||
*
|
||||
* `UserScope.source` is hardcoded to `'admin-ui'` per the
|
||||
* PERSON_SOURCES catalogue — see ADR-0026 PR 1's `person-source.ts`
|
||||
* for the shared catalogue rules. (`UserScope.source` is not in that
|
||||
* catalogue at the drift-gate level today; the seed writes 'seed'
|
||||
* and we write 'admin-ui'. ADR-0029 will add upstream-sync values
|
||||
* and likely formalise the UserScope.source catalogue then.)
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserScopesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves an Entra `oid` to the new ADR-0026 `User` row + its
|
||||
* linked `Person`. Throws 404 when no User row exists for the oid
|
||||
* — caller may need to surface "this persona has never signed in
|
||||
* and is not in the seed" with a hint.
|
||||
*/
|
||||
async resolveUserByOid(oid: string): Promise<ResolvedUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { entraOid: oid },
|
||||
select: {
|
||||
id: true,
|
||||
person: { select: { firstName: true, lastName: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
throw new NotFoundException(`No portal User found for Entra oid ${oid}.`);
|
||||
}
|
||||
return {
|
||||
oid,
|
||||
userId: user.id,
|
||||
displayName: composeDisplay(user.person.firstName, user.person.lastName),
|
||||
email: user.person.email,
|
||||
};
|
||||
}
|
||||
|
||||
async list(oid: string): Promise<UserScopesPageDto> {
|
||||
const target = await this.resolveUserByOid(oid);
|
||||
const rows = await this.prisma.userScope.findMany({
|
||||
where: { userId: target.userId },
|
||||
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
value: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: target,
|
||||
scopes: rows.map(toDto),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a scope. Validates the tuple, then inserts. Returns the
|
||||
* resolved User (so the controller can include it in the audit
|
||||
* payload) plus the freshly-created `UserScope` row.
|
||||
*/
|
||||
async grant(
|
||||
oid: string,
|
||||
input: { kind: string; value?: string; expiresAt?: string },
|
||||
): Promise<{ user: ResolvedUser; scope: UserScopeDto }> {
|
||||
const target = await this.resolveUserByOid(oid);
|
||||
if (!isScopeKind(input.kind)) {
|
||||
// The DTO already enforces this — defensive belt-and-braces.
|
||||
throw new BadRequestException(`Unknown scope kind: ${input.kind}.`);
|
||||
}
|
||||
const normalisedValue = await this.validateValueForKind(input.kind, input.value ?? '');
|
||||
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
|
||||
|
||||
try {
|
||||
const row = await this.prisma.userScope.create({
|
||||
data: {
|
||||
userId: target.userId,
|
||||
kind: input.kind,
|
||||
value: normalisedValue,
|
||||
source: 'admin-ui',
|
||||
expiresAt,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
value: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
return { user: target, scope: toDto(row) };
|
||||
} catch (err) {
|
||||
// Prisma's unique-constraint on (userId, kind, value) raises
|
||||
// P2002. Surface it as 409-ish via a BadRequest so the admin UI
|
||||
// can show "already granted" without a stack trace.
|
||||
if (isPrismaP2002(err)) {
|
||||
throw new BadRequestException(
|
||||
`Scope already granted to this user: kind=${input.kind}, value=${normalisedValue || '(empty)'}.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a scope by its row id. Returns the resolved user + the
|
||||
* deleted tuple (kind / value) so the audit row reflects what the
|
||||
* row WAS — the row itself is gone by the time the controller
|
||||
* audits.
|
||||
*/
|
||||
async revoke(
|
||||
oid: string,
|
||||
scopeId: string,
|
||||
): Promise<{ user: ResolvedUser; revoked: { kind: string; value: string } }> {
|
||||
const target = await this.resolveUserByOid(oid);
|
||||
const existing = await this.prisma.userScope.findUnique({
|
||||
where: { id: scopeId },
|
||||
select: { id: true, userId: true, kind: true, value: true },
|
||||
});
|
||||
if (existing === null || existing.userId !== target.userId) {
|
||||
// Don't leak "this scope id exists but is for someone else" —
|
||||
// 404 either way.
|
||||
throw new NotFoundException(`Scope ${scopeId} not found for user ${oid}.`);
|
||||
}
|
||||
await this.prisma.userScope.delete({ where: { id: scopeId } });
|
||||
this.logger.log(
|
||||
{
|
||||
event: 'user_scopes.revoked',
|
||||
oid,
|
||||
scopeId,
|
||||
kind: existing.kind,
|
||||
value: existing.value,
|
||||
},
|
||||
'UserScopesService',
|
||||
);
|
||||
return {
|
||||
user: target,
|
||||
revoked: { kind: existing.kind, value: existing.value },
|
||||
};
|
||||
}
|
||||
|
||||
private async validateValueForKind(kind: ScopeKind, rawValue: string): Promise<string> {
|
||||
if (!VALUE_BEARING_KINDS.has(kind)) {
|
||||
if (rawValue !== '') {
|
||||
throw new BadRequestException(`Scope kind '${kind}' is valueless — value must be empty.`);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (rawValue === '') {
|
||||
throw new BadRequestException(`Scope kind '${kind}' requires a non-empty value.`);
|
||||
}
|
||||
// Look up the referenced row. Single existence check per kind;
|
||||
// ADR-0026 PR 1's "no FK on UserScope.value" rationale lets the
|
||||
// value persist even after the target is decommissioned, so we
|
||||
// validate only at the write path.
|
||||
//
|
||||
// Initialise `exists = false` so TypeScript sees it as definitely
|
||||
// assigned regardless of how the switch lands (the type-system
|
||||
// can't see that `VALUE_BEARING_KINDS.has` already narrowed
|
||||
// `kind` to the three branches). If somehow a non-value-bearing
|
||||
// kind reached this code, the false default makes the next `if`
|
||||
// throw with the same friendly "does not match" message — safe
|
||||
// by construction.
|
||||
let exists = false;
|
||||
switch (kind) {
|
||||
case 'etablissement':
|
||||
exists =
|
||||
(await this.prisma.structure.findUnique({
|
||||
where: { code: rawValue },
|
||||
select: { code: true },
|
||||
})) !== null;
|
||||
break;
|
||||
case 'delegation':
|
||||
exists =
|
||||
(await this.prisma.delegation.findUnique({
|
||||
where: { code: rawValue },
|
||||
select: { code: true },
|
||||
})) !== null;
|
||||
break;
|
||||
case 'region':
|
||||
exists =
|
||||
(await this.prisma.region.findUnique({
|
||||
where: { code: rawValue },
|
||||
select: { code: true },
|
||||
})) !== null;
|
||||
break;
|
||||
}
|
||||
if (!exists) {
|
||||
throw new BadRequestException(`Scope value '${rawValue}' does not match any ${kind} row.`);
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
function toDto(row: {
|
||||
id: string;
|
||||
kind: string;
|
||||
value: string;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}): UserScopeDto {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
value: row.value,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt === null ? null : row.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function composeDisplay(firstName: string, lastName: string): string {
|
||||
const combined = `${firstName} ${lastName}`.trim();
|
||||
return combined === '' ? '(unnamed)' : combined;
|
||||
}
|
||||
|
||||
function isPrismaP2002(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
(err as { code: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { AdminModule } from '../admin/admin.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DownstreamModule } from '../downstream/downstream.module';
|
||||
import { AiBridgeModule } from '../grpc/ai-bridge/ai-bridge.module';
|
||||
import { MeModule } from '../me/me.module';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { SecurityModule } from '../security/security.module';
|
||||
@@ -26,7 +25,6 @@ import { UsersModule } from '../users/users.module';
|
||||
SecurityModule,
|
||||
HealthModule,
|
||||
AdminModule,
|
||||
AiBridgeModule,
|
||||
DownstreamModule,
|
||||
MeModule,
|
||||
UsersModule,
|
||||
|
||||
@@ -469,67 +469,4 @@ describe('AuditWriter — typed event methods', () => {
|
||||
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizationDenied()', () => {
|
||||
it('records auth.authorization_denied with kind=privilege payload', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.authorizationDenied({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/audit',
|
||||
kind: 'privilege',
|
||||
required: ['Portal.Auditor'],
|
||||
held: ['Portal.Admin'],
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.authorization_denied');
|
||||
expect(row.outcome).toBe('denied');
|
||||
expect(row.subject).toBe('GET /api/audit');
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
kind: 'privilege',
|
||||
required: ['Portal.Auditor'],
|
||||
held: ['Portal.Admin'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records kind=role with the role lists', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.authorizationDenied({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/hr/payroll',
|
||||
kind: 'role',
|
||||
required: ['rh', 'responsable-paie'],
|
||||
held: ['collaborateur'],
|
||||
});
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
kind: 'role',
|
||||
required: ['rh', 'responsable-paie'],
|
||||
held: ['collaborateur'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records kind=scope with the scope descriptors', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.authorizationDenied({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/etablissement/0330800021',
|
||||
kind: 'scope',
|
||||
required: ['etablissement:0330800021'],
|
||||
held: ['etablissement:0330800013'],
|
||||
});
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
kind: 'scope',
|
||||
required: ['etablissement:0330800021'],
|
||||
held: ['etablissement:0330800013'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,8 @@ import type {
|
||||
AdminAccessDeniedInput,
|
||||
AdminAuditQueryInput,
|
||||
AdminAuditStatsQueryInput,
|
||||
AdminScopeGrantedInput,
|
||||
AdminScopeRevokedInput,
|
||||
AdminUsersQueryInput,
|
||||
AuditEventInput,
|
||||
AuthorizationDeniedInput,
|
||||
MfaRequiredInput,
|
||||
SignInActor,
|
||||
SignInFailedInput,
|
||||
@@ -222,50 +219,6 @@ export class AuditWriter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: an admin granted a scope to a user via the ADR-0026
|
||||
* PR 2b scope-management screen. Blocking per ADR-0013 — a failed
|
||||
* audit write means the scope write itself is rolled back upstream.
|
||||
* The `subject` is `user:<oid>` of the affected user, so an auditor
|
||||
* pivots on the *target* of the change, not the actor.
|
||||
*/
|
||||
async adminScopeGranted(input: AdminScopeGrantedInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'admin.scope_granted',
|
||||
audience: 'workforce',
|
||||
outcome: 'success',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: `user:${input.target.oid}`,
|
||||
payload: {
|
||||
scopeId: input.scopeId,
|
||||
kind: input.kind,
|
||||
value: input.value,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: an admin revoked a scope from a user. Payload
|
||||
* carries the resolved tuple at the moment of revocation so the
|
||||
* deletion is reconstructable from the audit log alone — the
|
||||
* `user_scopes` row itself is gone by then.
|
||||
*/
|
||||
async adminScopeRevoked(input: AdminScopeRevokedInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'admin.scope_revoked',
|
||||
audience: 'workforce',
|
||||
outcome: 'success',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: `user:${input.target.oid}`,
|
||||
payload: {
|
||||
scopeId: input.scopeId,
|
||||
kind: input.kind,
|
||||
value: input.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
||||
* because the session's `roles` claim does not include `Portal.Admin`.
|
||||
@@ -286,34 +239,6 @@ export class AuditWriter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: a request was rejected by one of the three new
|
||||
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
|
||||
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
|
||||
* an auditor can keep the existing admin-surface signal clean
|
||||
* while tracking the broader role/scope denial surface as it
|
||||
* grows.
|
||||
*
|
||||
* `outcome=denied` matches the existing posture: the user *is*
|
||||
* authenticated, the action is just refused. The `required` and
|
||||
* `held` payload arrays let an auditor pivot on "tried admin
|
||||
* while logged in as RH" without joining anything.
|
||||
*/
|
||||
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.authorization_denied',
|
||||
audience: 'workforce',
|
||||
outcome: 'denied',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: input.attemptedRoute,
|
||||
payload: {
|
||||
kind: input.kind,
|
||||
required: input.required,
|
||||
held: input.held,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async recordEvent(input: AuditEventInput): Promise<void> {
|
||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||
const actorIdHash =
|
||||
|
||||
@@ -133,38 +133,6 @@ export interface AdminUsersQueryInput {
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin granted a scope to a user via the ADR-0026 PR 2b admin
|
||||
* screen. The `target` field carries the affected user's Entra `oid`
|
||||
* (the URL-path identifier of the scope-management screen). Stored
|
||||
* as the audit row's `subject` (`user:<oid>`). Payload captures the
|
||||
* scope tuple + the resulting row id + the expiry so a reviewer can
|
||||
* spot operator drift without re-querying.
|
||||
*/
|
||||
export interface AdminScopeGrantedInput {
|
||||
actor: { oid: string };
|
||||
target: { oid: string };
|
||||
scopeId: string;
|
||||
kind: string;
|
||||
value: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin revoked (deleted) a scope row from a user. Same target
|
||||
* convention as {@link AdminScopeGrantedInput}; payload carries the
|
||||
* resolved tuple at the moment of revocation so the deletion is
|
||||
* reconstructable from the audit log even after the row itself is
|
||||
* gone.
|
||||
*/
|
||||
export interface AdminScopeRevokedInput {
|
||||
actor: { oid: string };
|
||||
target: { oid: string };
|
||||
scopeId: string;
|
||||
kind: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit aggregations read — `GET /api/admin/audit/stats`. Same
|
||||
* deterrent posture as {@link AdminAuditQueryInput} (every admin
|
||||
@@ -179,42 +147,6 @@ export interface AdminAuditStatsQueryInput {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator on `AuthorizationDeniedInput.kind` — which of the
|
||||
* three new ADR-0025 guards rejected the request. Stored in the
|
||||
* payload so an auditor can spot "what kind of authorization
|
||||
* failed" without parsing the route. The existing
|
||||
* `admin.access_denied` event keeps its own type for backwards
|
||||
* compatibility with the legacy `AdminRoleGuard` audit posture.
|
||||
*/
|
||||
export type AuthorizationDeniedKind = 'privilege' | 'role' | 'scope';
|
||||
|
||||
export interface AuthorizationDeniedInput {
|
||||
actor: { oid: string };
|
||||
/** Canonical "{METHOD} {originalUrl}" of the rejected request. */
|
||||
attemptedRoute: string;
|
||||
/** Which guard fired the rejection. */
|
||||
kind: AuthorizationDeniedKind;
|
||||
/**
|
||||
* For `kind: 'privilege' | 'role'`: the catalogue values the
|
||||
* guard required. For `kind: 'scope'`: the route's resource
|
||||
* descriptor serialised into a `{kind, value}` map. Stored as
|
||||
* a generic `string[]` rather than the union of `Privilege` /
|
||||
* `FunctionalRole` so the audit module stays decoupled from the
|
||||
* shared catalogue.
|
||||
*/
|
||||
required: readonly string[];
|
||||
/**
|
||||
* What the principal actually held on the matching axis at the
|
||||
* moment of denial — `privileges[]` for privilege/admin denials,
|
||||
* `roles[]` for role denials, `scopes[]` (serialised as
|
||||
* `kind` or `kind:value` strings) for scope denials. Lets an
|
||||
* auditor spot "tried admin while logged in as RH" patterns
|
||||
* without joining anything.
|
||||
*/
|
||||
held: readonly string[];
|
||||
}
|
||||
|
||||
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
||||
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Persona-matrix integration tests per ADR-0025 §"More Information"
|
||||
* phasing step 2: the three new guards exercised against every
|
||||
* persona provisioned in the `apfrd.onmicrosoft.com` test tenant on
|
||||
* 2026-05-20. Each test instantiates the guard with the persona's
|
||||
* resolved `Principal` (the shape `PrincipalBuilder` would have
|
||||
* produced at sign-in) and asserts the guard either passes or
|
||||
* denies — the matrix proves the contracts hold against the real
|
||||
* provisioning matrix, not just synthesised toy principals.
|
||||
*
|
||||
* Scopes are stubbed `unrestricted` for every persona in v1 per
|
||||
* ADR-0025 §331 (the per-persona scope values land with the
|
||||
* Prisma `user_scopes` table in a later PR). The scope guard is
|
||||
* therefore exercised here against synthesised varied principals,
|
||||
* not against the persona matrix.
|
||||
*/
|
||||
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import type { FunctionalRole, Principal, Privilege } from 'shared-auth';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||
import { RequireRoleGuard } from './require-role.guard';
|
||||
|
||||
interface Persona {
|
||||
readonly label: string;
|
||||
readonly privileges: readonly Privilege[];
|
||||
readonly roles: readonly FunctionalRole[];
|
||||
}
|
||||
|
||||
// Verbatim copy of the persona matrix from
|
||||
// `principal-builder.spec.ts`. Kept here as a local constant so a
|
||||
// reviewer sees the same 19-row table both files assert against;
|
||||
// the two specs cover different layers (builder, then guards).
|
||||
const PERSONAS: readonly Persona[] = [
|
||||
{ label: 'admin', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'] },
|
||||
{
|
||||
label: 'directeur-bordeaux',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'directeur-etablissement'],
|
||||
},
|
||||
{
|
||||
label: 'directeur-complexe',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'directeur-etablissement'],
|
||||
},
|
||||
{ label: 'rh-aquitaine', privileges: [], roles: ['collaborateur', 'rh', 'formation'] },
|
||||
{
|
||||
label: 'rh-siege',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
|
||||
},
|
||||
{ label: 'collaborateur-simple', privileges: [], roles: ['collaborateur'] },
|
||||
{ label: 'tresorier-bordeaux', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'] },
|
||||
{
|
||||
label: 'dpo',
|
||||
privileges: ['Portal.DPO', 'Portal.Auditor'],
|
||||
roles: ['collaborateur', 'dpo', 'qualite'],
|
||||
},
|
||||
{ label: 'it', privileges: [], roles: ['collaborateur', 'it'] },
|
||||
{
|
||||
label: 'benevole-aquitaine',
|
||||
privileges: [],
|
||||
roles: ['delegue', 'benevole', 'benevole-responsable'],
|
||||
},
|
||||
{ label: 'chef-equipe-bordeaux', privileges: [], roles: ['collaborateur', 'chef-equipe'] },
|
||||
{ label: 'chef-service-bordeaux', privileges: [], roles: ['collaborateur', 'chef-service'] },
|
||||
{
|
||||
label: 'directeur-territorial-aquitaine',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'directeur-territorial'],
|
||||
},
|
||||
{ label: 'juriste-siege', privileges: [], roles: ['collaborateur', 'juriste'] },
|
||||
{ label: 'rssi', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'] },
|
||||
{ label: 'communication-siege', privileges: [], roles: ['collaborateur', 'communication'] },
|
||||
{ label: 'elu-ca-national', privileges: [], roles: ['elu-ca'] },
|
||||
{ label: 'president-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-president'] },
|
||||
{ label: 'secretaire-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'] },
|
||||
];
|
||||
|
||||
function principalOf(persona: Persona): Principal {
|
||||
return {
|
||||
user: {
|
||||
id: `oid-${persona.label}`,
|
||||
personId: `oid-${persona.label}`,
|
||||
entraOid: `oid-${persona.label}`,
|
||||
tenantId: 'tenant-1',
|
||||
displayName: persona.label,
|
||||
},
|
||||
privileges: persona.privileges,
|
||||
roles: persona.roles,
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: ['pwd', 'mfa'],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtxFor(principal: Principal): ExecutionContext {
|
||||
const req = {
|
||||
session: { principal },
|
||||
method: 'GET',
|
||||
originalUrl: '/api/test',
|
||||
} as unknown as Request;
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||
getHandler: () => 'handler',
|
||||
getClass: () => 'TestController',
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function privilegeGuardFor(metadata: readonly Privilege[]) {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(metadata),
|
||||
} as unknown as Reflector;
|
||||
const audit = jest.fn().mockResolvedValue(undefined);
|
||||
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
|
||||
}
|
||||
|
||||
function roleGuardFor(metadata: readonly FunctionalRole[]) {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(metadata),
|
||||
} as unknown as Reflector;
|
||||
const audit = jest.fn().mockResolvedValue(undefined);
|
||||
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||
return { guard: new RequireRoleGuard(reflector, writer), audit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Each row asserts which personas should pass a given guard. The
|
||||
* complement (everyone NOT in `allowed`) must be denied — the
|
||||
* `expect denied` block at the bottom of each describe catches a
|
||||
* regression that would silently let extra personas through.
|
||||
*/
|
||||
interface Matrix {
|
||||
readonly title: string;
|
||||
readonly allowed: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const PRIVILEGE_MATRIX: ReadonlyArray<{
|
||||
metadata: readonly Privilege[];
|
||||
matrix: Matrix;
|
||||
}> = [
|
||||
{
|
||||
metadata: ['Portal.Admin'],
|
||||
matrix: { title: '@RequirePrivilege(Portal.Admin)', allowed: ['admin'] },
|
||||
},
|
||||
{
|
||||
metadata: ['Portal.DPO'],
|
||||
matrix: { title: '@RequirePrivilege(Portal.DPO)', allowed: ['dpo'] },
|
||||
},
|
||||
{
|
||||
metadata: ['Portal.Auditor'],
|
||||
matrix: { title: '@RequirePrivilege(Portal.Auditor)', allowed: ['dpo'] },
|
||||
},
|
||||
{
|
||||
metadata: ['Portal.SecurityOfficer'],
|
||||
matrix: { title: '@RequirePrivilege(Portal.SecurityOfficer)', allowed: ['rssi'] },
|
||||
},
|
||||
{
|
||||
metadata: ['Portal.Admin', 'Portal.DPO'],
|
||||
matrix: {
|
||||
title: '@RequirePrivilege(Portal.Admin, Portal.DPO) — OR composition',
|
||||
allowed: ['admin', 'dpo'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ROLE_MATRIX: ReadonlyArray<{
|
||||
metadata: readonly FunctionalRole[];
|
||||
matrix: Matrix;
|
||||
}> = [
|
||||
{
|
||||
metadata: ['rh'],
|
||||
matrix: { title: '@RequireRole(rh)', allowed: ['admin', 'rh-aquitaine', 'rh-siege'] },
|
||||
},
|
||||
{
|
||||
metadata: ['directeur-etablissement'],
|
||||
matrix: {
|
||||
title: '@RequireRole(directeur-etablissement)',
|
||||
allowed: ['directeur-bordeaux', 'directeur-complexe'],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: ['rh', 'comptable', 'responsable-paie'],
|
||||
matrix: {
|
||||
title: '@RequireRole(rh, comptable, responsable-paie) — OR composition',
|
||||
allowed: ['admin', 'rh-aquitaine', 'rh-siege'],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: ['elu-cd'],
|
||||
matrix: {
|
||||
title: '@RequireRole(elu-cd) — every CD member',
|
||||
allowed: ['tresorier-bordeaux', 'president-cd-aquitaine', 'secretaire-cd-aquitaine'],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: ['benevole-responsable'],
|
||||
matrix: {
|
||||
title: '@RequireRole(benevole-responsable)',
|
||||
allowed: ['benevole-aquitaine'],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: ['collaborateur'],
|
||||
matrix: {
|
||||
title: '@RequireRole(collaborateur) — every workforce persona',
|
||||
allowed: [
|
||||
'admin',
|
||||
'directeur-bordeaux',
|
||||
'directeur-complexe',
|
||||
'rh-aquitaine',
|
||||
'rh-siege',
|
||||
'collaborateur-simple',
|
||||
'dpo',
|
||||
'it',
|
||||
'chef-equipe-bordeaux',
|
||||
'chef-service-bordeaux',
|
||||
'directeur-territorial-aquitaine',
|
||||
'juriste-siege',
|
||||
'rssi',
|
||||
'communication-siege',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('Privilege guard — persona matrix', () => {
|
||||
for (const { metadata, matrix } of PRIVILEGE_MATRIX) {
|
||||
describe(matrix.title, () => {
|
||||
for (const persona of PERSONAS) {
|
||||
const shouldPass = matrix.allowed.includes(persona.label);
|
||||
const verb = shouldPass ? 'allows' : 'denies';
|
||||
it(`${verb} ${persona.label}`, async () => {
|
||||
const { guard } = privilegeGuardFor(metadata);
|
||||
const ctx = makeCtxFor(principalOf(persona));
|
||||
if (shouldPass) {
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
} else {
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Role guard — persona matrix', () => {
|
||||
for (const { metadata, matrix } of ROLE_MATRIX) {
|
||||
describe(matrix.title, () => {
|
||||
for (const persona of PERSONAS) {
|
||||
const shouldPass = matrix.allowed.includes(persona.label);
|
||||
const verb = shouldPass ? 'allows' : 'denies';
|
||||
it(`${verb} ${persona.label}`, async () => {
|
||||
const { guard } = roleGuardFor(metadata);
|
||||
const ctx = makeCtxFor(principalOf(persona));
|
||||
if (shouldPass) {
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
} else {
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -147,44 +147,14 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller
|
||||
// The directory service is a best-effort dependency — a noop mock
|
||||
// is sufficient for the controller's behavioural assertions.
|
||||
const userDirectory = { recordSignIn: jest.fn().mockResolvedValue(undefined) };
|
||||
// The provisioner is blocking per ADR-0026 — the controller specs
|
||||
// don't exercise the provisioning behaviour itself, so a noop mock
|
||||
// returning a fixed UUID pair is enough to let SessionEstablisher
|
||||
// proceed.
|
||||
const personUserProvisioner = {
|
||||
ensureUser: jest.fn().mockResolvedValue({
|
||||
userId: '00000000-0000-0000-0000-000000000fff',
|
||||
personId: '00000000-0000-0000-0000-000000000eee',
|
||||
}),
|
||||
};
|
||||
// Real SessionEstablisher with the same mocks the legacy tests
|
||||
// already wire — keeps the behavioural assertions on session
|
||||
// fields / audit calls untouched after the controller refactor.
|
||||
const principalBuilder = {
|
||||
// The auth.controller specs do not assert on the principal
|
||||
// shape; the resolved value just has to be a valid `Principal`
|
||||
// so SessionEstablisher persists it without throwing.
|
||||
build: jest.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: '00000000-0000-0000-0000-000000000fff',
|
||||
personId: '00000000-0000-0000-0000-000000000eee',
|
||||
entraOid: 'oid',
|
||||
tenantId: 'tid',
|
||||
displayName: 'Jane',
|
||||
},
|
||||
privileges: [],
|
||||
roles: [],
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: [],
|
||||
}),
|
||||
};
|
||||
const sessionEstablisher = new SessionEstablisher(
|
||||
logger as unknown as Logger,
|
||||
userSessionIndex as unknown as UserSessionIndexService,
|
||||
audit as unknown as AuditWriter,
|
||||
userDirectory as unknown as UserDirectoryService,
|
||||
personUserProvisioner as unknown as import('../users/person-and-user-provisioner.service').PersonAndUserProvisioner,
|
||||
principalBuilder as unknown as import('./principal-builder').PrincipalBuilder,
|
||||
);
|
||||
return {
|
||||
controller: new AuthController(
|
||||
|
||||
@@ -2,20 +2,12 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { assertEntraConfig } from '../config/check-entra-config';
|
||||
import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
||||
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
|
||||
import { MSAL_CLIENT } from './msal-client.token';
|
||||
import { PrincipalBuilder } from './principal-builder';
|
||||
import { PrismaScopeResolver } from './prisma-scope-resolver';
|
||||
import { RequireMfaGuard } from './require-mfa.guard';
|
||||
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||
import { RequireRoleGuard } from './require-role.guard';
|
||||
import { RequireScopeGuard } from './require-scope.guard';
|
||||
import { ScopeResolver } from './scope-resolver';
|
||||
import { SessionEstablisher } from './session-establisher.service';
|
||||
|
||||
/**
|
||||
@@ -54,43 +46,11 @@ import { SessionEstablisher } from './session-establisher.service';
|
||||
providers: [
|
||||
AuthService,
|
||||
RequireMfaGuard,
|
||||
RequirePrivilegeGuard,
|
||||
RequireRoleGuard,
|
||||
RequireScopeGuard,
|
||||
SessionEstablisher,
|
||||
PrincipalBuilder,
|
||||
// PrismaScopeResolver replaces StubScopeResolver per ADR-0026 PR 2.
|
||||
// The stub class stays exported from scope-resolver.ts as a
|
||||
// fallback for spec fixtures / future "force-unrestricted" dev
|
||||
// modes, but it is no longer the default wiring.
|
||||
{ provide: ScopeResolver, useClass: PrismaScopeResolver },
|
||||
{
|
||||
provide: ENTRA_CONFIG,
|
||||
useFactory: () => assertEntraConfig(),
|
||||
},
|
||||
{
|
||||
provide: ENTRA_GROUP_TO_ROLE_RESOLVER,
|
||||
inject: [Logger],
|
||||
useFactory: (logger: Logger) => {
|
||||
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
|
||||
onWarn: (event, payload) => logger.warn({ event, ...payload }, 'AuthModule'),
|
||||
});
|
||||
// Log the post-load summary unconditionally so an operator
|
||||
// grepping the boot log can confirm how many functional
|
||||
// roles are wired without inspecting the JSON file. The
|
||||
// file path (if any) helps diagnose env-var / cwd mismatches.
|
||||
logger.log(
|
||||
{
|
||||
event: 'auth.entra_group_map_loaded',
|
||||
sourcePath,
|
||||
mappingCount: resolver.size,
|
||||
coveredRoles: resolver.coveredRoles(),
|
||||
},
|
||||
'AuthModule',
|
||||
);
|
||||
return resolver;
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MSAL_CLIENT,
|
||||
inject: [ENTRA_CONFIG, Logger],
|
||||
@@ -132,18 +92,6 @@ import { SessionEstablisher } from './session-establisher.service';
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
ENTRA_CONFIG,
|
||||
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
||||
MSAL_CLIENT,
|
||||
RequireMfaGuard,
|
||||
RequirePrivilegeGuard,
|
||||
RequireRoleGuard,
|
||||
RequireScopeGuard,
|
||||
AuthService,
|
||||
PrincipalBuilder,
|
||||
ScopeResolver,
|
||||
SessionEstablisher,
|
||||
],
|
||||
exports: [ENTRA_CONFIG, MSAL_CLIENT, RequireMfaGuard, AuthService, SessionEstablisher],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -38,7 +38,6 @@ function makeAuthResult(
|
||||
claims: Partial<{
|
||||
amr: string[];
|
||||
roles: unknown;
|
||||
groups: unknown;
|
||||
oid: string;
|
||||
tid: string;
|
||||
name: string;
|
||||
@@ -59,12 +58,6 @@ function makeAuthResult(
|
||||
if (Object.prototype.hasOwnProperty.call(claims, 'roles')) {
|
||||
idTokenClaims['roles'] = claims.roles;
|
||||
}
|
||||
// Same opt-in pattern for `groups` — Entra emits it only when
|
||||
// the app registration sets `groupMembershipClaims: 'SecurityGroup'`
|
||||
// and the user is in at least one group.
|
||||
if (Object.prototype.hasOwnProperty.call(claims, 'groups')) {
|
||||
idTokenClaims['groups'] = claims.groups;
|
||||
}
|
||||
return {
|
||||
idTokenClaims,
|
||||
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
|
||||
@@ -131,7 +124,6 @@ describe('AuthService.completeAuthCodeFlow', () => {
|
||||
displayName: 'Jane Doe',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [],
|
||||
groups: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,80 +220,6 @@ describe('AuthService.completeAuthCodeFlow', () => {
|
||||
expect(user.roles).toEqual(['admin', 'editor']);
|
||||
});
|
||||
|
||||
// `groups` claim — extracted identically to `roles`. The raw
|
||||
// GUIDs are surfaced here; resolution to `apf-role-*` slugs
|
||||
// happens downstream in `PrincipalBuilder` per ADR-0025.
|
||||
it('surfaces the `groups` claim when Entra includes it (security-group member)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(
|
||||
makeAuthResult({
|
||||
groups: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'],
|
||||
}),
|
||||
);
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty `groups` array when the claim is absent (no group membership)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({}));
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty `groups` array when the claim is non-array (defensive)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({ groups: 'oops' }));
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops non-string entries from `groups` (defensive)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(
|
||||
makeAuthResult({
|
||||
groups: [
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
42,
|
||||
null,
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
],
|
||||
}),
|
||||
);
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws token-exchange-failed when MSAL throws', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
|
||||
@@ -38,23 +38,11 @@ export interface AuthCodeFlowStart {
|
||||
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
|
||||
* freshness checks once the session lands.
|
||||
* - `roles`: Entra app roles assigned to this user on the BFF's
|
||||
* app registration. These are the four `Portal.*` *privileges*
|
||||
* per ADR-0025 (`Portal.Admin`, `Portal.Auditor`,
|
||||
* `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the
|
||||
* existing `@RequireAdmin()` guard and the upcoming
|
||||
* `@RequirePrivilege()` decorator. Always present in the shape
|
||||
* — an empty array means the user has no app role on this app
|
||||
* registration, not that the claim was unparseable.
|
||||
* - `groups`: Entra security-group GUIDs the user belongs to.
|
||||
* Emitted by the `groups` claim once the app registration sets
|
||||
* `groupMembershipClaims: 'SecurityGroup'` (per ADR-0025
|
||||
* §"Sources of truth — Entra-side configuration"). The
|
||||
* `PrincipalBuilder` resolves these GUIDs to `apf-role-*` slugs
|
||||
* via the `EntraGroupToRoleResolver`. Always present — empty
|
||||
* means either the user has no group membership or the claim
|
||||
* was not configured on the app registration. Unknown GUIDs
|
||||
* are dropped at resolve time, not at extraction time, so an
|
||||
* audit reader still sees the raw claim.
|
||||
* app registration. Surfaced for the future `@RequireAdmin` /
|
||||
* `@RequireRole(...)` guards (ADR-0020 admin module). Always
|
||||
* present in the shape — an empty array means the user has no
|
||||
* app role on this app registration, not that the claim was
|
||||
* unparseable.
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
readonly oid: string;
|
||||
@@ -63,7 +51,6 @@ export interface AuthenticatedUser {
|
||||
readonly displayName: string;
|
||||
readonly amr: readonly string[];
|
||||
readonly roles: readonly string[];
|
||||
readonly groups: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,18 +212,6 @@ export class AuthService {
|
||||
? (claims['roles'] as unknown[]).filter((v): v is string => typeof v === 'string')
|
||||
: [];
|
||||
|
||||
// `groups` is an optional claim (per ADR-0025 §"Sources of truth
|
||||
// — Entra-side configuration"). Present only when the app
|
||||
// registration sets `groupMembershipClaims: 'SecurityGroup'`.
|
||||
// Empty array if the user is in zero security groups or the
|
||||
// claim is not configured — both are valid "no functional
|
||||
// roles" states the rest of the BFF must tolerate. Same
|
||||
// string-filter as `roles` to defend against a non-string value
|
||||
// smuggled in via a malformed token.
|
||||
const groups = Array.isArray(claims['groups'])
|
||||
? (claims['groups'] as unknown[]).filter((v): v is string => typeof v === 'string')
|
||||
: [];
|
||||
|
||||
return {
|
||||
oid: requireString(claims['oid'], 'oid'),
|
||||
tid: requireString(claims['tid'], 'tid'),
|
||||
@@ -250,7 +225,6 @@ export class AuthService {
|
||||
: (result.account?.name ?? ''),
|
||||
amr,
|
||||
roles,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* DI token for the lazily-loaded `EntraGroupToRoleResolver`.
|
||||
*
|
||||
* The resolver wraps a `Map<groupGuid, FunctionalRole>` parsed
|
||||
* once at boot from `infra/<env>-tenant.entra.json` (per ADR-0025
|
||||
* §"Sources of truth — Entra-side configuration"). The token is
|
||||
* separate from the class import so the BFF can swap the
|
||||
* implementation (an empty-map stub when no file is configured,
|
||||
* a real one when `ENTRA_GROUP_MAP_PATH` resolves) without the
|
||||
* consumers caring.
|
||||
*/
|
||||
export const ENTRA_GROUP_TO_ROLE_RESOLVER = Symbol('ENTRA_GROUP_TO_ROLE_RESOLVER');
|
||||
@@ -1,359 +0,0 @@
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import {
|
||||
EntraGroupToRoleResolver,
|
||||
parseEntraGroupMap,
|
||||
type FunctionalRole,
|
||||
type Scope,
|
||||
} from 'shared-auth';
|
||||
import type { AuthenticatedUser } from './auth.service';
|
||||
import { PrincipalBuilder } from './principal-builder';
|
||||
import type { ScopeResolver } from './scope-resolver';
|
||||
|
||||
/**
|
||||
* Mirrors the 24-entry `apf-role-*` catalogue: one synthetic GUID
|
||||
* per slug, in catalogue order. The exact GUIDs do not matter to
|
||||
* any assertion below — what matters is that the test resolver
|
||||
* maps them deterministically so a persona's `groups` claim can
|
||||
* be assembled by slug.
|
||||
*/
|
||||
const ROLE_GUID: Record<FunctionalRole, string> = {
|
||||
collaborateur: '00000000-0000-0000-0000-000000000101',
|
||||
'chef-equipe': '00000000-0000-0000-0000-000000000102',
|
||||
'chef-service': '00000000-0000-0000-0000-000000000103',
|
||||
'directeur-etablissement': '00000000-0000-0000-0000-000000000104',
|
||||
'directeur-territorial': '00000000-0000-0000-0000-000000000105',
|
||||
rh: '00000000-0000-0000-0000-000000000106',
|
||||
'responsable-paie': '00000000-0000-0000-0000-000000000107',
|
||||
comptable: '00000000-0000-0000-0000-000000000108',
|
||||
juriste: '00000000-0000-0000-0000-000000000109',
|
||||
dpo: '00000000-0000-0000-0000-00000000010a',
|
||||
rssi: '00000000-0000-0000-0000-00000000010b',
|
||||
it: '00000000-0000-0000-0000-00000000010c',
|
||||
formation: '00000000-0000-0000-0000-00000000010d',
|
||||
qualite: '00000000-0000-0000-0000-00000000010e',
|
||||
communication: '00000000-0000-0000-0000-00000000010f',
|
||||
'elu-ca': '00000000-0000-0000-0000-000000000110',
|
||||
'elu-cd': '00000000-0000-0000-0000-000000000111',
|
||||
'elu-cd-president': '00000000-0000-0000-0000-000000000112',
|
||||
'elu-cd-tresorier': '00000000-0000-0000-0000-000000000113',
|
||||
'elu-cd-secretaire': '00000000-0000-0000-0000-000000000114',
|
||||
delegue: '00000000-0000-0000-0000-000000000115',
|
||||
benevole: '00000000-0000-0000-0000-000000000116',
|
||||
'benevole-responsable': '00000000-0000-0000-0000-000000000117',
|
||||
partenaire: '00000000-0000-0000-0000-000000000118',
|
||||
};
|
||||
|
||||
/**
|
||||
* The 19 personas provisioned in the `apfrd.onmicrosoft.com` test
|
||||
* tenant on 2026-05-20, transcribed from
|
||||
* `notes/test-tenant-role-assignments.md` (reverse view). The
|
||||
* builder's contract is that these inputs round-trip to the
|
||||
* expected `(privileges, roles, scopes)` triple — when the spec
|
||||
* passes, every persona we documented in the ADR is exercised
|
||||
* end-to-end.
|
||||
*
|
||||
* Scopes are stubbed to `[{ kind: 'unrestricted' }]` for every
|
||||
* persona in v1 per ADR-0025 §"Sources of truth — apf_portal-side
|
||||
* `user_scopes` table". The intended per-persona scopes
|
||||
* (`etablissement:…`, `delegation:33`, …) land with the Prisma
|
||||
* `user_scopes` table — the next PR after this skeleton.
|
||||
*/
|
||||
interface Persona {
|
||||
readonly label: string;
|
||||
readonly username: string;
|
||||
readonly privileges: readonly string[]; // raw `roles` claim values
|
||||
readonly roles: readonly FunctionalRole[];
|
||||
}
|
||||
|
||||
const PERSONAS: readonly Persona[] = [
|
||||
{
|
||||
label: 'admin',
|
||||
username: 'admin@apfrd.onmicrosoft.com',
|
||||
privileges: ['Portal.Admin'],
|
||||
roles: ['collaborateur', 'rh'],
|
||||
},
|
||||
{
|
||||
label: 'directeur-bordeaux',
|
||||
username: 'directeur-bordeaux@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'directeur-etablissement'],
|
||||
},
|
||||
{
|
||||
label: 'directeur-complexe',
|
||||
username: 'directeur-complexe@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'directeur-etablissement'],
|
||||
},
|
||||
{
|
||||
label: 'rh-aquitaine',
|
||||
username: 'rh-aquitaine@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'rh', 'formation'],
|
||||
},
|
||||
{
|
||||
label: 'rh-siege',
|
||||
username: 'rh-siege@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
|
||||
},
|
||||
{
|
||||
label: 'collaborateur-simple',
|
||||
username: 'collaborateur-simple@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur'],
|
||||
},
|
||||
{
|
||||
label: 'tresorier-bordeaux',
|
||||
username: 'tresorier-bordeaux@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['elu-cd', 'elu-cd-tresorier'],
|
||||
},
|
||||
{
|
||||
label: 'dpo',
|
||||
username: 'dpo@apfrd.onmicrosoft.com',
|
||||
privileges: ['Portal.DPO', 'Portal.Auditor'],
|
||||
roles: ['collaborateur', 'dpo', 'qualite'],
|
||||
},
|
||||
{
|
||||
label: 'it',
|
||||
username: 'it@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'it'],
|
||||
},
|
||||
{
|
||||
label: 'benevole-aquitaine',
|
||||
username: 'benevole-aquitaine@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
// Catalogue order: delegue (governance) precedes benevole +
|
||||
// benevole-responsable (volunteer). The resolver re-sorts on
|
||||
// catalogue order regardless of the Entra claim's order.
|
||||
roles: ['delegue', 'benevole', 'benevole-responsable'],
|
||||
},
|
||||
{
|
||||
label: 'chef-equipe-bordeaux',
|
||||
username: 'chef-equipe-bordeaux@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'chef-equipe'],
|
||||
},
|
||||
{
|
||||
label: 'chef-service-bordeaux',
|
||||
username: 'chef-service-bordeaux@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'chef-service'],
|
||||
},
|
||||
{
|
||||
label: 'directeur-territorial-aquitaine',
|
||||
username: 'directeur-territorial-aquitaine@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'directeur-territorial'],
|
||||
},
|
||||
{
|
||||
label: 'juriste-siege',
|
||||
username: 'juriste-siege@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'juriste'],
|
||||
},
|
||||
{
|
||||
label: 'rssi',
|
||||
username: 'rssi@apfrd.onmicrosoft.com',
|
||||
privileges: ['Portal.SecurityOfficer'],
|
||||
roles: ['collaborateur', 'rssi'],
|
||||
},
|
||||
{
|
||||
label: 'communication-siege',
|
||||
username: 'communication-siege@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['collaborateur', 'communication'],
|
||||
},
|
||||
{
|
||||
label: 'elu-ca-national',
|
||||
username: 'elu-ca-national@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['elu-ca'],
|
||||
},
|
||||
{
|
||||
label: 'president-cd-aquitaine',
|
||||
username: 'president-cd-aquitaine@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['elu-cd', 'elu-cd-president'],
|
||||
},
|
||||
{
|
||||
label: 'secretaire-cd-aquitaine',
|
||||
username: 'secretaire-cd-aquitaine@apfrd.onmicrosoft.com',
|
||||
privileges: [],
|
||||
roles: ['elu-cd', 'elu-cd-secretaire'],
|
||||
},
|
||||
];
|
||||
|
||||
function makeUser(p: Persona): AuthenticatedUser {
|
||||
return {
|
||||
oid: `oid-${p.label}`,
|
||||
tid: 'tenant-1',
|
||||
username: p.username,
|
||||
displayName: p.label,
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [...p.privileges],
|
||||
groups: p.roles.map((slug) => ROLE_GUID[slug]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed identity used by every `build()` call in the spec. The
|
||||
* provisioner is mocked at the `SessionEstablisher` test level; the
|
||||
* `PrincipalBuilder` spec receives the UUIDs verbatim — what matters
|
||||
* here is that they round-trip onto `Principal.user.{id, personId}`.
|
||||
*/
|
||||
const TEST_IDENTITY = {
|
||||
userId: '00000000-0000-0000-0000-000000000fff',
|
||||
personId: '00000000-0000-0000-0000-000000000eee',
|
||||
};
|
||||
|
||||
function makeBuilder(): {
|
||||
builder: PrincipalBuilder;
|
||||
scopeResolver: { resolve: jest.Mock };
|
||||
logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock };
|
||||
} {
|
||||
const rawMap: Record<string, FunctionalRole> = {};
|
||||
for (const [slug, guid] of Object.entries(ROLE_GUID) as Array<[FunctionalRole, string]>) {
|
||||
rawMap[guid] = slug;
|
||||
}
|
||||
const resolver = new EntraGroupToRoleResolver(parseEntraGroupMap(rawMap));
|
||||
const scopeResolver = {
|
||||
resolve: jest.fn().mockResolvedValue([{ kind: 'unrestricted' } as Scope]),
|
||||
};
|
||||
const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||
const builder = new PrincipalBuilder(
|
||||
resolver,
|
||||
scopeResolver as unknown as ScopeResolver,
|
||||
logger as unknown as Logger,
|
||||
);
|
||||
return { builder, scopeResolver, logger };
|
||||
}
|
||||
|
||||
describe('PrincipalBuilder — 19 test-tenant personas', () => {
|
||||
for (const persona of PERSONAS) {
|
||||
it(`builds the principal for ${persona.label}`, async () => {
|
||||
const { builder } = makeBuilder();
|
||||
const principal = await builder.build(makeUser(persona), TEST_IDENTITY);
|
||||
expect(principal.privileges).toEqual(persona.privileges);
|
||||
expect(principal.roles).toEqual(persona.roles);
|
||||
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
|
||||
expect(principal.user.entraOid).toBe(`oid-${persona.label}`);
|
||||
expect(principal.user.tenantId).toBe('tenant-1');
|
||||
// Real UUIDs from the provisioner, per ADR-0026 PR 1 — no
|
||||
// longer the entraOid placeholder.
|
||||
expect(principal.user.id).toBe(TEST_IDENTITY.userId);
|
||||
expect(principal.user.personId).toBe(TEST_IDENTITY.personId);
|
||||
// amr passes through verbatim — MFA freshness checks read it
|
||||
// off the principal per ADR-0011.
|
||||
expect(principal.amr).toEqual(['pwd', 'mfa']);
|
||||
});
|
||||
}
|
||||
|
||||
it('asserts the 19 personas cover all 4 privileges + 23 of 24 functional roles', () => {
|
||||
// Coverage check baked into the spec so a regression that
|
||||
// drops a role from a persona is caught here, not at PR review.
|
||||
// The intentional gap is `partenaire` (placeholder per ADR-0025).
|
||||
const privilegesUsed = new Set(PERSONAS.flatMap((p) => p.privileges));
|
||||
expect(privilegesUsed).toEqual(
|
||||
new Set(['Portal.Admin', 'Portal.Auditor', 'Portal.SecurityOfficer', 'Portal.DPO']),
|
||||
);
|
||||
const rolesUsed = new Set(PERSONAS.flatMap((p) => p.roles));
|
||||
expect(rolesUsed.size).toBe(23);
|
||||
expect(rolesUsed.has('partenaire')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrincipalBuilder — edge cases', () => {
|
||||
it('returns an empty privileges array when the user has no app role assignment', async () => {
|
||||
const { builder } = makeBuilder();
|
||||
const user = makeUser({
|
||||
label: 'anon',
|
||||
username: 'anon@example',
|
||||
privileges: [],
|
||||
roles: ['collaborateur'],
|
||||
});
|
||||
const principal = await builder.build(user, TEST_IDENTITY);
|
||||
expect(principal.privileges).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops + warns on a claim value that is not a known privilege', async () => {
|
||||
const { builder, logger } = makeBuilder();
|
||||
const user = makeUser({
|
||||
label: 'stale',
|
||||
username: 'stale@example',
|
||||
privileges: [],
|
||||
roles: [],
|
||||
});
|
||||
// Force in a non-catalogue value the way an old assignment would.
|
||||
const userWithDrift: AuthenticatedUser = {
|
||||
...user,
|
||||
roles: ['Portal.Admin', 'Portal.GhostRole'],
|
||||
};
|
||||
const principal = await builder.build(userWithDrift, TEST_IDENTITY);
|
||||
expect(principal.privileges).toEqual(['Portal.Admin']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'auth.unknown_privilege_claim',
|
||||
value: 'Portal.GhostRole',
|
||||
}),
|
||||
'PrincipalBuilder',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops + warns on an unknown group GUID (tenant misconfiguration)', async () => {
|
||||
const { builder, logger } = makeBuilder();
|
||||
const user = makeUser({
|
||||
label: 'ghost-group',
|
||||
username: 'ghost-group@example',
|
||||
privileges: [],
|
||||
roles: ['collaborateur'],
|
||||
});
|
||||
const userWithUnknownGroup: AuthenticatedUser = {
|
||||
...user,
|
||||
groups: [...user.groups, 'ffffffff-ffff-ffff-ffff-ffffffffffff'],
|
||||
};
|
||||
const principal = await builder.build(userWithUnknownGroup, TEST_IDENTITY);
|
||||
expect(principal.roles).toEqual(['collaborateur']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'auth.unknown_group_claim',
|
||||
groupId: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||
}),
|
||||
'PrincipalBuilder',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds an empty-permissions principal when the user has no groups or roles', async () => {
|
||||
const { builder } = makeBuilder();
|
||||
const user = makeUser({
|
||||
label: 'empty',
|
||||
username: 'empty@example',
|
||||
privileges: [],
|
||||
roles: [],
|
||||
});
|
||||
const principal = await builder.build(user, TEST_IDENTITY);
|
||||
expect(principal.privileges).toEqual([]);
|
||||
expect(principal.roles).toEqual([]);
|
||||
// Scope resolver stub returns unrestricted — the v1 behaviour
|
||||
// per ADR-0025 §331 until the user_scopes table lands.
|
||||
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
|
||||
});
|
||||
|
||||
it('asks the scope resolver to resolve by userId (ADR-0026 PR 1 seam change)', async () => {
|
||||
const { builder, scopeResolver } = makeBuilder();
|
||||
await builder.build(
|
||||
makeUser({
|
||||
label: 'sr',
|
||||
username: 'sr@example',
|
||||
privileges: [],
|
||||
roles: ['collaborateur'],
|
||||
}),
|
||||
TEST_IDENTITY,
|
||||
);
|
||||
// Pre-ADR-0026 the resolver was called with { entraOid }; PR 1
|
||||
// switches the seam to { userId } so PR 2's PrismaScopeResolver
|
||||
// can key queries on the real User.id.
|
||||
expect(scopeResolver.resolve).toHaveBeenCalledWith({ userId: TEST_IDENTITY.userId });
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { EntraGroupToRoleResolver, isPrivilege, type Principal, type Privilege } from 'shared-auth';
|
||||
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
|
||||
import { ScopeResolver } from './scope-resolver';
|
||||
import type { AuthenticatedUser } from './auth.service';
|
||||
|
||||
/**
|
||||
* Builds the session-resident `Principal` per
|
||||
* [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
*
|
||||
* Composes the three orthogonal axes once at sign-in:
|
||||
* - **Privileges** — pulled from the `roles` claim, filtered to
|
||||
* the closed `PRIVILEGES` catalogue. Unknown values are
|
||||
* dropped (with a WARN) rather than honoured: the BFF only
|
||||
* reasons about catalogue privileges, anything else is
|
||||
* either a tenant misconfiguration or a leftover from a v1+1
|
||||
* experiment that should not silently grant access.
|
||||
* - **Functional roles** — resolved from the `groups` claim via
|
||||
* `EntraGroupToRoleResolver`. Unknown GUIDs are logged at
|
||||
* WARN (per ADR-0025 §"Sources of truth — Entra-side
|
||||
* configuration") and ignored.
|
||||
* - **Scopes** — resolved by `ScopeResolver` from the portal-side
|
||||
* `user_scopes` table (schema landed in ADR-0026 PR 1; the
|
||||
* `PrismaScopeResolver` consumer lands in PR 2). v1 stubs to
|
||||
* `[{ kind: 'unrestricted' }]`.
|
||||
*
|
||||
* Built once per sign-in, not per request: the session payload
|
||||
* carries the resolved `Principal` so guards can read it without
|
||||
* re-doing the GUID lookup on every API call.
|
||||
*
|
||||
* The `identity` parameter (UUIDs from `PersonAndUserProvisioner`)
|
||||
* populates `Principal.user.{id, personId}` with the real portal-
|
||||
* side identifiers introduced in ADR-0026 PR 1 — pre-PR-1 the BFF
|
||||
* reused the Entra `oid` as a placeholder.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrincipalBuilder {
|
||||
constructor(
|
||||
@Inject(ENTRA_GROUP_TO_ROLE_RESOLVER)
|
||||
private readonly groupToRole: EntraGroupToRoleResolver,
|
||||
private readonly scopes: ScopeResolver,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async build(
|
||||
user: AuthenticatedUser,
|
||||
identity: { userId: string; personId: string },
|
||||
): Promise<Principal> {
|
||||
const privileges = filterPrivileges(user.roles, user.oid, this.logger);
|
||||
|
||||
const roles = this.groupToRole.resolve(user.groups, (groupId: string) => {
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'auth.unknown_group_claim',
|
||||
oid: user.oid,
|
||||
groupId,
|
||||
},
|
||||
'PrincipalBuilder',
|
||||
);
|
||||
});
|
||||
|
||||
const scopes = await this.scopes.resolve({ userId: identity.userId });
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: identity.userId,
|
||||
personId: identity.personId,
|
||||
entraOid: user.oid,
|
||||
tenantId: user.tid,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
privileges,
|
||||
roles,
|
||||
scopes,
|
||||
amr: user.amr,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function filterPrivileges(
|
||||
rawRoles: readonly string[],
|
||||
oid: string,
|
||||
logger: Logger,
|
||||
): ReadonlyArray<Privilege> {
|
||||
const out: Privilege[] = [];
|
||||
for (const value of rawRoles) {
|
||||
if (isPrivilege(value)) {
|
||||
out.push(value);
|
||||
} else {
|
||||
// A value in the `roles` claim that is not a known privilege
|
||||
// is either a leftover from a different app registration, a
|
||||
// typo in the Entra manifest, or a future privilege not yet
|
||||
// in the catalogue. None of those should silently grant
|
||||
// access — drop with a WARN so an operator can investigate.
|
||||
logger.warn(
|
||||
{
|
||||
event: 'auth.unknown_privilege_claim',
|
||||
oid,
|
||||
value,
|
||||
},
|
||||
'PrincipalBuilder',
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { Request } from 'express';
|
||||
import type { Principal, Scope } from 'shared-auth';
|
||||
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
|
||||
|
||||
const PRINCIPAL: Principal = {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
personId: 'person-1',
|
||||
entraOid: 'oid-1',
|
||||
tenantId: 'tenant-1',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
privileges: ['Portal.Admin'],
|
||||
roles: ['rh'],
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: ['pwd', 'mfa'],
|
||||
};
|
||||
|
||||
function makeReq(session: Record<string, unknown> | undefined): Request {
|
||||
return { session } as unknown as Request;
|
||||
}
|
||||
|
||||
describe('readSessionPrincipal', () => {
|
||||
it('returns null when the request has no session at all', () => {
|
||||
expect(readSessionPrincipal(makeReq(undefined))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the session has neither principal nor legacy user', () => {
|
||||
expect(readSessionPrincipal(makeReq({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the principal verbatim when session.principal is set', () => {
|
||||
const out = readSessionPrincipal(makeReq({ principal: PRINCIPAL }));
|
||||
expect(out).toBe(PRINCIPAL);
|
||||
});
|
||||
|
||||
describe('legacy bridge — session.user only (pre-ADR-0025 sessions)', () => {
|
||||
it('synthesises a principal carrying the Portal.* values as privileges', () => {
|
||||
const req = makeReq({
|
||||
user: {
|
||||
oid: 'legacy-oid',
|
||||
tid: 'tenant-7',
|
||||
username: 'legacy@example',
|
||||
displayName: 'Legacy Joe',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['Portal.Admin', 'Portal.DPO', 'editor', 'auditor'],
|
||||
groups: [],
|
||||
},
|
||||
});
|
||||
const out = readSessionPrincipal(req);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.privileges).toEqual(['Portal.Admin', 'Portal.DPO']);
|
||||
expect(out!.user.entraOid).toBe('legacy-oid');
|
||||
expect(out!.user.tenantId).toBe('tenant-7');
|
||||
});
|
||||
|
||||
it('mirrors entraOid into user.id and user.personId (ADR-0026 placeholder)', () => {
|
||||
const req = makeReq({
|
||||
user: {
|
||||
oid: 'legacy-oid',
|
||||
tid: 'tenant-7',
|
||||
username: 'legacy@example',
|
||||
displayName: 'Legacy Joe',
|
||||
amr: [],
|
||||
roles: [],
|
||||
groups: [],
|
||||
},
|
||||
});
|
||||
const out = readSessionPrincipal(req);
|
||||
expect(out!.user.id).toBe('legacy-oid');
|
||||
expect(out!.user.personId).toBe('legacy-oid');
|
||||
});
|
||||
|
||||
it('legacy sessions get no functional roles and a coarse unrestricted scope', () => {
|
||||
const req = makeReq({
|
||||
user: {
|
||||
oid: 'legacy-oid',
|
||||
tid: 'tenant-7',
|
||||
username: 'legacy@example',
|
||||
displayName: 'Legacy Joe',
|
||||
amr: [],
|
||||
roles: ['Portal.Admin'],
|
||||
groups: [],
|
||||
},
|
||||
});
|
||||
const out = readSessionPrincipal(req);
|
||||
expect(out!.roles).toEqual([]);
|
||||
expect(out!.scopes).toEqual([{ kind: 'unrestricted' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopesToAuditStrings', () => {
|
||||
it('renders implicit scopes as the kind verbatim', () => {
|
||||
const scopes: Scope[] = [{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }];
|
||||
expect(scopesToAuditStrings(scopes)).toEqual(['self', 'siege', 'unrestricted']);
|
||||
});
|
||||
|
||||
it('renders value-bearing scopes as kind:value', () => {
|
||||
const scopes: Scope[] = [
|
||||
{ kind: 'etablissement', value: '0330800013' },
|
||||
{ kind: 'delegation', value: '33' },
|
||||
{ kind: 'region', value: '75' },
|
||||
];
|
||||
expect(scopesToAuditStrings(scopes)).toEqual([
|
||||
'etablissement:0330800013',
|
||||
'delegation:33',
|
||||
'region:75',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array on an empty scope list', () => {
|
||||
expect(scopesToAuditStrings([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Request } from 'express';
|
||||
import type { Principal, Scope } from 'shared-auth';
|
||||
|
||||
/**
|
||||
* Reads the authorization `Principal` off the express session,
|
||||
* coalescing the legacy session-shape carryover so a session that
|
||||
* was minted before the ADR-0025 implementation landed still works
|
||||
* for the duration of its 12 h absolute TTL.
|
||||
*
|
||||
* Returns `null` when no session is present at all (the guard
|
||||
* should answer 401 — the user is anonymous). Returns a
|
||||
* synthesised `Principal` derived from `session.user` when the
|
||||
* session is authenticated but predates the `principal` field —
|
||||
* the synthesised principal carries the `Portal.*` privileges
|
||||
* from the legacy `roles` claim, no functional roles (the
|
||||
* `groups` claim was unconfigured at the time), and an
|
||||
* `unrestricted` scope. After every persisted session has cycled
|
||||
* through the absolute-timeout window post-deploy, this fallback
|
||||
* is dead code.
|
||||
*/
|
||||
export function readSessionPrincipal(req: Request): Principal | null {
|
||||
const session = req.session;
|
||||
if (session === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.principal !== undefined) {
|
||||
return session.principal;
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
if (user === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy session bridge. Filter `user.roles` through a generic
|
||||
// `Portal.*` heuristic rather than re-importing `isPrivilege`
|
||||
// here — the file's whole purpose is graceful degradation; if
|
||||
// the legacy claim carries a non-catalogue Portal.X the matcher
|
||||
// downstream just returns false on it.
|
||||
const portalPrivileges = user.roles.filter((r) => r.startsWith('Portal.'));
|
||||
const fallback: Principal = {
|
||||
user: {
|
||||
id: user.oid,
|
||||
personId: user.oid,
|
||||
entraOid: user.oid,
|
||||
tenantId: user.tid,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
privileges: portalPrivileges as Principal['privileges'],
|
||||
roles: [],
|
||||
scopes: [{ kind: 'unrestricted' } satisfies Scope],
|
||||
amr: user.amr,
|
||||
};
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a scope list as the `string[]` shape the
|
||||
* `AuthorizationDeniedInput.held` audit field expects:
|
||||
* `unrestricted` / `self` / `siege` ride as-is;
|
||||
* `etablissement:<finess>` / `delegation:<dept>` / `region:<insee>`
|
||||
* concatenate kind and value. Mirrors `PrincipalProjector` in
|
||||
* spirit but is independent (the audit module must not depend on
|
||||
* the AI-bridge projector — different surfaces).
|
||||
*/
|
||||
export function scopesToAuditStrings(scopes: ReadonlyArray<Scope>): string[] {
|
||||
return scopes.map((s) => {
|
||||
if (s.kind === 'etablissement' || s.kind === 'delegation' || s.kind === 'region') {
|
||||
return `${s.kind}:${s.value}`;
|
||||
}
|
||||
return s.kind;
|
||||
});
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import type { PrismaService } from 'nestjs-prisma';
|
||||
import { PrismaScopeResolver, toScope } from './prisma-scope-resolver';
|
||||
|
||||
interface PrismaStub {
|
||||
userScope: {
|
||||
findMany: jest.Mock;
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
|
||||
log: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
error: jest.Mock;
|
||||
};
|
||||
}
|
||||
|
||||
function makeSubject(opts?: { findMany?: jest.Mock }): {
|
||||
resolver: PrismaScopeResolver;
|
||||
prisma: PrismaStub;
|
||||
logger: ReturnType<typeof makeLogger>;
|
||||
} {
|
||||
const prisma: PrismaStub = {
|
||||
userScope: {
|
||||
findMany: opts?.findMany ?? jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const logger = makeLogger();
|
||||
const resolver = new PrismaScopeResolver(prisma as unknown as PrismaService, logger);
|
||||
return { resolver, prisma, logger };
|
||||
}
|
||||
|
||||
describe('PrismaScopeResolver.resolve — query shape', () => {
|
||||
it('filters by userId and excludes rows whose expiresAt has passed', async () => {
|
||||
const { resolver, prisma } = makeSubject();
|
||||
await resolver.resolve({ userId: 'user-uuid-1' });
|
||||
expect(prisma.userScope.findMany).toHaveBeenCalledTimes(1);
|
||||
const args = prisma.userScope.findMany.mock.calls[0]?.[0] as {
|
||||
where: {
|
||||
userId: string;
|
||||
OR: ReadonlyArray<{ expiresAt: Date | { gt: Date } | null }>;
|
||||
};
|
||||
select: { kind: boolean; value: boolean };
|
||||
};
|
||||
expect(args.where.userId).toBe('user-uuid-1');
|
||||
expect(args.where.OR).toHaveLength(2);
|
||||
expect(args.where.OR[0]).toEqual({ expiresAt: null });
|
||||
// The second predicate carries a `gt: <Date>` matching "expiresAt > NOW()".
|
||||
expect(args.where.OR[1]).toMatchObject({ expiresAt: { gt: expect.any(Date) } });
|
||||
expect(args.select).toEqual({ kind: true, value: true });
|
||||
});
|
||||
|
||||
it('returns an empty array when the user has no scope rows', async () => {
|
||||
const { resolver } = makeSubject();
|
||||
const result = await resolver.resolve({ userId: 'user-uuid-1' });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrismaScopeResolver.resolve — row-to-Scope mapping', () => {
|
||||
it('maps valueless kinds to `{ kind }`', async () => {
|
||||
const findMany = jest.fn().mockResolvedValue([
|
||||
{ kind: 'self', value: '' },
|
||||
{ kind: 'siege', value: '' },
|
||||
{ kind: 'unrestricted', value: '' },
|
||||
]);
|
||||
const { resolver } = makeSubject({ findMany });
|
||||
const result = await resolver.resolve({ userId: 'u' });
|
||||
expect(result).toEqual([{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }]);
|
||||
});
|
||||
|
||||
it('maps value-bearing kinds to `{ kind, value }`', async () => {
|
||||
const findMany = jest.fn().mockResolvedValue([
|
||||
{ kind: 'etablissement', value: '0330800013' },
|
||||
{ kind: 'delegation', value: '33' },
|
||||
{ kind: 'region', value: '75' },
|
||||
]);
|
||||
const { resolver } = makeSubject({ findMany });
|
||||
const result = await resolver.resolve({ userId: 'u' });
|
||||
expect(result).toEqual([
|
||||
{ kind: 'etablissement', value: '0330800013' },
|
||||
{ kind: 'delegation', value: '33' },
|
||||
{ kind: 'region', value: '75' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips + warns on off-catalogue kinds (defense in depth — drift gate is the canonical guard)', async () => {
|
||||
const findMany = jest.fn().mockResolvedValue([
|
||||
{ kind: 'self', value: '' },
|
||||
{ kind: 'phantom-kind', value: '' },
|
||||
{ kind: 'unrestricted', value: '' },
|
||||
]);
|
||||
const { resolver, logger } = makeSubject({ findMany });
|
||||
const result = await resolver.resolve({ userId: 'u' });
|
||||
expect(result).toEqual([{ kind: 'self' }, { kind: 'unrestricted' }]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'scope_resolver.unknown_kind',
|
||||
userId: 'u',
|
||||
kind: 'phantom-kind',
|
||||
}),
|
||||
'PrismaScopeResolver',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toScope — pure mapping helper', () => {
|
||||
it('returns null for off-catalogue kinds', () => {
|
||||
expect(toScope('phantom-kind', '')).toBeNull();
|
||||
expect(toScope('', '')).toBeNull();
|
||||
expect(toScope('Etablissement', '0330800013')).toBeNull(); // case-sensitive
|
||||
});
|
||||
|
||||
it('drops the value for valueless kinds (even if a stale value is in the row)', () => {
|
||||
expect(toScope('self', 'stale')).toEqual({ kind: 'self' });
|
||||
expect(toScope('siege', 'stale')).toEqual({ kind: 'siege' });
|
||||
expect(toScope('unrestricted', 'stale')).toEqual({ kind: 'unrestricted' });
|
||||
});
|
||||
|
||||
it('preserves the value verbatim for value-bearing kinds', () => {
|
||||
expect(toScope('etablissement', '0330800013')).toEqual({
|
||||
kind: 'etablissement',
|
||||
value: '0330800013',
|
||||
});
|
||||
expect(toScope('delegation', '2A')).toEqual({ kind: 'delegation', value: '2A' });
|
||||
expect(toScope('region', '75')).toEqual({ kind: 'region', value: '75' });
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { isScopeKind, type Scope } from 'shared-auth';
|
||||
import { ScopeResolver } from './scope-resolver';
|
||||
|
||||
/**
|
||||
* `PrismaScopeResolver` — production implementation of
|
||||
* [`ScopeResolver`](scope-resolver.ts) backed by the `user_scopes`
|
||||
* table introduced in ADR-0026 PR 1.
|
||||
*
|
||||
* Replaces `StubScopeResolver` (which always returned
|
||||
* `[{ kind: 'unrestricted' }]`) per ADR-0025 §"Sources of truth —
|
||||
* apf_portal-side `user_scopes` table". Called once per sign-in by
|
||||
* `PrincipalBuilder.build`; the resolved scopes ride on the
|
||||
* session-resident `Principal` so guards never re-query at request
|
||||
* time.
|
||||
*
|
||||
* **Expiry handling.** Rows with a non-null `expiresAt` in the past
|
||||
* are filtered server-side (`expiresAt IS NULL OR expiresAt > NOW()`).
|
||||
* The unique-tuple constraint on `(userId, kind, value)` means a
|
||||
* persona with a `delegation:33` scope that just expired can be
|
||||
* re-granted by inserting a new row — the resolver picks up the
|
||||
* fresh one on the next sign-in. No background cleanup job in v1;
|
||||
* expired rows are harmless until either a re-grant fires the
|
||||
* unique constraint or an admin manually purges them.
|
||||
*
|
||||
* **Catalogue defense.** Rows with an unknown `kind` (off-catalogue
|
||||
* — should be impossible given the drift gate, but possible if a
|
||||
* future migration writes raw SQL or a future operator runs an
|
||||
* unguarded INSERT) are skipped + logged. Defensive read; the write
|
||||
* path is the canonical place to enforce catalogue membership.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaScopeResolver extends ScopeResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
|
||||
const now = new Date();
|
||||
const rows = await this.prisma.userScope.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
select: { kind: true, value: true },
|
||||
});
|
||||
|
||||
const scopes: Scope[] = [];
|
||||
for (const row of rows) {
|
||||
const scope = toScope(row.kind, row.value);
|
||||
if (scope === null) {
|
||||
this.logger.warn(
|
||||
{
|
||||
event: 'scope_resolver.unknown_kind',
|
||||
userId,
|
||||
kind: row.kind,
|
||||
},
|
||||
'PrismaScopeResolver',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
scopes.push(scope);
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `(kind, value)` DB row to a typed `Scope`. Returns null for
|
||||
* off-catalogue kinds so the resolver can log + skip them without a
|
||||
* throw. Exported for the spec — call sites should always go through
|
||||
* the resolver.
|
||||
*/
|
||||
export function toScope(kind: string, value: string): Scope | null {
|
||||
if (!isScopeKind(kind)) return null;
|
||||
switch (kind) {
|
||||
case 'self':
|
||||
case 'siege':
|
||||
case 'unrestricted':
|
||||
return { kind };
|
||||
case 'etablissement':
|
||||
case 'delegation':
|
||||
case 'region':
|
||||
return { kind, value };
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ const USER = {
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['admin'],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
describe('RequireMfaGuard', () => {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
|
||||
import type { Privilege } from 'shared-auth';
|
||||
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||
|
||||
/**
|
||||
* Reflector metadata key carrying the privilege list a route
|
||||
* requires. Re-exported by the guard module so the two stay in
|
||||
* lockstep.
|
||||
*/
|
||||
export const REQUIRE_PRIVILEGE_METADATA = 'auth:require-privilege';
|
||||
|
||||
/**
|
||||
* `@RequirePrivilege('Portal.X', ...)` — gates a route on
|
||||
* `principal.privileges` containing at least one of the listed
|
||||
* `Portal.*` values per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
*
|
||||
* Multiple slugs compose as **OR** within the decorator. Stacking
|
||||
* `@RequirePrivilege` with another guard (`@RequireRole`,
|
||||
* `@RequireMfa`) **AND**-combines at the Nest level — each guard
|
||||
* runs separately and a single denial stops the request.
|
||||
*
|
||||
* Calling with an empty privilege list is rejected at the type
|
||||
* level by the `[Privilege, ...Privilege[]]` tuple — a route can
|
||||
* not opt out of authorization by passing zero values.
|
||||
*/
|
||||
export function RequirePrivilege(
|
||||
...privileges: [Privilege, ...Privilege[]]
|
||||
): ClassDecorator & MethodDecorator {
|
||||
return applyDecorators(
|
||||
SetMetadata(REQUIRE_PRIVILEGE_METADATA, privileges),
|
||||
UseGuards(RequirePrivilegeGuard),
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import type { Principal, Privilege } from 'shared-auth';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||
|
||||
function principalFor(privileges: readonly Privilege[]): Principal {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
personId: 'user-1',
|
||||
entraOid: 'oid-1',
|
||||
tenantId: 'tenant-1',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
privileges,
|
||||
roles: [],
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: ['pwd', 'mfa'],
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(opts: {
|
||||
metadata: readonly Privilege[] | undefined;
|
||||
session?: { principal?: Principal };
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): { ctx: ExecutionContext; req: Request } {
|
||||
const req = {
|
||||
session: opts.session,
|
||||
method: opts.method ?? 'GET',
|
||||
originalUrl: opts.url ?? '/api/audit',
|
||||
} as unknown as Request;
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||
getHandler: () => 'handler',
|
||||
getClass: () => 'AuditController',
|
||||
} as unknown as ExecutionContext;
|
||||
return { ctx, req };
|
||||
}
|
||||
|
||||
function makeGuard(opts: { metadata: readonly Privilege[] | undefined; audit?: jest.Mock }): {
|
||||
guard: RequirePrivilegeGuard;
|
||||
audit: jest.Mock;
|
||||
} {
|
||||
const audit = opts.audit ?? jest.fn().mockResolvedValue(undefined);
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
|
||||
} as unknown as Reflector;
|
||||
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
|
||||
}
|
||||
|
||||
describe('RequirePrivilegeGuard', () => {
|
||||
it('passes when no metadata is set (decorator absent — guard wired but route unprotected)', async () => {
|
||||
const { guard } = makeGuard({ metadata: undefined });
|
||||
const { ctx } = makeContext({ metadata: undefined });
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('throws 401 when no session is present', async () => {
|
||||
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||
const { ctx } = makeContext({ metadata: ['Portal.Admin'], session: undefined });
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the principal carries the required privilege', async () => {
|
||||
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||
const { ctx } = makeContext({
|
||||
metadata: ['Portal.Admin'],
|
||||
session: { principal: principalFor(['Portal.Admin']) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('OR-combines multiple required privileges (matches any one)', async () => {
|
||||
const { guard } = makeGuard({
|
||||
metadata: ['Portal.Auditor', 'Portal.Admin'],
|
||||
});
|
||||
const { ctx } = makeContext({
|
||||
metadata: ['Portal.Auditor', 'Portal.Admin'],
|
||||
session: { principal: principalFor(['Portal.Auditor']) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('throws 403 + audits when the principal lacks every required privilege', async () => {
|
||||
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||
const { ctx } = makeContext({
|
||||
metadata: ['Portal.Admin'],
|
||||
session: { principal: principalFor(['Portal.Auditor']) },
|
||||
method: 'POST',
|
||||
url: '/api/admin/cms/pages',
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(audit).toHaveBeenCalledWith({
|
||||
actor: { oid: 'oid-1' },
|
||||
attemptedRoute: 'POST /api/admin/cms/pages',
|
||||
kind: 'privilege',
|
||||
required: ['Portal.Admin'],
|
||||
held: ['Portal.Auditor'],
|
||||
});
|
||||
});
|
||||
|
||||
it('audits the held privileges as the principal saw them, not the legacy roles claim', async () => {
|
||||
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||
const { ctx } = makeContext({
|
||||
metadata: ['Portal.Admin'],
|
||||
session: { principal: principalFor([]) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ held: [] }));
|
||||
});
|
||||
|
||||
it('returns a generic forbidden body (no privilege hint leaks)', async () => {
|
||||
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||
const { ctx } = makeContext({
|
||||
metadata: ['Portal.Admin'],
|
||||
session: { principal: principalFor([]) },
|
||||
});
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
throw new Error('guard did not throw');
|
||||
} catch (err) {
|
||||
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
|
||||
expect(body['code']).toBe('forbidden');
|
||||
expect(body['message']).toBe('Forbidden');
|
||||
expect(JSON.stringify(body)).not.toContain('Portal.Admin');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { type Privilege, principalHasAnyPrivilege } from 'shared-auth';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { readSessionPrincipal } from './principal-extractor';
|
||||
import { REQUIRE_PRIVILEGE_METADATA } from './require-privilege.decorator';
|
||||
|
||||
/**
|
||||
* `RequirePrivilegeGuard` — enforces ADR-0025 privilege gates.
|
||||
*
|
||||
* Contract (matches `AdminRoleGuard` for symmetry):
|
||||
*
|
||||
* - **No session → 401 `unauthenticated`.** Anonymous traffic is
|
||||
* noise; no audit row, the SPA kicks off the login flow.
|
||||
*
|
||||
* - **Session but missing every required privilege → 403 + audit.**
|
||||
* The user *is* authenticated; they just are not authorised.
|
||||
* Audit row: `auth.authorization_denied`, `kind=privilege`,
|
||||
* `required` = what the route asked for, `held` = what the
|
||||
* principal actually carries.
|
||||
*
|
||||
* - **Session with at least one required privilege → pass.**
|
||||
*
|
||||
* The structured error envelope per [ADR-0021](../../../../docs/decisions/0021-phase-2-security-baseline.md):
|
||||
* the body is `{ error: { code: 'forbidden', message, traceId } }`
|
||||
* with a generic message — no role / privilege hint, so an
|
||||
* attacker cannot use 403 responses to enumerate which privilege
|
||||
* a route gates.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequirePrivilegeGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditWriter,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<readonly Privilege[]>(
|
||||
REQUIRE_PRIVILEGE_METADATA,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (required === undefined || required.length === 0) {
|
||||
// No metadata = guard wired but route un-annotated. Treat as
|
||||
// pass-through rather than guess at the intent — the decorator
|
||||
// is what carries the privilege list.
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const principal = readSessionPrincipal(req);
|
||||
if (principal === null) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'Unauthenticated',
|
||||
});
|
||||
}
|
||||
|
||||
if (principalHasAnyPrivilege(principal, required)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.audit.authorizationDenied({
|
||||
actor: { oid: principal.user.entraOid },
|
||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||
kind: 'privilege',
|
||||
required,
|
||||
held: [...principal.privileges],
|
||||
});
|
||||
throw new ForbiddenException({
|
||||
code: 'forbidden',
|
||||
message: 'Forbidden',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
|
||||
import type { FunctionalRole } from 'shared-auth';
|
||||
import { RequireRoleGuard } from './require-role.guard';
|
||||
|
||||
/**
|
||||
* Reflector metadata key carrying the functional-role list a
|
||||
* route requires.
|
||||
*/
|
||||
export const REQUIRE_ROLE_METADATA = 'auth:require-role';
|
||||
|
||||
/**
|
||||
* `@RequireRole('rh', ...)` — gates a route on `principal.roles`
|
||||
* containing at least one of the listed `apf-role-*` slugs per
|
||||
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
*
|
||||
* Multiple slugs compose as **OR** within the decorator. Stacking
|
||||
* `@RequireRole` with another guard (`@RequirePrivilege`,
|
||||
* `@RequireScope`, `@RequireMfa`) **AND**-combines at the Nest
|
||||
* level.
|
||||
*
|
||||
* The empty-list call is rejected at the type level by the
|
||||
* `[FunctionalRole, ...FunctionalRole[]]` tuple — a route can
|
||||
* not opt out of authorization by passing zero values.
|
||||
*/
|
||||
export function RequireRole(
|
||||
...roles: [FunctionalRole, ...FunctionalRole[]]
|
||||
): ClassDecorator & MethodDecorator {
|
||||
return applyDecorators(SetMetadata(REQUIRE_ROLE_METADATA, roles), UseGuards(RequireRoleGuard));
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import type { FunctionalRole, Principal } from 'shared-auth';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import { RequireRoleGuard } from './require-role.guard';
|
||||
|
||||
function principalFor(roles: readonly FunctionalRole[]): Principal {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
personId: 'user-1',
|
||||
entraOid: 'oid-1',
|
||||
tenantId: 'tenant-1',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
privileges: [],
|
||||
roles,
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: ['pwd', 'mfa'],
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(opts: {
|
||||
session?: { principal?: Principal };
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): { ctx: ExecutionContext; req: Request } {
|
||||
const req = {
|
||||
session: opts.session,
|
||||
method: opts.method ?? 'GET',
|
||||
originalUrl: opts.url ?? '/api/hr/payroll',
|
||||
} as unknown as Request;
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||
getHandler: () => 'handler',
|
||||
getClass: () => 'HrController',
|
||||
} as unknown as ExecutionContext;
|
||||
return { ctx, req };
|
||||
}
|
||||
|
||||
function makeGuard(opts: { metadata: readonly FunctionalRole[] | undefined }): {
|
||||
guard: RequireRoleGuard;
|
||||
audit: jest.Mock;
|
||||
} {
|
||||
const audit = jest.fn().mockResolvedValue(undefined);
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
|
||||
} as unknown as Reflector;
|
||||
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||
return { guard: new RequireRoleGuard(reflector, writer), audit };
|
||||
}
|
||||
|
||||
describe('RequireRoleGuard', () => {
|
||||
it('passes when no metadata is set', async () => {
|
||||
const { guard } = makeGuard({ metadata: undefined });
|
||||
const { ctx } = makeContext({});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('throws 401 when no session is present', async () => {
|
||||
const { guard, audit } = makeGuard({ metadata: ['rh'] });
|
||||
const { ctx } = makeContext({ session: undefined });
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the principal carries the required role', async () => {
|
||||
const { guard } = makeGuard({ metadata: ['rh'] });
|
||||
const { ctx } = makeContext({ session: { principal: principalFor(['rh']) } });
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('OR-combines multiple required roles', async () => {
|
||||
const { guard } = makeGuard({ metadata: ['rh', 'responsable-paie', 'comptable'] });
|
||||
const { ctx } = makeContext({
|
||||
session: { principal: principalFor(['comptable']) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('throws 403 + audits when the principal lacks every required role', async () => {
|
||||
const { guard, audit } = makeGuard({ metadata: ['rh'] });
|
||||
const { ctx } = makeContext({
|
||||
session: { principal: principalFor(['collaborateur']) },
|
||||
method: 'GET',
|
||||
url: '/api/hr/payroll',
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(audit).toHaveBeenCalledWith({
|
||||
actor: { oid: 'oid-1' },
|
||||
attemptedRoute: 'GET /api/hr/payroll',
|
||||
kind: 'role',
|
||||
required: ['rh'],
|
||||
held: ['collaborateur'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a generic forbidden body (no role hint leaks)', async () => {
|
||||
const { guard } = makeGuard({ metadata: ['rh'] });
|
||||
const { ctx } = makeContext({ session: { principal: principalFor([]) } });
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
throw new Error('guard did not throw');
|
||||
} catch (err) {
|
||||
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
|
||||
expect(body['code']).toBe('forbidden');
|
||||
expect(JSON.stringify(body)).not.toContain('rh');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { type FunctionalRole, principalHasAnyRole } from 'shared-auth';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { readSessionPrincipal } from './principal-extractor';
|
||||
import { REQUIRE_ROLE_METADATA } from './require-role.decorator';
|
||||
|
||||
/**
|
||||
* `RequireRoleGuard` — enforces ADR-0025 functional-role gates.
|
||||
*
|
||||
* Same contract as `RequirePrivilegeGuard` with the role axis as
|
||||
* the source of truth: 401 if anonymous, 403 + audit if
|
||||
* authenticated but missing every required role, pass otherwise.
|
||||
* Multiple roles on the decorator are OR-combined; stacking with
|
||||
* other decorators is AND-combined at the Nest layer.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequireRoleGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditWriter,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<readonly FunctionalRole[]>(
|
||||
REQUIRE_ROLE_METADATA,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (required === undefined || required.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const principal = readSessionPrincipal(req);
|
||||
if (principal === null) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'Unauthenticated',
|
||||
});
|
||||
}
|
||||
|
||||
if (principalHasAnyRole(principal, required)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.audit.authorizationDenied({
|
||||
actor: { oid: principal.user.entraOid },
|
||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||
kind: 'role',
|
||||
required,
|
||||
held: [...principal.roles],
|
||||
});
|
||||
throw new ForbiddenException({
|
||||
code: 'forbidden',
|
||||
message: 'Forbidden',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { ScopableResource } from 'shared-auth';
|
||||
import { RequireScopeGuard } from './require-scope.guard';
|
||||
|
||||
/**
|
||||
* Reflector metadata key carrying the per-route resource
|
||||
* extractor. The metadata value is the extractor function
|
||||
* itself — `Reflect.metadata` happily stores callables and the
|
||||
* guard invokes it on every request.
|
||||
*/
|
||||
export const REQUIRE_SCOPE_METADATA = 'auth:require-scope';
|
||||
|
||||
/**
|
||||
* Extractor signature consumed by `@RequireScope`. Receives the
|
||||
* incoming Express request, returns the resource descriptor the
|
||||
* matcher will compare against the principal's scopes. May be
|
||||
* async — a typical extractor loads the resource by
|
||||
* `@Param('id')` from Prisma, denormalises its parentage chain
|
||||
* (`etablissementFiness` / `delegationCode` / `regionCode`), and
|
||||
* returns the descriptor.
|
||||
*
|
||||
* Returning `null` from the extractor means "the resource the
|
||||
* route protects could not be identified" (a 404 in disguise) —
|
||||
* the guard treats it as a denial and emits the audit row with
|
||||
* `required: []`. The route handler still runs if the guard
|
||||
* lets it through; routes that want a true 404 should surface
|
||||
* it from the handler.
|
||||
*/
|
||||
export type RequireScopeExtractor = (
|
||||
req: Request,
|
||||
) => ScopableResource | null | Promise<ScopableResource | null>;
|
||||
|
||||
/**
|
||||
* `@RequireScope(req => …)` — gates a route on
|
||||
* `principalCoversResource(principal, extractor(req))` per
|
||||
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
*
|
||||
* The extractor receives the Express request — typically pulls
|
||||
* an id from `req.params`, loads the resource, returns its
|
||||
* scope-relevant chain (FINESS / delegation / region). The guard
|
||||
* does not assume the extractor is cheap — routes that load the
|
||||
* resource a second time inside the handler should pass the
|
||||
* Prisma instance via a request-scoped CLS or DI, not via the
|
||||
* extractor.
|
||||
*/
|
||||
export function RequireScope(extractor: RequireScopeExtractor): ClassDecorator & MethodDecorator {
|
||||
return applyDecorators(
|
||||
SetMetadata(REQUIRE_SCOPE_METADATA, extractor),
|
||||
UseGuards(RequireScopeGuard),
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import type { Principal, ScopableResource, Scope } from 'shared-auth';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import type { RequireScopeExtractor } from './require-scope.decorator';
|
||||
import { RequireScopeGuard } from './require-scope.guard';
|
||||
|
||||
function principalFor(scopes: readonly Scope[]): Principal {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
personId: 'person-1',
|
||||
entraOid: 'oid-1',
|
||||
tenantId: 'tenant-1',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
privileges: [],
|
||||
roles: [],
|
||||
scopes,
|
||||
amr: ['pwd', 'mfa'],
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(opts: {
|
||||
session?: { principal?: Principal };
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): { ctx: ExecutionContext; req: Request } {
|
||||
const req = {
|
||||
session: opts.session,
|
||||
method: opts.method ?? 'GET',
|
||||
originalUrl: opts.url ?? '/api/etablissements/0330800013',
|
||||
} as unknown as Request;
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||
getHandler: () => 'handler',
|
||||
getClass: () => 'EtablissementController',
|
||||
} as unknown as ExecutionContext;
|
||||
return { ctx, req };
|
||||
}
|
||||
|
||||
function makeGuard(opts: { extractor: RequireScopeExtractor | undefined }): {
|
||||
guard: RequireScopeGuard;
|
||||
audit: jest.Mock;
|
||||
} {
|
||||
const audit = jest.fn().mockResolvedValue(undefined);
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(opts.extractor),
|
||||
} as unknown as Reflector;
|
||||
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||
return { guard: new RequireScopeGuard(reflector, writer), audit };
|
||||
}
|
||||
|
||||
describe('RequireScopeGuard', () => {
|
||||
it('passes when no extractor metadata is set', async () => {
|
||||
const { guard } = makeGuard({ extractor: undefined });
|
||||
const { ctx } = makeContext({});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('throws 401 when no session is present', async () => {
|
||||
const { guard, audit } = makeGuard({
|
||||
extractor: () => ({ etablissementFiness: '0330800013' }),
|
||||
});
|
||||
const { ctx } = makeContext({ session: undefined });
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the principal scope covers the resource', async () => {
|
||||
const { guard } = makeGuard({
|
||||
extractor: () => ({ etablissementFiness: '0330800013' }),
|
||||
});
|
||||
const { ctx } = makeContext({
|
||||
session: {
|
||||
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
|
||||
},
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('honours async extractors (Prisma lookup pattern)', async () => {
|
||||
const { guard } = makeGuard({
|
||||
extractor: async () => Promise.resolve({ delegationCode: '33' }),
|
||||
});
|
||||
const { ctx } = makeContext({
|
||||
session: { principal: principalFor([{ kind: 'delegation', value: '33' }]) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('throws 403 + audits when the resource is outside the principal scopes', async () => {
|
||||
const { guard, audit } = makeGuard({
|
||||
extractor: () => ({ etablissementFiness: '0330800021' }),
|
||||
});
|
||||
const { ctx } = makeContext({
|
||||
session: {
|
||||
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
|
||||
},
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(audit).toHaveBeenCalledWith({
|
||||
actor: { oid: 'oid-1' },
|
||||
attemptedRoute: 'GET /api/etablissements/0330800013',
|
||||
kind: 'scope',
|
||||
required: ['etablissement:0330800021'],
|
||||
held: ['etablissement:0330800013'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 403 + audits with empty required when the extractor returns null', async () => {
|
||||
const { guard, audit } = makeGuard({ extractor: () => null });
|
||||
const { ctx } = makeContext({
|
||||
session: { principal: principalFor([{ kind: 'unrestricted' }]) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'scope', required: [] }));
|
||||
});
|
||||
|
||||
it('describes the resource correctly when it carries the full parentage chain', async () => {
|
||||
const resource: ScopableResource = {
|
||||
etablissementFiness: '0330800021',
|
||||
delegationCode: '33',
|
||||
regionCode: '75',
|
||||
};
|
||||
const { guard, audit } = makeGuard({ extractor: () => resource });
|
||||
const { ctx } = makeContext({
|
||||
session: { principal: principalFor([{ kind: 'region', value: '11' }]) },
|
||||
});
|
||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
required: ['etablissement:0330800021', 'delegation:33', 'region:75'],
|
||||
held: ['region:11'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a generic forbidden body (no resource fingerprint leaks)', async () => {
|
||||
const { guard } = makeGuard({
|
||||
extractor: () => ({ etablissementFiness: '0330800021' }),
|
||||
});
|
||||
const { ctx } = makeContext({
|
||||
session: { principal: principalFor([]) },
|
||||
});
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
throw new Error('guard did not throw');
|
||||
} catch (err) {
|
||||
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
|
||||
expect(body['code']).toBe('forbidden');
|
||||
expect(JSON.stringify(body)).not.toContain('0330800021');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { principalCoversResource, type ScopableResource } from 'shared-auth';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
|
||||
import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator';
|
||||
|
||||
/**
|
||||
* `RequireScopeGuard` — enforces ADR-0025 scope gates.
|
||||
*
|
||||
* Contract
|
||||
* --------
|
||||
* - **No session → 401.** Same posture as the privilege / role
|
||||
* guards.
|
||||
* - **Extractor returned `null` → 403 + audit.** The route's
|
||||
* "what resource am I protecting?" question has no answer for
|
||||
* this request; deny is the safe direction. Audit row carries
|
||||
* `required: []` so an auditor can spot the missing-resource
|
||||
* pattern.
|
||||
* - **No matching scope → 403 + audit.** Standard denial; audit
|
||||
* captures the resource descriptor as `required` and the
|
||||
* principal's actual scope list as `held`.
|
||||
* - **Matching scope → pass.**
|
||||
*
|
||||
* Like the other two new guards, the response body is the
|
||||
* ADR-0021 structured-error envelope with a generic `forbidden`
|
||||
* code — no resource fingerprint leaks back to the caller.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequireScopeGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditWriter,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const extractor = this.reflector.getAllAndOverride<RequireScopeExtractor>(
|
||||
REQUIRE_SCOPE_METADATA,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (extractor === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const principal = readSessionPrincipal(req);
|
||||
if (principal === null) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'Unauthenticated',
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await extractor(req);
|
||||
if (resource === null) {
|
||||
await this.audit.authorizationDenied({
|
||||
actor: { oid: principal.user.entraOid },
|
||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||
kind: 'scope',
|
||||
required: [],
|
||||
held: scopesToAuditStrings(principal.scopes),
|
||||
});
|
||||
throw new ForbiddenException({
|
||||
code: 'forbidden',
|
||||
message: 'Forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
if (principalCoversResource(principal, resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.audit.authorizationDenied({
|
||||
actor: { oid: principal.user.entraOid },
|
||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||
kind: 'scope',
|
||||
required: describeResource(resource),
|
||||
held: scopesToAuditStrings(principal.scopes),
|
||||
});
|
||||
throw new ForbiddenException({
|
||||
code: 'forbidden',
|
||||
message: 'Forbidden',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise the resource descriptor into the `string[]` shape the
|
||||
* audit row's `required` field expects. Mirrors the `scope:value`
|
||||
* convention of {@link scopesToAuditStrings} so the two sides of
|
||||
* the audit row read consistently — easy `requiredVs.held` diff
|
||||
* in a log dashboard.
|
||||
*/
|
||||
function describeResource(resource: ScopableResource): string[] {
|
||||
const out: string[] = [];
|
||||
if (resource.personId !== undefined) {
|
||||
out.push(`self:${resource.personId}`);
|
||||
}
|
||||
if (resource.etablissementFiness !== undefined) {
|
||||
out.push(`etablissement:${resource.etablissementFiness}`);
|
||||
}
|
||||
if (resource.delegationCode !== undefined) {
|
||||
out.push(`delegation:${resource.delegationCode}`);
|
||||
}
|
||||
if (resource.regionCode !== undefined) {
|
||||
out.push(`region:${resource.regionCode}`);
|
||||
}
|
||||
if (resource.isSiege === true) {
|
||||
out.push('siege');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Scope } from 'shared-auth';
|
||||
|
||||
/**
|
||||
* Resolves the portal-side scopes for a user being signed in.
|
||||
*
|
||||
* Per [ADR-0025 §"Sources of truth — apf_portal-side `user_scopes`
|
||||
* table"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md),
|
||||
* scopes are *not* carried by Entra: they live in the portal-side
|
||||
* `user_scopes` Prisma table (per ADR-0026 PR 1's schema), keyed on
|
||||
* `User.id` (UUID). Each row materialises one `Scope` entry on the
|
||||
* session-resident principal.
|
||||
*
|
||||
* The seam is here in v1 so ADR-0026 PR 2 (the `PrismaScopeResolver`
|
||||
* landing the seed + the `/admin/users/:id/scopes` screen) replaces
|
||||
* only the implementation, leaving the call-site in
|
||||
* `PrincipalBuilder` untouched. The v1 stub (`StubScopeResolver`)
|
||||
* always returns `[{ kind: 'unrestricted' }]` per ADR-0025 §331.
|
||||
*
|
||||
* Note: the `input` shape switched from `{ entraOid }` to `{ userId }`
|
||||
* with ADR-0026 PR 1 (Person + User + UserScope schema landed). The
|
||||
* stub ignores its argument either way; the change is the seam for
|
||||
* PR 2's Prisma resolver, which keys queries on `User.id`.
|
||||
*/
|
||||
export abstract class ScopeResolver {
|
||||
abstract resolve(input: { userId: string }): Promise<ReadonlyArray<Scope>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 stub — returns `unrestricted` for every user. Per ADR-0025
|
||||
* §"Sources of truth — apf_portal-side `user_scopes` table" the
|
||||
* real implementation queries `user_scopes WHERE userId = ?`; that
|
||||
* lands as `PrismaScopeResolver` in ADR-0026 PR 2 alongside the
|
||||
* test-tenant seed.
|
||||
*
|
||||
* Deliberately not an in-memory hard-coded persona-keyed map: the
|
||||
* 19 test personas have *intended* scopes (see
|
||||
* `notes/test-tenant-role-assignments.md`) but those scopes have
|
||||
* no guard consuming them yet, so per-persona stub data would be
|
||||
* write-only documentation. ADR-0026 PR 2 populates the table; this
|
||||
* class is replaced by a Prisma-backed implementation in the same
|
||||
* change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StubScopeResolver extends ScopeResolver {
|
||||
override resolve(): Promise<ReadonlyArray<Scope>> {
|
||||
return Promise.resolve([{ kind: 'unrestricted' }]);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import type { Principal } from 'shared-auth';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
||||
import type { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
|
||||
import type { UserDirectoryService } from '../users/user-directory.service';
|
||||
import type { AuthenticatedUser } from './auth.service';
|
||||
import type { PrincipalBuilder } from './principal-builder';
|
||||
import { SessionEstablisher } from './session-establisher.service';
|
||||
|
||||
const USER: AuthenticatedUser = {
|
||||
@@ -16,7 +13,6 @@ const USER: AuthenticatedUser = {
|
||||
displayName: 'Jane Doe',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
|
||||
@@ -56,30 +52,9 @@ interface Fixture {
|
||||
index: { add: jest.Mock; remove: jest.Mock; list: jest.Mock };
|
||||
audit: { signIn: jest.Mock; signOut: jest.Mock };
|
||||
directory: { recordSignIn: jest.Mock };
|
||||
provisioner: { ensureUser: jest.Mock };
|
||||
logger: ReturnType<typeof makeLoggerStub>;
|
||||
principalBuilder: { build: jest.Mock };
|
||||
}
|
||||
|
||||
const PROVISIONED_IDENTITY = {
|
||||
userId: 'user-uuid-1',
|
||||
personId: 'person-uuid-1',
|
||||
};
|
||||
|
||||
const STUB_PRINCIPAL: Principal = {
|
||||
user: {
|
||||
id: PROVISIONED_IDENTITY.userId,
|
||||
personId: PROVISIONED_IDENTITY.personId,
|
||||
entraOid: USER.oid,
|
||||
tenantId: USER.tid,
|
||||
displayName: USER.displayName,
|
||||
},
|
||||
privileges: [],
|
||||
roles: [],
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: USER.amr,
|
||||
};
|
||||
|
||||
function makeFixture(): Fixture {
|
||||
const index = {
|
||||
add: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -93,22 +68,14 @@ function makeFixture(): Fixture {
|
||||
const directory = {
|
||||
recordSignIn: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const provisioner = {
|
||||
ensureUser: jest.fn().mockResolvedValue(PROVISIONED_IDENTITY),
|
||||
};
|
||||
const logger = makeLoggerStub();
|
||||
const principalBuilder = {
|
||||
build: jest.fn().mockResolvedValue(STUB_PRINCIPAL),
|
||||
};
|
||||
const est = new SessionEstablisher(
|
||||
logger as unknown as Logger,
|
||||
index as unknown as UserSessionIndexService,
|
||||
audit as unknown as AuditWriter,
|
||||
directory as unknown as UserDirectoryService,
|
||||
provisioner as unknown as PersonAndUserProvisioner,
|
||||
principalBuilder as unknown as PrincipalBuilder,
|
||||
);
|
||||
return { est, index, audit, directory, provisioner, logger, principalBuilder };
|
||||
return { est, index, audit, directory, logger };
|
||||
}
|
||||
|
||||
describe('SessionEstablisher.establish', () => {
|
||||
@@ -130,56 +97,6 @@ describe('SessionEstablisher.establish', () => {
|
||||
expect((sess['csrfToken'] as string).length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('provisions Person + User before building the Principal (ADR-0026 §Lifecycle)', async () => {
|
||||
const { est, provisioner } = makeFixture();
|
||||
const req = makeReqStub();
|
||||
const res = makeResStub();
|
||||
await est.establish({ user: USER, req, res, surface: 'user' });
|
||||
expect(provisioner.ensureUser).toHaveBeenCalledWith({
|
||||
oid: USER.oid,
|
||||
tenantId: USER.tid,
|
||||
displayName: USER.displayName,
|
||||
// Entra preferred_username (= AuthenticatedUser.username) maps
|
||||
// to Person.email per ADR-0026 §"Lifecycle".
|
||||
email: USER.username,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the authorization principal with the provisioned UUIDs and stamps mfaVerifiedAt (ADR-0025 + ADR-0026)', async () => {
|
||||
const { est, provisioner, principalBuilder } = makeFixture();
|
||||
const req = makeReqStub();
|
||||
const res = makeResStub();
|
||||
await est.establish({ user: USER, req, res, surface: 'user' });
|
||||
expect(principalBuilder.build).toHaveBeenCalledWith(USER, PROVISIONED_IDENTITY);
|
||||
// Order: provisioner MUST run before principalBuilder so the
|
||||
// build call has real UUIDs to populate Principal.user.{id,personId}.
|
||||
const provisionerOrder = provisioner.ensureUser.mock.invocationCallOrder[0] ?? Infinity;
|
||||
const buildOrder = principalBuilder.build.mock.invocationCallOrder[0] ?? 0;
|
||||
expect(provisionerOrder).toBeLessThan(buildOrder);
|
||||
const sess = (req as unknown as { session: Record<string, unknown> }).session;
|
||||
const principal = sess['principal'] as Principal;
|
||||
expect(principal).toBeDefined();
|
||||
expect(principal.user.entraOid).toBe(USER.oid);
|
||||
expect(principal.user.id).toBe(PROVISIONED_IDENTITY.userId);
|
||||
expect(principal.user.personId).toBe(PROVISIONED_IDENTITY.personId);
|
||||
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
|
||||
expect(principal.mfaVerifiedAt).toBe(sess['createdAt']);
|
||||
});
|
||||
|
||||
it('propagates provisioner failures (blocking per ADR-0026)', async () => {
|
||||
const { est, provisioner, audit, directory, principalBuilder } = makeFixture();
|
||||
provisioner.ensureUser.mockRejectedValueOnce(new Error('postgres unreachable'));
|
||||
const req = makeReqStub();
|
||||
const res = makeResStub();
|
||||
await expect(est.establish({ user: USER, req, res, surface: 'user' })).rejects.toThrow(
|
||||
'postgres unreachable',
|
||||
);
|
||||
// Nothing downstream of the provisioner runs when it fails.
|
||||
expect(principalBuilder.build).not.toHaveBeenCalled();
|
||||
expect(audit.signIn).not.toHaveBeenCalled();
|
||||
expect(directory.recordSignIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves the session before returning (no race with the controller redirect)', async () => {
|
||||
const { est } = makeFixture();
|
||||
const req = makeReqStub();
|
||||
|
||||
@@ -6,10 +6,8 @@ import { AuditWriter } from '../audit/audit.service';
|
||||
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
|
||||
import { readSessionTimeouts } from '../session/session-cookie';
|
||||
import { UserSessionIndexService } from '../session/user-session-index.service';
|
||||
import { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
|
||||
import { UserDirectoryService } from '../users/user-directory.service';
|
||||
import type { AuthenticatedUser } from './auth.service';
|
||||
import { PrincipalBuilder } from './principal-builder';
|
||||
|
||||
export type AuthSurface = 'user' | 'admin';
|
||||
|
||||
@@ -49,8 +47,6 @@ export class SessionEstablisher {
|
||||
private readonly userSessionIndex: UserSessionIndexService,
|
||||
private readonly audit: AuditWriter,
|
||||
private readonly userDirectory: UserDirectoryService,
|
||||
private readonly personUserProvisioner: PersonAndUserProvisioner,
|
||||
private readonly principalBuilder: PrincipalBuilder,
|
||||
) {}
|
||||
|
||||
async establish(opts: {
|
||||
@@ -83,28 +79,6 @@ export class SessionEstablisher {
|
||||
// does not re-validate factors. Refreshed by future step-up
|
||||
// re-auth flows.
|
||||
req.session.mfaVerifiedAt = now;
|
||||
// Provision Person + User (blocking per ADR-0026 §"Lifecycle"):
|
||||
// the BFF cannot build a Principal without User.id / Person.id,
|
||||
// so a Postgres outage here fails the sign-in. Idempotent —
|
||||
// returns the existing pair's UUIDs on subsequent sign-ins.
|
||||
// Note: the Entra `preferred_username` claim maps to Person.email
|
||||
// (it's typically the user's UPN / email); `AuthenticatedUser.username`
|
||||
// is the BFF's name for the same field.
|
||||
const identity = await this.personUserProvisioner.ensureUser({
|
||||
oid: user.oid,
|
||||
tenantId: user.tid,
|
||||
displayName: user.displayName,
|
||||
email: user.username,
|
||||
});
|
||||
|
||||
// Authorization principal per ADR-0025: composes the three
|
||||
// axes (privileges / functional roles / scopes) once at
|
||||
// sign-in so guards read a single coherent shape instead of
|
||||
// re-parsing claims on every request. Built before the
|
||||
// session is persisted so a Redis hiccup either persists
|
||||
// everything or nothing.
|
||||
const principal = await this.principalBuilder.build(user, identity);
|
||||
req.session.principal = { ...principal, mfaVerifiedAt: now };
|
||||
|
||||
await saveSession(req);
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from '@jest/globals';
|
||||
import { assertAiServiceConfig } from './check-ai-service-config';
|
||||
|
||||
/**
|
||||
* Pre-flight validation contract for `AI_SERVICE_*` env vars.
|
||||
* Mirrors the test posture of `check-log-user-id-salt.spec.ts` —
|
||||
* each rejection path produces an actionable error message.
|
||||
*/
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function setEnv(values: Record<string, string | undefined>): void {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['AI_SERVICE_GRPC_ENDPOINT'];
|
||||
delete process.env['AI_SERVICE_CLIENT_ID'];
|
||||
delete process.env['AI_SERVICE_GRPC_TLS'];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
describe('assertAiServiceConfig', () => {
|
||||
it('returns the parsed config when every required var is present', () => {
|
||||
setEnv({
|
||||
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
|
||||
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
|
||||
AI_SERVICE_GRPC_TLS: 'false',
|
||||
});
|
||||
|
||||
expect(assertAiServiceConfig()).toEqual({
|
||||
endpoint: 'apf-ai-service:8080',
|
||||
clientId: 'apf-portal-dev',
|
||||
useTls: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults useTls to true when AI_SERVICE_GRPC_TLS is unset', () => {
|
||||
setEnv({
|
||||
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai.internal:443',
|
||||
AI_SERVICE_CLIENT_ID: 'apf-portal-prod',
|
||||
});
|
||||
|
||||
expect(assertAiServiceConfig().useTls).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when AI_SERVICE_GRPC_ENDPOINT is missing', () => {
|
||||
setEnv({ AI_SERVICE_CLIENT_ID: 'apf-portal-dev' });
|
||||
expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_GRPC_ENDPOINT is not set/);
|
||||
});
|
||||
|
||||
it('throws when AI_SERVICE_GRPC_ENDPOINT does not match host:port', () => {
|
||||
setEnv({
|
||||
AI_SERVICE_GRPC_ENDPOINT: 'no-port-here',
|
||||
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
|
||||
});
|
||||
expect(() => assertAiServiceConfig()).toThrow(/must match "host:port"/);
|
||||
});
|
||||
|
||||
it('throws when AI_SERVICE_CLIENT_ID is missing', () => {
|
||||
setEnv({ AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080' });
|
||||
expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_CLIENT_ID is not set/);
|
||||
});
|
||||
|
||||
it('throws when AI_SERVICE_CLIENT_ID has invalid characters', () => {
|
||||
setEnv({
|
||||
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
|
||||
AI_SERVICE_CLIENT_ID: 'Apf Portal Dev',
|
||||
});
|
||||
expect(() => assertAiServiceConfig()).toThrow(/lowercase kebab-case slug/);
|
||||
});
|
||||
|
||||
it('throws when AI_SERVICE_GRPC_TLS is neither "true" nor "false"', () => {
|
||||
setEnv({
|
||||
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
|
||||
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
|
||||
AI_SERVICE_GRPC_TLS: 'yes',
|
||||
});
|
||||
expect(() => assertAiServiceConfig()).toThrow(/must be "true" or "false"/);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Validate the env vars that drive the BFF's gRPC client to
|
||||
* `apf-ai-service`, per ADR-0024.
|
||||
*
|
||||
* Three knobs, all required at module-init time of `AiClientModule`:
|
||||
*
|
||||
* - `AI_SERVICE_GRPC_ENDPOINT` — `host:port` reachable from the
|
||||
* BFF. `apf-ai-service:8080` in the dev Compose network;
|
||||
* a service DNS name + 443 in prod behind an edge proxy that
|
||||
* terminates the public TLS and forwards h2 internally.
|
||||
* - `AI_SERVICE_CLIENT_ID` — deployment slug propagated as the
|
||||
* `x-client-id` gRPC metadata on every call (per
|
||||
* `apf-ai-service/docs/contract.md`). Naming convention:
|
||||
* `apf-portal-<env>` (`apf-portal-dev`, `apf-portal-preprod`,
|
||||
* `apf-portal-prod`).
|
||||
* - `AI_SERVICE_GRPC_TLS` — `'true'` to dial with TLS credentials
|
||||
* against a CA-signed cert (default for non-dev), `'false'` for
|
||||
* h2c against the Compose network in dev. Any other value is
|
||||
* rejected to keep the cleartext-vs-TLS toggle explicit.
|
||||
*
|
||||
* Following the same family as `assertLogUserIdSalt()` and the
|
||||
* other config validators under `apps/portal-bff/src/config/`,
|
||||
* per ADR-0018 §"BFF env-var loading" — small, per-key, run at
|
||||
* bootstrap, throw with an actionable message on misconfiguration.
|
||||
*/
|
||||
|
||||
export interface AiServiceConfig {
|
||||
readonly endpoint: string;
|
||||
readonly clientId: string;
|
||||
readonly useTls: boolean;
|
||||
}
|
||||
|
||||
const ENDPOINT_PATTERN = /^[a-z0-9.-]+:[0-9]{2,5}$/i;
|
||||
const CLIENT_ID_PATTERN = /^[a-z0-9-]+$/;
|
||||
|
||||
export function assertAiServiceConfig(): AiServiceConfig {
|
||||
const endpoint = process.env['AI_SERVICE_GRPC_ENDPOINT'];
|
||||
if (!endpoint || endpoint === '') {
|
||||
throw new Error(
|
||||
`AI_SERVICE_GRPC_ENDPOINT is not set. Expected "host:port" reachable from the BFF, ` +
|
||||
`e.g. "apf-ai-service:8080" in dev Compose or "apf-ai.internal:443" in prod.`,
|
||||
);
|
||||
}
|
||||
if (!ENDPOINT_PATTERN.test(endpoint)) {
|
||||
throw new Error(
|
||||
`AI_SERVICE_GRPC_ENDPOINT must match "host:port" (got: ${JSON.stringify(endpoint)}). ` +
|
||||
`Hostname uses a-z 0-9 . - characters; port is 2-5 digits.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clientId = process.env['AI_SERVICE_CLIENT_ID'];
|
||||
if (!clientId || clientId === '') {
|
||||
throw new Error(
|
||||
`AI_SERVICE_CLIENT_ID is not set. Use a deployment slug like ` +
|
||||
`"apf-portal-dev" / "apf-portal-preprod" / "apf-portal-prod"; ` +
|
||||
`propagated as the x-client-id gRPC metadata on every call.`,
|
||||
);
|
||||
}
|
||||
if (!CLIENT_ID_PATTERN.test(clientId)) {
|
||||
throw new Error(
|
||||
`AI_SERVICE_CLIENT_ID must be a lowercase kebab-case slug ` +
|
||||
`(got: ${JSON.stringify(clientId)}). Pattern: ^[a-z0-9-]+$.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tlsRaw = process.env['AI_SERVICE_GRPC_TLS'] ?? 'true';
|
||||
if (tlsRaw !== 'true' && tlsRaw !== 'false') {
|
||||
throw new Error(
|
||||
`AI_SERVICE_GRPC_TLS must be "true" or "false" (got: ${JSON.stringify(tlsRaw)}). ` +
|
||||
`Default in prod is "true"; the dev Compose stack sets it to "false" for h2c.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
clientId,
|
||||
useTls: tlsRaw === 'true',
|
||||
};
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EntraGroupMapError } from 'shared-auth';
|
||||
import { loadEntraGroupToRoleResolver } from './load-entra-group-map';
|
||||
|
||||
function tmpFile(name: string, contents: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'entra-map-'));
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, contents, 'utf8');
|
||||
return path;
|
||||
}
|
||||
|
||||
function noopWarn(): { onWarn: jest.Mock } {
|
||||
return { onWarn: jest.fn() };
|
||||
}
|
||||
|
||||
describe('loadEntraGroupToRoleResolver', () => {
|
||||
it('returns an empty resolver and warns when ENTRA_GROUP_MAP_PATH is unset', () => {
|
||||
const { onWarn } = noopWarn();
|
||||
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
|
||||
env: {},
|
||||
onWarn,
|
||||
});
|
||||
expect(resolver.size).toBe(0);
|
||||
expect(sourcePath).toBeNull();
|
||||
expect(onWarn).toHaveBeenCalledWith('auth.entra_group_map_path_unset', expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns an empty resolver and warns when the file cannot be read', () => {
|
||||
const { onWarn } = noopWarn();
|
||||
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
|
||||
env: { ENTRA_GROUP_MAP_PATH: '/tmp/definitely-not-a-real-path-xyz.json' },
|
||||
onWarn,
|
||||
});
|
||||
expect(resolver.size).toBe(0);
|
||||
expect(sourcePath).toBe('/tmp/definitely-not-a-real-path-xyz.json');
|
||||
expect(onWarn).toHaveBeenCalledWith(
|
||||
'auth.entra_group_map_file_unreadable',
|
||||
expect.objectContaining({ path: '/tmp/definitely-not-a-real-path-xyz.json' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on malformed JSON', () => {
|
||||
const path = tmpFile('bad.json', '{ not json');
|
||||
expect(() =>
|
||||
loadEntraGroupToRoleResolver({
|
||||
env: { ENTRA_GROUP_MAP_PATH: path },
|
||||
onWarn: jest.fn(),
|
||||
}),
|
||||
).toThrow(EntraGroupMapError);
|
||||
});
|
||||
|
||||
it('throws when the JSON is not an object', () => {
|
||||
const path = tmpFile('arr.json', '["collaborateur"]');
|
||||
expect(() =>
|
||||
loadEntraGroupToRoleResolver({
|
||||
env: { ENTRA_GROUP_MAP_PATH: path },
|
||||
onWarn: jest.fn(),
|
||||
}),
|
||||
).toThrow(EntraGroupMapError);
|
||||
});
|
||||
|
||||
it('throws when a value is not a string', () => {
|
||||
const path = tmpFile(
|
||||
'bad-val.json',
|
||||
JSON.stringify({ '11111111-1111-1111-1111-111111111111': 42 }),
|
||||
);
|
||||
expect(() =>
|
||||
loadEntraGroupToRoleResolver({
|
||||
env: { ENTRA_GROUP_MAP_PATH: path },
|
||||
onWarn: jest.fn(),
|
||||
}),
|
||||
).toThrow(EntraGroupMapError);
|
||||
});
|
||||
|
||||
it('returns a populated resolver on a valid map', () => {
|
||||
const path = tmpFile(
|
||||
'good.json',
|
||||
JSON.stringify({
|
||||
'11111111-1111-1111-1111-111111111111': 'collaborateur',
|
||||
'22222222-2222-2222-2222-222222222222': 'rh',
|
||||
}),
|
||||
);
|
||||
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
|
||||
env: { ENTRA_GROUP_MAP_PATH: path },
|
||||
onWarn: jest.fn(),
|
||||
});
|
||||
expect(resolver.size).toBe(2);
|
||||
expect(sourcePath).toBe(path);
|
||||
expect(resolver.coveredRoles()).toEqual(['collaborateur', 'rh']);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { EntraGroupMapError, EntraGroupToRoleResolver, parseEntraGroupMap } from 'shared-auth';
|
||||
|
||||
/**
|
||||
* Outcome of `loadEntraGroupToRoleResolver` — paired with the
|
||||
* source path (when set) so the boot log can name the file that
|
||||
* was loaded, even when the resolver itself ends up empty.
|
||||
*/
|
||||
export interface EntraGroupResolverLoadResult {
|
||||
readonly resolver: EntraGroupToRoleResolver;
|
||||
/** `null` when no path was configured (env var unset). */
|
||||
readonly sourcePath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the Entra group-GUID → role-slug map at BFF boot per
|
||||
* [ADR-0025 §"Sources of truth — Entra-side configuration"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
*
|
||||
* Sourcing precedence:
|
||||
* 1. `ENTRA_GROUP_MAP_PATH` env var → JSON file path (absolute,
|
||||
* or relative to `cwd`).
|
||||
* 2. Unset → empty resolver, WARN logged at boot. Sign-in still
|
||||
* works but every user gets an empty `roles[]` until the
|
||||
* operator wires up the file. Acceptable for fresh dev
|
||||
* environments; production should always set the var.
|
||||
*
|
||||
* Failure modes:
|
||||
* - File configured but unreadable / missing → empty resolver,
|
||||
* WARN. Same fail-soft posture: an operator pointing at the
|
||||
* wrong path should not block every sign-in.
|
||||
* - File present but malformed (bad JSON, wrong shape, unknown
|
||||
* slug, duplicate GUID) → throws. A misconfigured map is a
|
||||
* hard-fail because the alternative is silently mis-resolving
|
||||
* a role.
|
||||
*
|
||||
* The function never reads `process.env` outside the documented
|
||||
* key and never mutates it.
|
||||
*/
|
||||
export function loadEntraGroupToRoleResolver(opts: {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onWarn: (event: string, payload: Record<string, unknown>) => void;
|
||||
}): EntraGroupResolverLoadResult {
|
||||
const env = opts.env ?? process.env;
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const rawPath = env['ENTRA_GROUP_MAP_PATH'];
|
||||
|
||||
if (typeof rawPath !== 'string' || rawPath === '') {
|
||||
opts.onWarn('auth.entra_group_map_path_unset', {
|
||||
hint: 'set ENTRA_GROUP_MAP_PATH to infra/<env>-tenant.entra.json — sign-in will succeed but resolve zero functional roles until configured',
|
||||
});
|
||||
return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: null };
|
||||
}
|
||||
|
||||
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(absolutePath, 'utf8');
|
||||
} catch (err) {
|
||||
opts.onWarn('auth.entra_group_map_file_unreadable', {
|
||||
path: absolutePath,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: absolutePath };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new EntraGroupMapError(
|
||||
`Entra group map at ${absolutePath} is not valid JSON: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new EntraGroupMapError(
|
||||
`Entra group map at ${absolutePath} must be a JSON object keyed on group GUID.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stringified: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new EntraGroupMapError(
|
||||
`Entra group map entry "${key}" in ${absolutePath} must be a string (the role slug); got ${typeof value}.`,
|
||||
);
|
||||
}
|
||||
stringified[key] = value;
|
||||
}
|
||||
|
||||
const map = parseEntraGroupMap(stringified);
|
||||
return { resolver: new EntraGroupToRoleResolver(map), sourcePath: absolutePath };
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
ChannelCredentials,
|
||||
Server,
|
||||
ServerCredentials,
|
||||
status as GrpcStatus,
|
||||
type sendUnaryData,
|
||||
type ServerUnaryCall,
|
||||
type ServerWritableStream,
|
||||
} from '@grpc/grpc-js';
|
||||
import { HashUserIdService } from '../../audit/hash-user-id.service';
|
||||
import { ChatClient } from '../ai-client/chat.client';
|
||||
import { GrpcMetadataBuilder } from '../ai-client/grpc-metadata.builder';
|
||||
import { ModelsClient } from '../ai-client/models.client';
|
||||
import { PrincipalMapper } from '../ai-client/principal.mapper';
|
||||
import { RagClient } from '../ai-client/rag.client';
|
||||
import {
|
||||
ChatServiceClient,
|
||||
ChatServiceService,
|
||||
type ChatEvent,
|
||||
type ChatRequest,
|
||||
} from '../gen/apf-ai/chat';
|
||||
import {
|
||||
ModelsServiceClient,
|
||||
ModelsServiceService,
|
||||
type ListModelsRequest,
|
||||
type ListModelsResponse,
|
||||
} from '../gen/apf-ai/models';
|
||||
import {
|
||||
RagServiceClient,
|
||||
RagServiceService,
|
||||
type RagSearchRequest,
|
||||
type RagSearchResponse,
|
||||
} from '../gen/apf-ai/rag';
|
||||
import { Struct } from '../gen/apf-ai/google/protobuf/struct';
|
||||
import { AiBridgeController } from './ai-bridge.controller';
|
||||
import type { AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
import type { AuthenticatedUser } from '../../auth/auth.service';
|
||||
|
||||
/**
|
||||
* Exercises `AiBridgeController` end-to-end through the gRPC wire
|
||||
* against an in-process fake of every service the controller calls.
|
||||
*
|
||||
* The controller is a thin translation layer (DTO → proto, gRPC →
|
||||
* SSE, session → Principal). The spec confirms each direction of
|
||||
* the translation lands the right bytes.
|
||||
*/
|
||||
|
||||
const STRONG_SALT = randomBytes(32).toString('base64url');
|
||||
const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT'];
|
||||
|
||||
beforeAll(() => {
|
||||
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_SALT === undefined) {
|
||||
delete process.env['LOG_USER_ID_SALT'];
|
||||
} else {
|
||||
process.env['LOG_USER_ID_SALT'] = ORIGINAL_SALT;
|
||||
}
|
||||
});
|
||||
|
||||
const USER: AuthenticatedUser = {
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
username: 'alice@example.org',
|
||||
displayName: 'Alice',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['admin'],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
// ---- Fake gRPC server (Chat + Rag + Models) -------------------
|
||||
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
|
||||
let ragHandler: (
|
||||
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
|
||||
callback: sendUnaryData<RagSearchResponse>,
|
||||
) => void;
|
||||
let modelsHandler: (
|
||||
call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
|
||||
callback: sendUnaryData<ListModelsResponse>,
|
||||
) => void;
|
||||
let observedChatRequest: ChatRequest | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new Server();
|
||||
server.addService(ChatServiceService, {
|
||||
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
|
||||
observedChatRequest = call.request;
|
||||
chatHandler(call);
|
||||
},
|
||||
});
|
||||
server.addService(RagServiceService, {
|
||||
search: (
|
||||
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
|
||||
callback: sendUnaryData<RagSearchResponse>,
|
||||
) => ragHandler(call, callback),
|
||||
});
|
||||
server.addService(ModelsServiceService, {
|
||||
listModels: (
|
||||
call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
|
||||
callback: sendUnaryData<ListModelsResponse>,
|
||||
) => modelsHandler(call, callback),
|
||||
});
|
||||
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(bound);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.tryShutdown((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Controller wiring --------------------------------------
|
||||
|
||||
function buildController(): AiBridgeController {
|
||||
const config: AiServiceConfig = {
|
||||
endpoint: `127.0.0.1:${port}`,
|
||||
clientId: 'apf-portal-test',
|
||||
useTls: false,
|
||||
};
|
||||
const credentials = ChannelCredentials.createInsecure();
|
||||
const metadata = new GrpcMetadataBuilder(config);
|
||||
const chatClient = new ChatClient(new ChatServiceClient(config.endpoint, credentials), metadata);
|
||||
const ragClient = new RagClient(new RagServiceClient(config.endpoint, credentials), metadata);
|
||||
const modelsClient = new ModelsClient(
|
||||
new ModelsServiceClient(config.endpoint, credentials),
|
||||
metadata,
|
||||
);
|
||||
const principalMapper = new PrincipalMapper(new HashUserIdService());
|
||||
return new AiBridgeController(chatClient, ragClient, modelsClient, principalMapper);
|
||||
}
|
||||
|
||||
// ---- Mock Express Request / Response -----------------------
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number | undefined;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
ended: boolean;
|
||||
writableEnded: boolean;
|
||||
}
|
||||
|
||||
function mockReq(user: AuthenticatedUser | undefined): {
|
||||
req: import('express').Request;
|
||||
close: () => void;
|
||||
} {
|
||||
const emitter = new EventEmitter();
|
||||
const req = Object.assign(emitter, {
|
||||
session: { user },
|
||||
}) as unknown as import('express').Request;
|
||||
return {
|
||||
req,
|
||||
close: () => emitter.emit('close'),
|
||||
};
|
||||
}
|
||||
|
||||
function mockRes(): { res: import('express').Response; captured: CapturedResponse } {
|
||||
const captured: CapturedResponse = {
|
||||
statusCode: undefined,
|
||||
headers: {},
|
||||
body: '',
|
||||
ended: false,
|
||||
writableEnded: false,
|
||||
};
|
||||
const res = {
|
||||
set statusCode(value: number) {
|
||||
captured.statusCode = value;
|
||||
},
|
||||
get writableEnded() {
|
||||
return captured.writableEnded;
|
||||
},
|
||||
setHeader(name: string, value: string): void {
|
||||
captured.headers[name] = value;
|
||||
},
|
||||
flushHeaders(): void {
|
||||
// no-op for the mock
|
||||
},
|
||||
write(chunk: string | Buffer): boolean {
|
||||
captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
|
||||
return true;
|
||||
},
|
||||
end(chunk?: string | Buffer): void {
|
||||
if (chunk !== undefined) {
|
||||
captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
|
||||
}
|
||||
captured.ended = true;
|
||||
captured.writableEnded = true;
|
||||
},
|
||||
};
|
||||
return { res: res as unknown as import('express').Response, captured };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observedChatRequest = null;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
describe('AiBridgeController — POST /api/ai/chat (SSE bridge)', () => {
|
||||
it('streams ChatEvent frames as SSE and sets the right headers', async () => {
|
||||
chatHandler = (call) => {
|
||||
call.write({ token: { token: 'hi', value: 'Hi' } });
|
||||
call.write({ token: { token: ' there', value: ' there' } });
|
||||
call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
|
||||
call.end();
|
||||
};
|
||||
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(USER);
|
||||
const { res, captured } = mockRes();
|
||||
|
||||
await controller.chat(
|
||||
{
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
conversationId: 'c-1',
|
||||
},
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.headers['Content-Type']).toBe('text/event-stream; charset=utf-8');
|
||||
expect(captured.headers['Cache-Control']).toBe('no-cache, no-transform');
|
||||
expect(captured.headers['X-Accel-Buffering']).toBe('no');
|
||||
expect(captured.body).toContain('event: token\n');
|
||||
expect(captured.body).toContain('"value":"Hi"');
|
||||
expect(captured.body).toContain('event: done\n');
|
||||
expect(captured.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('places a hashed subject and pass-through roles in the proto Principal', async () => {
|
||||
chatHandler = (call) => {
|
||||
call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
|
||||
call.end();
|
||||
};
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(USER);
|
||||
const { res } = mockRes();
|
||||
|
||||
await controller.chat(
|
||||
{
|
||||
messages: [{ role: 'user', content: 'q' }],
|
||||
},
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(observedChatRequest?.principal?.subject).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(observedChatRequest?.principal?.subject).not.toBe(USER.oid);
|
||||
expect(observedChatRequest?.principal?.roles).toEqual(['admin']);
|
||||
expect(observedChatRequest?.principal?.attributes).toEqual({ tenantId: 'tenant-1' });
|
||||
expect(observedChatRequest?.toolsAvailable).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects with 401 when no session.user is present', async () => {
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(undefined);
|
||||
const { res } = mockRes();
|
||||
|
||||
await expect(
|
||||
controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('cancels the gRPC call when the request emits "close" mid-stream', async () => {
|
||||
const cancellationObserved = new Promise<void>((resolve) => {
|
||||
chatHandler = (call) => {
|
||||
call.write({ token: { token: 't', value: 't' } });
|
||||
call.on('cancelled', () => resolve());
|
||||
// never end — wait for client cancellation
|
||||
};
|
||||
});
|
||||
|
||||
const controller = buildController();
|
||||
const { req, close } = mockReq(USER);
|
||||
const { res } = mockRes();
|
||||
|
||||
const completion = controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);
|
||||
|
||||
// Give the server time to emit the first frame then cut the
|
||||
// request — this is the controller's documented browser-close
|
||||
// pathway.
|
||||
setTimeout(close, 30);
|
||||
|
||||
await completion;
|
||||
await cancellationObserved;
|
||||
});
|
||||
|
||||
it('writes a relay error frame on upstream failure', async () => {
|
||||
chatHandler = (call) => {
|
||||
call.emit('error', {
|
||||
code: GrpcStatus.UNAVAILABLE,
|
||||
details: 'upstream down',
|
||||
message: 'upstream down',
|
||||
});
|
||||
};
|
||||
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(USER);
|
||||
const { res, captured } = mockRes();
|
||||
|
||||
await controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);
|
||||
|
||||
expect(captured.body).toContain('event: error\n');
|
||||
expect(captured.body).toContain('"code":"urn:apf-ai:unavailable"');
|
||||
expect(captured.body).toContain('"retriable":true');
|
||||
expect(captured.ended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiBridgeController — GET /api/ai/rag/search', () => {
|
||||
it('returns the unary response', async () => {
|
||||
ragHandler = (_call, callback) => {
|
||||
callback(null, {
|
||||
chunks: [
|
||||
{
|
||||
id: 'k',
|
||||
documentId: 'd',
|
||||
content: 'snippet',
|
||||
source: 'src',
|
||||
score: 1,
|
||||
metadata: Struct.fromPartial({}),
|
||||
},
|
||||
],
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
};
|
||||
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(USER);
|
||||
const response = await controller.ragSearch({ query: 'hello', topK: 3 }, req);
|
||||
|
||||
expect(response.chunks).toHaveLength(1);
|
||||
expect(response.correlationId).toBe('corr-1');
|
||||
});
|
||||
|
||||
it('rejects with 401 when no session.user', async () => {
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(undefined);
|
||||
await expect(controller.ragSearch({ query: 'x' }, req)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiBridgeController — GET /api/ai/models', () => {
|
||||
it('returns the model list', async () => {
|
||||
modelsHandler = (_call, callback) => {
|
||||
callback(null, {
|
||||
active: 'openai-compatible',
|
||||
providers: [
|
||||
{
|
||||
discriminator: 'openai-compatible',
|
||||
capabilities: 'chat,embedding',
|
||||
endpoint: 'http://ollama:11434/v1',
|
||||
model: 'qwen2.5:3b',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(USER);
|
||||
const response = await controller.listModels(req);
|
||||
expect(response.active).toBe('openai-compatible');
|
||||
expect(response.providers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects with 401 when no session.user', async () => {
|
||||
const controller = buildController();
|
||||
const { req } = mockReq(undefined);
|
||||
await expect(controller.listModels(req)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { status as GrpcStatus, type ServiceError } from '@grpc/grpc-js';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ChatClient } from '../ai-client/chat.client';
|
||||
import { ModelsClient } from '../ai-client/models.client';
|
||||
import { PrincipalMapper } from '../ai-client/principal.mapper';
|
||||
import { RagClient } from '../ai-client/rag.client';
|
||||
import type { ChatEvent, ChatRequest } from '../gen/apf-ai/chat';
|
||||
import { ChatRole } from '../gen/apf-ai/common';
|
||||
import type { ListModelsResponse } from '../gen/apf-ai/models';
|
||||
import type { RagSearchResponse } from '../gen/apf-ai/rag';
|
||||
import { ChatRequestDto, type ChatMessageDto } from './dto/chat-request.dto';
|
||||
import { RagSearchQueryDto } from './dto/rag-search-query.dto';
|
||||
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
|
||||
import type { AuthenticatedUser } from '../../auth/auth.service';
|
||||
|
||||
/**
|
||||
* BFF-facing surface of the AI relay, per
|
||||
* [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md):
|
||||
*
|
||||
* - `POST /api/ai/chat` — streaming chat, bridged from
|
||||
* `ChatService.Chat` (gRPC server-
|
||||
* stream) to `text/event-stream`.
|
||||
* - `GET /api/ai/rag/search` — unary RAG retrieval.
|
||||
* - `GET /api/ai/models` — list configured providers.
|
||||
*
|
||||
* No `@RequireAdmin()` — AI features are end-user surfaces, gated
|
||||
* only by the active portal session (`req.session.user`). The
|
||||
* session + CSRF middleware mounted in `main.ts` covers the
|
||||
* authentication + double-submit token before the controller
|
||||
* runs; this class asserts presence of `session.user` for the
|
||||
* routes that need it and lets the global middleware handle the
|
||||
* rest.
|
||||
*/
|
||||
@ApiTags('ai')
|
||||
@ApiCookieAuth('portal_session')
|
||||
@Controller('ai')
|
||||
export class AiBridgeController {
|
||||
constructor(
|
||||
private readonly chatClient: ChatClient,
|
||||
private readonly ragClient: RagClient,
|
||||
private readonly modelsClient: ModelsClient,
|
||||
private readonly principalMapper: PrincipalMapper,
|
||||
) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Streaming chat. Returns text/event-stream with one frame per ChatEvent (token / citation / agent-step / tool-call / error / done).',
|
||||
})
|
||||
@Post('chat')
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const user = requireSessionUser(req);
|
||||
|
||||
const principal = this.principalMapper.fromInputs({
|
||||
oid: user.oid,
|
||||
tid: user.tid,
|
||||
roles: user.roles,
|
||||
});
|
||||
const protoRequest: ChatRequest = {
|
||||
messages: body.messages.map(toProtoMessage),
|
||||
conversationId: body.conversationId ?? '',
|
||||
model: body.model ?? '',
|
||||
provider: body.provider ?? '',
|
||||
toolsAvailable: [],
|
||||
// v1: tool registry is empty (per ADR-0024 §"Tool-dispatch
|
||||
// contract"). The AI service will never emit `tool_call` for
|
||||
// requests with `toolsAvailable: []`; the SSE writer still
|
||||
// covers the case in case a future tool lands.
|
||||
rag: { enabled: false, topK: 0 },
|
||||
principal,
|
||||
};
|
||||
|
||||
writeSseHeaders(res);
|
||||
|
||||
// Browser disconnect (tab close, fetch abort, network drop) →
|
||||
// AbortController → gRPC call.cancel() → upstream LLM stop.
|
||||
// Per ADR-0024 §"SSE bridge between BFF and SPA".
|
||||
const abort = new AbortController();
|
||||
req.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const stream = this.chatClient.chat(protoRequest, { signal: abort.signal });
|
||||
|
||||
try {
|
||||
for await (const event of stream as AsyncIterable<ChatEvent>) {
|
||||
const frame = chatEventToSseFrame(event);
|
||||
if (frame !== null) {
|
||||
res.write(frame);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// gRPC `CANCELLED` after `abort.abort()` is the expected exit
|
||||
// path on browser disconnect — do not surface an error frame
|
||||
// because the response is already closing. Anything else
|
||||
// becomes a structured error event so the SPA's renderer
|
||||
// sees the failure rather than a torn-down connection.
|
||||
const code = (err as Partial<ServiceError>).code;
|
||||
const cancelled = code === GrpcStatus.CANCELLED;
|
||||
if (!cancelled && !res.writableEnded) {
|
||||
res.write(
|
||||
relayErrorFrame(
|
||||
mapServiceErrorCode(code),
|
||||
(err as Error).message ?? 'AI relay error',
|
||||
isRetriable(code),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Unary RAG retrieval bounded by the caller principal.' })
|
||||
@Get('rag/search')
|
||||
async ragSearch(
|
||||
@Query() query: RagSearchQueryDto,
|
||||
@Req() req: Request,
|
||||
): Promise<RagSearchResponse> {
|
||||
const user = requireSessionUser(req);
|
||||
const principal = this.principalMapper.fromInputs({
|
||||
oid: user.oid,
|
||||
tid: user.tid,
|
||||
roles: user.roles,
|
||||
});
|
||||
|
||||
return this.ragClient.search({
|
||||
query: query.query,
|
||||
topK: query.topK ?? 0,
|
||||
filters: {
|
||||
source: query.source ?? '',
|
||||
documentId: query.documentId ?? '',
|
||||
},
|
||||
principal,
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'List configured AI providers and the active provider.' })
|
||||
@Get('models')
|
||||
async listModels(@Req() req: Request): Promise<ListModelsResponse> {
|
||||
requireSessionUser(req);
|
||||
return this.modelsClient.listModels({});
|
||||
}
|
||||
}
|
||||
|
||||
function requireSessionUser(req: Request): AuthenticatedUser {
|
||||
const user = req.session.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'The AI surface requires an authenticated portal session.',
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function writeSseHeaders(res: Response): void {
|
||||
res.statusCode = HttpStatus.OK;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// nginx-style buffering hint — keeps reverse-proxies from
|
||||
// accumulating chunks before forwarding, which would defeat the
|
||||
// streaming UX.
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
function toProtoMessage(message: ChatMessageDto): {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
} {
|
||||
return {
|
||||
role: ROLE_PROTO[message.role],
|
||||
content: message.content,
|
||||
toolCallId: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
|
||||
const ROLE_PROTO: Record<ChatMessageDto['role'], ChatRole> = {
|
||||
system: ChatRole.CHAT_ROLE_SYSTEM,
|
||||
user: ChatRole.CHAT_ROLE_USER,
|
||||
assistant: ChatRole.CHAT_ROLE_ASSISTANT,
|
||||
};
|
||||
|
||||
/**
|
||||
* gRPC status → SSE error code. Keeps the urn:apf-ai:* namespace
|
||||
* the AI service itself uses so a relay-side error and an upstream
|
||||
* error look the same to the SPA's renderer.
|
||||
*/
|
||||
function mapServiceErrorCode(code: number | undefined): string {
|
||||
switch (code) {
|
||||
case GrpcStatus.UNAVAILABLE:
|
||||
return 'urn:apf-ai:unavailable';
|
||||
case GrpcStatus.DEADLINE_EXCEEDED:
|
||||
return 'urn:apf-ai:timeout';
|
||||
case GrpcStatus.PERMISSION_DENIED:
|
||||
return 'urn:apf-ai:permission_denied';
|
||||
case GrpcStatus.RESOURCE_EXHAUSTED:
|
||||
return 'urn:apf-ai:rate_limited';
|
||||
case GrpcStatus.INVALID_ARGUMENT:
|
||||
return 'urn:apf-ai:invalid_argument';
|
||||
default:
|
||||
return 'urn:apf-ai:relay_error';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetriable(code: number | undefined): boolean {
|
||||
return code === GrpcStatus.UNAVAILABLE || code === GrpcStatus.DEADLINE_EXCEEDED;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiClientModule } from '../ai-client/ai-client.module';
|
||||
import { AiBridgeController } from './ai-bridge.controller';
|
||||
|
||||
/**
|
||||
* Hosts `AiBridgeController` and depends on `AiClientModule` for
|
||||
* the four wrapper clients (`ChatClient`, `RagClient`,
|
||||
* `ModelsClient`, plus `PrincipalMapper`). `AiClientModule`'s
|
||||
* `OnApplicationShutdown` lifecycle closes the gRPC stubs at
|
||||
* process termination — wiring this module into `AppModule`
|
||||
* brings both the controller AND the shutdown contract along.
|
||||
*
|
||||
* Per [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)
|
||||
* §"Out of scope for this ADR" the ingestion surface is not
|
||||
* exposed via the BFF in v1 — `IngestionClient` is in
|
||||
* `AiClientModule` for future reuse but `AiBridgeController`
|
||||
* does not surface it. The CLI under `apf-ai-service/tools/Apf.Ai.Ingest/`
|
||||
* is the v1 ingestion path.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AiClientModule],
|
||||
controllers: [AiBridgeController],
|
||||
})
|
||||
export class AiBridgeModule {}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Roles the SPA may attach to a message in the chat history.
|
||||
*
|
||||
* `tool` is intentionally absent — tool-result messages are
|
||||
* constructed by the BFF itself (caller-side tool dispatch, per
|
||||
* [ADR-0024](../../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)
|
||||
* §"Tool-dispatch contract — caller-side execution") and never
|
||||
* provided by the SPA. v1 ships with an empty tool registry, so
|
||||
* no `tool` message ever reaches this controller.
|
||||
*/
|
||||
const ALLOWED_ROLES = ['user', 'assistant', 'system'] as const;
|
||||
type AllowedRole = (typeof ALLOWED_ROLES)[number];
|
||||
|
||||
export class ChatMessageDto {
|
||||
@IsIn(ALLOWED_ROLES)
|
||||
role!: AllowedRole;
|
||||
|
||||
/**
|
||||
* Free-form message body. The cap is generous (16 KB) to support
|
||||
* code blocks and pasted excerpts; the BFF leaves further
|
||||
* pre-processing (truncation, token counting) to the AI service.
|
||||
*/
|
||||
@IsString()
|
||||
@MinLength(0)
|
||||
@MaxLength(16_384)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat body.
|
||||
*
|
||||
* Stateless from the BFF's perspective — the SPA owns the
|
||||
* conversation history. `conversationId` is an opaque correlation
|
||||
* key used by the AI service's audit log (not a database key on
|
||||
* either side); the SPA may omit it for one-off calls.
|
||||
*/
|
||||
export class ChatRequestDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(64)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages!: ChatMessageDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
conversationId?: string;
|
||||
|
||||
/**
|
||||
* Optional model + provider hints. Both default to "" on the
|
||||
* wire, which the AI service interprets as "use the configured
|
||||
* default". The SPA does not need to pass these in v1.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
model?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
provider?: string;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Query-string DTO for `GET /api/ai/rag/search`.
|
||||
*
|
||||
* Bounded — the upstream RAG service has its own server-side cap on
|
||||
* `top_k`, but the BFF rejects obviously-out-of-range values early
|
||||
* so a single SPA bug cannot induce repeated 400s round-tripped
|
||||
* through the AI service.
|
||||
*/
|
||||
export class RagSearchQueryDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
query!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
topK?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
documentId?: string;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
|
||||
|
||||
/**
|
||||
* Locks the wire shape the SPA consumes. Each frame is:
|
||||
*
|
||||
* event: <name>\n
|
||||
* data: <json>\n
|
||||
* \n
|
||||
*
|
||||
* The terminal blank line is the SSE message separator.
|
||||
*/
|
||||
|
||||
describe('chatEventToSseFrame', () => {
|
||||
it('maps token events with the JSON-encoded inner value', () => {
|
||||
const frame = chatEventToSseFrame({ token: { token: 't1', value: 'hello' } });
|
||||
expect(frame).toBe(`event: token\ndata: {"token":"t1","value":"hello"}\n\n`);
|
||||
});
|
||||
|
||||
it('maps citation events', () => {
|
||||
const frame = chatEventToSseFrame({
|
||||
citation: {
|
||||
chunkId: 'c-1',
|
||||
documentId: 'd-1',
|
||||
source: 's',
|
||||
score: 0.42,
|
||||
snippet: 'snip',
|
||||
},
|
||||
});
|
||||
expect(frame?.startsWith('event: citation\n')).toBe(true);
|
||||
expect(frame).toContain('"chunkId":"c-1"');
|
||||
});
|
||||
|
||||
it('maps agent_step to kebab-case `agent-step`', () => {
|
||||
const frame = chatEventToSseFrame({
|
||||
agentStep: { agent: 'a', step: 's', stepId: '1' },
|
||||
});
|
||||
expect(frame?.startsWith('event: agent-step\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps tool_call to kebab-case `tool-call`', () => {
|
||||
const frame = chatEventToSseFrame({
|
||||
toolCall: { callId: 'c-1', name: 'echo', args: undefined },
|
||||
});
|
||||
expect(frame?.startsWith('event: tool-call\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps error events', () => {
|
||||
const frame = chatEventToSseFrame({
|
||||
error: { code: 'urn:apf-ai:foo', message: 'bad', retriable: false },
|
||||
});
|
||||
expect(frame).toBe(
|
||||
`event: error\ndata: {"code":"urn:apf-ai:foo","message":"bad","retriable":false}\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps done as the terminal frame', () => {
|
||||
const frame = chatEventToSseFrame({
|
||||
done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } },
|
||||
});
|
||||
expect(frame?.startsWith('event: done\n')).toBe(true);
|
||||
expect(frame?.endsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when no oneof case is populated', () => {
|
||||
expect(chatEventToSseFrame({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('relayErrorFrame', () => {
|
||||
it('emits an error frame with the supplied code + message + retriable flag', () => {
|
||||
expect(relayErrorFrame('urn:apf-ai:relay_error', 'kaboom', true)).toBe(
|
||||
`event: error\ndata: {"code":"urn:apf-ai:relay_error","message":"kaboom","retriable":true}\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults `retriable` to false when omitted', () => {
|
||||
const frame = relayErrorFrame('urn:apf-ai:foo', 'msg');
|
||||
expect(frame).toContain('"retriable":false');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { ChatEvent } from '../gen/apf-ai/chat';
|
||||
|
||||
/**
|
||||
* Translate one `apf.ai.v1.ChatEvent` into a single SSE frame for
|
||||
* the SPA, per ADR-0024 §"Sub-decision 2 — SSE bridge between BFF
|
||||
* and SPA". The mapping is intentionally one-to-one with the
|
||||
* proto `oneof` cases:
|
||||
*
|
||||
* token → `event: token` / data = TokenEvent JSON
|
||||
* citation → `event: citation` / data = CitationEvent JSON
|
||||
* agent_step → `event: agent-step` / data = AgentStepEvent JSON
|
||||
* tool_call → `event: tool-call` / data = ToolCallEvent JSON
|
||||
* error → `event: error` / data = ErrorEvent JSON
|
||||
* done → `event: done` / data = DoneEvent JSON
|
||||
*
|
||||
* SSE event names use kebab-case so the SPA's `EventSource` /
|
||||
* fetch-streaming consumer can dispatch with `.addEventListener('agent-step', …)`
|
||||
* without re-mapping proto camelCase. The terminal `done` frame is
|
||||
* the contract's stream-close marker — no `[DONE]` sentinel, per
|
||||
* ADR-0024.
|
||||
*
|
||||
* Returns `null` when the event carries no populated oneof case
|
||||
* (defensive — gRPC-js will not produce this in practice, but the
|
||||
* caller can safely skip on `null` rather than emit an empty
|
||||
* frame). The data payload is JSON.stringified; consumers parse
|
||||
* with `JSON.parse(event.data)`.
|
||||
*/
|
||||
export function chatEventToSseFrame(event: ChatEvent): string | null {
|
||||
if (event.token !== undefined) {
|
||||
return formatFrame('token', event.token);
|
||||
}
|
||||
if (event.citation !== undefined) {
|
||||
return formatFrame('citation', event.citation);
|
||||
}
|
||||
if (event.agentStep !== undefined) {
|
||||
return formatFrame('agent-step', event.agentStep);
|
||||
}
|
||||
if (event.toolCall !== undefined) {
|
||||
return formatFrame('tool-call', event.toolCall);
|
||||
}
|
||||
if (event.error !== undefined) {
|
||||
return formatFrame('error', event.error);
|
||||
}
|
||||
if (event.done !== undefined) {
|
||||
return formatFrame('done', event.done);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience used by the controller's error path: synthesise a
|
||||
* `urn:apf-ai:relay_error` event frame so the SPA receives a
|
||||
* structured failure rather than a torn-down connection. Matches the
|
||||
* shape of the AI service's own `ErrorEvent` so the SPA's renderer
|
||||
* does not need a second code path for relay-level failures vs
|
||||
* upstream model errors.
|
||||
*/
|
||||
export function relayErrorFrame(code: string, message: string, retriable = false): string {
|
||||
return formatFrame('error', { code, message, retriable });
|
||||
}
|
||||
|
||||
function formatFrame(eventName: string, data: unknown): string {
|
||||
// SSE spec: every field line ends with `\n`, the frame is
|
||||
// terminated by a blank line (`\n\n`). `data:` is followed by a
|
||||
// single space convention. `JSON.stringify` produces no newlines
|
||||
// for plain objects, so a single `data:` line is correct; a
|
||||
// multi-line payload (not used by this codec) would require
|
||||
// splitting on `\n` and prefixing each part with `data:`.
|
||||
return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { AiClientModule } from './ai-client.module';
|
||||
import { ChatClient } from './chat.client';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { IngestionClient } from './ingestion.client';
|
||||
import { ModelsClient } from './models.client';
|
||||
import { PrincipalMapper } from './principal.mapper';
|
||||
import { RagClient } from './rag.client';
|
||||
import {
|
||||
AI_CHAT_GRPC_CLIENT,
|
||||
AI_CONFIG,
|
||||
AI_CREDENTIALS,
|
||||
AI_INGESTION_GRPC_CLIENT,
|
||||
AI_MODELS_GRPC_CLIENT,
|
||||
AI_RAG_GRPC_CLIENT,
|
||||
} from './tokens';
|
||||
|
||||
/**
|
||||
* Smoke-tests `AiClientModule.compile()` — every provider resolves,
|
||||
* the four exported wrapper clients are constructable, and the gRPC
|
||||
* stubs share the same credentials instance (i.e. the underlying
|
||||
* HTTP/2 channel can be multiplexed by grpc-js).
|
||||
*
|
||||
* Does NOT exercise the wire — the wire is covered by the per-client
|
||||
* fake-server specs.
|
||||
*/
|
||||
|
||||
const STRONG_SALT = randomBytes(32).toString('base64url');
|
||||
const ORIGINAL = {
|
||||
salt: process.env['LOG_USER_ID_SALT'],
|
||||
endpoint: process.env['AI_SERVICE_GRPC_ENDPOINT'],
|
||||
clientId: process.env['AI_SERVICE_CLIENT_ID'],
|
||||
tls: process.env['AI_SERVICE_GRPC_TLS'],
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
|
||||
process.env['AI_SERVICE_GRPC_ENDPOINT'] = 'apf-ai-service:8080';
|
||||
process.env['AI_SERVICE_CLIENT_ID'] = 'apf-portal-test';
|
||||
process.env['AI_SERVICE_GRPC_TLS'] = 'false';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL.salt === undefined) delete process.env['LOG_USER_ID_SALT'];
|
||||
else process.env['LOG_USER_ID_SALT'] = ORIGINAL.salt;
|
||||
if (ORIGINAL.endpoint === undefined) delete process.env['AI_SERVICE_GRPC_ENDPOINT'];
|
||||
else process.env['AI_SERVICE_GRPC_ENDPOINT'] = ORIGINAL.endpoint;
|
||||
if (ORIGINAL.clientId === undefined) delete process.env['AI_SERVICE_CLIENT_ID'];
|
||||
else process.env['AI_SERVICE_CLIENT_ID'] = ORIGINAL.clientId;
|
||||
if (ORIGINAL.tls === undefined) delete process.env['AI_SERVICE_GRPC_TLS'];
|
||||
else process.env['AI_SERVICE_GRPC_TLS'] = ORIGINAL.tls;
|
||||
});
|
||||
|
||||
describe('AiClientModule', () => {
|
||||
let moduleRef: Awaited<ReturnType<ReturnType<typeof Test.createTestingModule>['compile']>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// AiClientModule provides HashUserIdService locally — see the
|
||||
// module docstring for the rationale — so the test does not
|
||||
// need to bring in AuditModule (and AuditWriter's Prisma + CLS
|
||||
// dependencies that would imply).
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [AiClientModule],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
it('resolves all four wrapper clients', () => {
|
||||
expect(moduleRef.get(ChatClient)).toBeInstanceOf(ChatClient);
|
||||
expect(moduleRef.get(RagClient)).toBeInstanceOf(RagClient);
|
||||
expect(moduleRef.get(IngestionClient)).toBeInstanceOf(IngestionClient);
|
||||
expect(moduleRef.get(ModelsClient)).toBeInstanceOf(ModelsClient);
|
||||
});
|
||||
|
||||
it('resolves the Principal mapper and the metadata builder', () => {
|
||||
expect(moduleRef.get(PrincipalMapper)).toBeInstanceOf(PrincipalMapper);
|
||||
expect(moduleRef.get(GrpcMetadataBuilder)).toBeInstanceOf(GrpcMetadataBuilder);
|
||||
});
|
||||
|
||||
it('parses the env vars into AI_CONFIG', () => {
|
||||
const config = moduleRef.get(AI_CONFIG) as {
|
||||
endpoint: string;
|
||||
clientId: string;
|
||||
useTls: boolean;
|
||||
};
|
||||
expect(config).toEqual({
|
||||
endpoint: 'apf-ai-service:8080',
|
||||
clientId: 'apf-portal-test',
|
||||
useTls: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shares one credentials instance across every generated stub', () => {
|
||||
const credentials = moduleRef.get(AI_CREDENTIALS);
|
||||
expect(credentials).toBeDefined();
|
||||
// Each stub provider injects AI_CREDENTIALS — same reference
|
||||
// means the underlying HTTP/2 connection can be multiplexed by
|
||||
// grpc-js when they target the same address.
|
||||
const chat = moduleRef.get(AI_CHAT_GRPC_CLIENT) as unknown;
|
||||
const rag = moduleRef.get(AI_RAG_GRPC_CLIENT) as unknown;
|
||||
const ingestion = moduleRef.get(AI_INGESTION_GRPC_CLIENT) as unknown;
|
||||
const models = moduleRef.get(AI_MODELS_GRPC_CLIENT) as unknown;
|
||||
expect(chat).toBeDefined();
|
||||
expect(rag).toBeDefined();
|
||||
expect(ingestion).toBeDefined();
|
||||
expect(models).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import { Inject, Module, type OnApplicationShutdown, type Provider } from '@nestjs/common';
|
||||
import { ChannelCredentials, type Client } from '@grpc/grpc-js';
|
||||
import { HashUserIdService } from '../../audit/hash-user-id.service';
|
||||
import { assertAiServiceConfig, type AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
import { ChatServiceClient } from '../gen/apf-ai/chat';
|
||||
import { IngestionServiceClient } from '../gen/apf-ai/ingestion';
|
||||
import { ModelsServiceClient } from '../gen/apf-ai/models';
|
||||
import { RagServiceClient } from '../gen/apf-ai/rag';
|
||||
import { ChatClient } from './chat.client';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { IngestionClient } from './ingestion.client';
|
||||
import { ModelsClient } from './models.client';
|
||||
import { PrincipalMapper } from './principal.mapper';
|
||||
import { RagClient } from './rag.client';
|
||||
import {
|
||||
AI_CHAT_GRPC_CLIENT,
|
||||
AI_CONFIG,
|
||||
AI_CREDENTIALS,
|
||||
AI_INGESTION_GRPC_CLIENT,
|
||||
AI_MODELS_GRPC_CLIENT,
|
||||
AI_RAG_GRPC_CLIENT,
|
||||
} from './tokens';
|
||||
|
||||
/**
|
||||
* Wires the BFF's integration surface to `apf-ai-service`, per
|
||||
* [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
|
||||
*
|
||||
* The module is intentionally NOT imported in `AppModule` yet — the
|
||||
* SSE-bridge controller that consumes these clients ships in the
|
||||
* next chantier. Until then the module compiles, type-checks, and
|
||||
* unit-tests against an in-process fake gRPC server, but it does not
|
||||
* register any HTTP route.
|
||||
*
|
||||
* Provider layout:
|
||||
*
|
||||
* 1. `AI_CONFIG` factory reads + validates `AI_SERVICE_*` env vars.
|
||||
* 2. `AI_CREDENTIALS` factory builds the gRPC channel credentials
|
||||
* (insecure h2c in dev, system-CA-trusted SSL in prod).
|
||||
* 3. One token per generated stub — `AI_CHAT_GRPC_CLIENT` etc. —
|
||||
* backed by a factory that opens a `Client` against the
|
||||
* configured endpoint. gRPC-js multiplexes the underlying
|
||||
* HTTP/2 connection across stubs sharing the same address +
|
||||
* credentials, so the four stubs share one TCP+TLS channel.
|
||||
* 4. Hand-written wrapper services (`ChatClient`, `RagClient`,
|
||||
* `IngestionClient`, `ModelsClient`) add metadata injection
|
||||
* and promisification on top of the generated stubs.
|
||||
* 5. `PrincipalMapper` + `GrpcMetadataBuilder` collaborators.
|
||||
*
|
||||
* `PrincipalMapper` depends on `HashUserIdService` — the same hash
|
||||
* service the audit writer uses — so the `Principal.subject` field
|
||||
* matches `audit.events.actor_id_hash` exactly (per ADR-0024
|
||||
* §"Audit cross-referencing"). `HashUserIdService` is declared as a
|
||||
* local provider here (rather than relying on `AuditModule`'s
|
||||
* `@Global()` export) for two reasons:
|
||||
*
|
||||
* 1. **Test isolation.** The module compiles standalone with no
|
||||
* other portal module in scope; no need to pull `AuditWriter`
|
||||
* and its Prisma + CLS deps into a unit test that only exercises
|
||||
* the gRPC surface.
|
||||
* 2. **Self-containment of the wire boundary.** The Principal-side
|
||||
* contract is a property of `AiClientModule`, not of the audit
|
||||
* module. Co-locating the hash provider keeps the boundary
|
||||
* readable from one file.
|
||||
*
|
||||
* Functional identity with the audit-side instance is guaranteed by
|
||||
* `HashUserIdService` being purely a function of
|
||||
* `LOG_USER_ID_SALT` — same env value ⇒ same hash output, regardless
|
||||
* of how many instances exist. The cross-service join key invariant
|
||||
* from ADR-0013 still holds.
|
||||
*/
|
||||
|
||||
function buildCredentials(config: AiServiceConfig): ChannelCredentials {
|
||||
return config.useTls ? ChannelCredentials.createSsl() : ChannelCredentials.createInsecure();
|
||||
}
|
||||
|
||||
const grpcStubProviders: Provider[] = [
|
||||
{
|
||||
provide: AI_CHAT_GRPC_CLIENT,
|
||||
inject: [AI_CONFIG, AI_CREDENTIALS],
|
||||
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
|
||||
new ChatServiceClient(config.endpoint, credentials),
|
||||
},
|
||||
{
|
||||
provide: AI_RAG_GRPC_CLIENT,
|
||||
inject: [AI_CONFIG, AI_CREDENTIALS],
|
||||
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
|
||||
new RagServiceClient(config.endpoint, credentials),
|
||||
},
|
||||
{
|
||||
provide: AI_INGESTION_GRPC_CLIENT,
|
||||
inject: [AI_CONFIG, AI_CREDENTIALS],
|
||||
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
|
||||
new IngestionServiceClient(config.endpoint, credentials),
|
||||
},
|
||||
{
|
||||
provide: AI_MODELS_GRPC_CLIENT,
|
||||
inject: [AI_CONFIG, AI_CREDENTIALS],
|
||||
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
|
||||
new ModelsServiceClient(config.endpoint, credentials),
|
||||
},
|
||||
];
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AI_CONFIG,
|
||||
useFactory: () => assertAiServiceConfig(),
|
||||
},
|
||||
{
|
||||
provide: AI_CREDENTIALS,
|
||||
inject: [AI_CONFIG],
|
||||
useFactory: buildCredentials,
|
||||
},
|
||||
...grpcStubProviders,
|
||||
HashUserIdService,
|
||||
GrpcMetadataBuilder,
|
||||
PrincipalMapper,
|
||||
ChatClient,
|
||||
RagClient,
|
||||
IngestionClient,
|
||||
ModelsClient,
|
||||
],
|
||||
exports: [ChatClient, RagClient, IngestionClient, ModelsClient, PrincipalMapper],
|
||||
})
|
||||
export class AiClientModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(AI_CHAT_GRPC_CLIENT) private readonly chatStub: Client,
|
||||
@Inject(AI_RAG_GRPC_CLIENT) private readonly ragStub: Client,
|
||||
@Inject(AI_INGESTION_GRPC_CLIENT) private readonly ingestionStub: Client,
|
||||
@Inject(AI_MODELS_GRPC_CLIENT) private readonly modelsStub: Client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Close every generated gRPC stub when the BFF receives `SIGTERM`
|
||||
* / `SIGINT`. Each `Client.close()` flushes pending RPCs (with
|
||||
* their own gRPC `CANCELLED` semantics) and tears down the
|
||||
* shared HTTP/2 channel so the process can exit promptly without
|
||||
* waiting for the channel's keepalive PINGs.
|
||||
*
|
||||
* The four stubs share the same underlying HTTP/2 channel (same
|
||||
* endpoint + same credentials, gRPC-js de-duplicates), so the
|
||||
* four `close()` calls are cheap but kept explicit — adding a
|
||||
* fifth stub later means adding a fifth `close()` line, which is
|
||||
* easier to spot than iterating an array that grew without
|
||||
* review.
|
||||
*/
|
||||
onApplicationShutdown(): void {
|
||||
this.chatStub.close();
|
||||
this.ragStub.close();
|
||||
this.ingestionStub.close();
|
||||
this.modelsStub.close();
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
||||
import {
|
||||
ChannelCredentials,
|
||||
Server,
|
||||
ServerCredentials,
|
||||
type Metadata,
|
||||
type ServerWritableStream,
|
||||
} from '@grpc/grpc-js';
|
||||
import {
|
||||
ChatServiceClient,
|
||||
ChatServiceService,
|
||||
type ChatEvent,
|
||||
type ChatRequest,
|
||||
} from '../gen/apf-ai/chat';
|
||||
import { ChatClient } from './chat.client';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import type { AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
|
||||
/**
|
||||
* Integration test: drives `ChatClient` against an in-process fake
|
||||
* gRPC ChatService that emits a canned `ChatEvent` sequence. The
|
||||
* spec covers ADR-0024's confirmation criteria:
|
||||
*
|
||||
* - happy-path stream emits every event the server sent and ends
|
||||
* after `done`;
|
||||
* - metadata (`x-client-id` + `x-correlation-id`) reaches the
|
||||
* server unchanged;
|
||||
* - browser-close (modelled here as an `AbortController.abort()`)
|
||||
* propagates as `call.cancel()` and the server's stream callback
|
||||
* observes the cancellation.
|
||||
*/
|
||||
|
||||
const CONFIG: AiServiceConfig = {
|
||||
endpoint: '',
|
||||
clientId: 'apf-portal-test',
|
||||
useTls: false,
|
||||
};
|
||||
|
||||
interface ServerObservations {
|
||||
request: ChatRequest | null;
|
||||
metadata: Metadata | null;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let serverObservations: ServerObservations;
|
||||
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new Server();
|
||||
server.addService(ChatServiceService, {
|
||||
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
|
||||
serverObservations.request = call.request;
|
||||
serverObservations.metadata = call.metadata;
|
||||
call.on('cancelled', () => {
|
||||
serverObservations.cancelled = true;
|
||||
});
|
||||
chatHandler(call);
|
||||
},
|
||||
});
|
||||
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(bound);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.tryShutdown((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
serverObservations = { request: null, metadata: null, cancelled: false };
|
||||
});
|
||||
|
||||
function buildClient(): ChatClient {
|
||||
const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` };
|
||||
const grpc = new ChatServiceClient(config.endpoint, ChannelCredentials.createInsecure());
|
||||
return new ChatClient(grpc, new GrpcMetadataBuilder(config));
|
||||
}
|
||||
|
||||
async function collect(stream: AsyncIterable<ChatEvent>): Promise<ChatEvent[]> {
|
||||
const events: ChatEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('ChatClient (in-process fake server)', () => {
|
||||
it('emits every server event then terminates on done', async () => {
|
||||
chatHandler = (call) => {
|
||||
call.write({ token: { token: 'hello', value: 'Hello' } });
|
||||
call.write({ token: { token: ' world', value: ' world' } });
|
||||
call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
|
||||
call.end();
|
||||
};
|
||||
|
||||
const client = buildClient();
|
||||
const stream = client.chat({
|
||||
messages: [],
|
||||
conversationId: 'conv-1',
|
||||
model: '',
|
||||
provider: '',
|
||||
toolsAvailable: [],
|
||||
principal: { subject: 'subj-1', roles: ['admin'], attributes: { tenantId: 't' } },
|
||||
});
|
||||
const events = await collect(stream);
|
||||
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events[0]?.token?.value).toBe('Hello');
|
||||
expect(events[2]?.done?.stats?.tokensOut).toBe(2);
|
||||
});
|
||||
|
||||
it('propagates x-client-id and x-correlation-id metadata to the server', async () => {
|
||||
chatHandler = (call) => {
|
||||
call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
|
||||
call.end();
|
||||
};
|
||||
|
||||
const client = buildClient();
|
||||
await collect(
|
||||
client.chat(
|
||||
{
|
||||
messages: [],
|
||||
conversationId: 'conv-2',
|
||||
model: '',
|
||||
provider: '',
|
||||
toolsAvailable: [],
|
||||
principal: { subject: 'subj-2', roles: [], attributes: {} },
|
||||
},
|
||||
{ correlationId: 'corr-from-test' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(serverObservations.metadata?.get('x-client-id')).toEqual(['apf-portal-test']);
|
||||
expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['corr-from-test']);
|
||||
});
|
||||
|
||||
it('cancels the call when the AbortSignal aborts mid-stream', async () => {
|
||||
const cancellationObserved = new Promise<void>((resolve) => {
|
||||
chatHandler = (call) => {
|
||||
call.write({ token: { token: 't1', value: 't1' } });
|
||||
call.on('cancelled', () => resolve());
|
||||
// Deliberately do not `end()` — the test cancels.
|
||||
};
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const client = buildClient();
|
||||
const stream = client.chat(
|
||||
{
|
||||
messages: [],
|
||||
conversationId: 'conv-3',
|
||||
model: '',
|
||||
provider: '',
|
||||
toolsAvailable: [],
|
||||
principal: { subject: 'subj-3', roles: [], attributes: {} },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
// Drain the first event then abort.
|
||||
const iter = stream[Symbol.asyncIterator]();
|
||||
await iter.next();
|
||||
controller.abort();
|
||||
|
||||
await cancellationObserved;
|
||||
expect(serverObservations.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('terminates the stream without data when the AbortSignal is already aborted', async () => {
|
||||
// The signal aborts before the call dial completes. gRPC-js
|
||||
// cancels locally — depending on timing the server may or may
|
||||
// not observe the call, so the spec only locks the client-side
|
||||
// outcome: the stream ends in error and yields no payload.
|
||||
chatHandler = (call) => {
|
||||
call.on('cancelled', () => {
|
||||
// best-effort end on cancellation so the suite never hangs
|
||||
try {
|
||||
call.end();
|
||||
} catch {
|
||||
// already torn down
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const client = buildClient();
|
||||
const stream = client.chat(
|
||||
{
|
||||
messages: [],
|
||||
conversationId: 'conv-4',
|
||||
model: '',
|
||||
provider: '',
|
||||
toolsAvailable: [],
|
||||
principal: { subject: 'subj-4', roles: [], attributes: {} },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
const events: ChatEvent[] = [];
|
||||
let caught: unknown;
|
||||
try {
|
||||
for await (const ev of stream) {
|
||||
events.push(ev);
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
// gRPC-js may surface the cancellation as an error or as a clean
|
||||
// end-of-stream depending on whether the call had time to dial.
|
||||
// Either way, no payload is emitted — that is the contract.
|
||||
if (caught !== undefined) {
|
||||
expect((caught as { code?: number }).code).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ClientReadableStream } from '@grpc/grpc-js';
|
||||
import type { ChatEvent, ChatRequest, ChatServiceClient } from '../gen/apf-ai/chat';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { AI_CHAT_GRPC_CLIENT } from './tokens';
|
||||
|
||||
/**
|
||||
* Wrapper around the generated `ChatServiceClient` for the
|
||||
* server-streaming `Chat` RPC, per ADR-0024 §"Sub-decision 1".
|
||||
*
|
||||
* The wrapper does three things on top of the raw gRPC stub:
|
||||
*
|
||||
* 1. Injects the `x-client-id` + `x-correlation-id` metadata via
|
||||
* `GrpcMetadataBuilder` so every call carries the contract
|
||||
* headers from `apf-ai-service/docs/contract.md`.
|
||||
* 2. Wires an optional `AbortSignal` to `call.cancel()` so the SSE
|
||||
* bridge (next PR in the chantier) can propagate browser
|
||||
* disconnects up to the AI service in one line.
|
||||
* 3. Returns the raw `ClientReadableStream<ChatEvent>` — Node
|
||||
* `Readable` streams are async-iterable by default, so the SSE
|
||||
* bridge consumes the stream with `for await ... of` and no
|
||||
* intermediate adapter.
|
||||
*
|
||||
* Cancellation flows in both directions: a `.cancel()` from inside
|
||||
* the BFF (timeout, abort) reaches the AI service as a
|
||||
* `grpc.status.CANCELLED`, which translates upstream to
|
||||
* `ServerCallContext.CancellationToken` stopping LLM generation
|
||||
* (per `apf-ai-service/docs/streaming.md`).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatClient {
|
||||
constructor(
|
||||
@Inject(AI_CHAT_GRPC_CLIENT) private readonly grpc: ChatServiceClient,
|
||||
private readonly metadata: GrpcMetadataBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start a streaming Chat call.
|
||||
*
|
||||
* The returned `ClientReadableStream<ChatEvent>` emits one event
|
||||
* per AI service `ChatEvent` and ends after the terminal
|
||||
* `ChatEvent.done` (or on cancellation / error).
|
||||
*
|
||||
* @param request The full proto request, already populated with
|
||||
* the conversation history and a `Principal` (build the
|
||||
* principal via `PrincipalMapper.fromInputs`).
|
||||
* @param options.signal Optional AbortSignal. When it aborts, the
|
||||
* underlying gRPC call is cancelled and the stream closes.
|
||||
* @param options.correlationId Override the metadata
|
||||
* correlation-id (defaults to the active OTel trace-id).
|
||||
*/
|
||||
chat(
|
||||
request: ChatRequest,
|
||||
options: { signal?: AbortSignal; correlationId?: string } = {},
|
||||
): ClientReadableStream<ChatEvent> {
|
||||
const metadata = this.metadata.build({ correlationId: options.correlationId });
|
||||
const call = this.grpc.chat(request, metadata);
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
call.cancel();
|
||||
} else {
|
||||
options.signal.addEventListener('abort', () => call.cancel(), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import type { AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
|
||||
/**
|
||||
* Locks the metadata-injection contract per ADR-0024 §"Metadata contract":
|
||||
*
|
||||
* - x-client-id ← AI_SERVICE_CLIENT_ID env (via AI_CONFIG).
|
||||
* - x-correlation-id ← active OTel span's trace-id when present;
|
||||
* explicit `correlationId` override wins over
|
||||
* the span; otherwise a fresh UUID is minted
|
||||
* so the AI service can still join its audit
|
||||
* row to a unique id even when the portal
|
||||
* request is outside a trace.
|
||||
*/
|
||||
|
||||
const CONFIG: AiServiceConfig = {
|
||||
endpoint: 'apf-ai-service:8080',
|
||||
clientId: 'apf-portal-test',
|
||||
useTls: false,
|
||||
};
|
||||
|
||||
let activeSpan: ReturnType<typeof trace.getActiveSpan> = undefined;
|
||||
let getActiveSpanSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeSpan = undefined;
|
||||
getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockImplementation(() => activeSpan);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getActiveSpanSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('GrpcMetadataBuilder', () => {
|
||||
it('always emits x-client-id from the config', () => {
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build();
|
||||
expect(meta.get('x-client-id')).toEqual(['apf-portal-test']);
|
||||
});
|
||||
|
||||
it('uses the active OTel trace-id as x-correlation-id when a span is active', () => {
|
||||
activeSpan = {
|
||||
spanContext: () => ({
|
||||
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
spanId: 'bbbbbbbbbbbbbbbb',
|
||||
traceFlags: 1,
|
||||
}),
|
||||
} as ReturnType<typeof trace.getActiveSpan>;
|
||||
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build();
|
||||
expect(meta.get('x-correlation-id')).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']);
|
||||
});
|
||||
|
||||
it('prefers the explicit options.correlationId over the active span', () => {
|
||||
activeSpan = {
|
||||
spanContext: () => ({
|
||||
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
spanId: 'bbbbbbbbbbbbbbbb',
|
||||
traceFlags: 1,
|
||||
}),
|
||||
} as ReturnType<typeof trace.getActiveSpan>;
|
||||
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build({ correlationId: 'caller-supplied-id' });
|
||||
expect(meta.get('x-correlation-id')).toEqual(['caller-supplied-id']);
|
||||
});
|
||||
|
||||
it('falls back to a UUID v4 when no span is active and no override is passed', () => {
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build();
|
||||
const correlationIds = meta.get('x-correlation-id') as string[];
|
||||
expect(correlationIds).toHaveLength(1);
|
||||
expect(correlationIds[0]).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a fresh Metadata instance on every call (no shared mutation)', () => {
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const a = builder.build();
|
||||
const b = builder.build();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Metadata } from '@grpc/grpc-js';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { AI_CONFIG } from './tokens';
|
||||
import type { AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
|
||||
/**
|
||||
* Builds the gRPC `Metadata` object every outbound call to
|
||||
* `apf-ai-service` must carry, per ADR-0024 §"Metadata contract".
|
||||
*
|
||||
* Two entries:
|
||||
*
|
||||
* - `x-client-id` — deployment slug from `AI_SERVICE_CLIENT_ID`.
|
||||
* Lets the AI service tag its audit rows and metrics with the
|
||||
* calling deployment without inspecting the proto body.
|
||||
* - `x-correlation-id` — W3C `trace-id` of the active OpenTelemetry
|
||||
* span when one exists (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)),
|
||||
* falling back to an explicit value if passed, falling back to a
|
||||
* freshly-minted UUID v4 as a last resort. The AI service echoes
|
||||
* this id in its audit log and propagates it to Jaeger, so the
|
||||
* portal and AI traces can be joined cross-service.
|
||||
*
|
||||
* The builder is a class (not a static helper) so it composes with
|
||||
* NestJS DI and so it can be swapped for a fake in tests that need
|
||||
* to assert the metadata shape without standing up the OTel SDK.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GrpcMetadataBuilder {
|
||||
constructor(@Inject(AI_CONFIG) private readonly config: AiServiceConfig) {}
|
||||
|
||||
/**
|
||||
* Construct the metadata for one outbound call.
|
||||
*
|
||||
* @param options.correlationId Override the auto-resolved value.
|
||||
* Use when the caller already holds a stable id (for example
|
||||
* the SSE bridge controller's request-scoped trace-id) and
|
||||
* wants to bind multiple calls to the same correlation key.
|
||||
* `undefined` is accepted (and equivalent to omitting the
|
||||
* key) so callers can forward an optional value without
|
||||
* a conditional spread.
|
||||
*/
|
||||
build(options: { correlationId?: string | undefined } = {}): Metadata {
|
||||
const meta = new Metadata();
|
||||
meta.set('x-client-id', this.config.clientId);
|
||||
|
||||
const correlationId =
|
||||
options.correlationId ?? trace.getActiveSpan()?.spanContext().traceId ?? randomUUID();
|
||||
meta.set('x-correlation-id', correlationId);
|
||||
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
DeleteDocumentRequest,
|
||||
DeleteDocumentResponse,
|
||||
DocumentSummary,
|
||||
GetDocumentRequest,
|
||||
IngestDocumentRequest,
|
||||
IngestDocumentResponse,
|
||||
IngestionServiceClient,
|
||||
} from '../gen/apf-ai/ingestion';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { AI_INGESTION_GRPC_CLIENT } from './tokens';
|
||||
|
||||
/**
|
||||
* Wrapper around the generated `IngestionServiceClient`.
|
||||
*
|
||||
* Unused by the BFF in v1 — apf-ai-service ships a dedicated CLI
|
||||
* (`tools/Apf.Ai.Ingest/`) for document ingestion during the POC.
|
||||
* The wrapper is in place so the future admin "manage AI corpus"
|
||||
* surface lands as one new controller, not as a new module + a new
|
||||
* client + a new generated stub binding. The unit tests under
|
||||
* `ingestion.client.spec.ts` lock the wire contract today.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IngestionClient {
|
||||
constructor(
|
||||
@Inject(AI_INGESTION_GRPC_CLIENT) private readonly grpc: IngestionServiceClient,
|
||||
private readonly metadata: GrpcMetadataBuilder,
|
||||
) {}
|
||||
|
||||
ingestDocument(
|
||||
request: IngestDocumentRequest,
|
||||
options: { correlationId?: string } = {},
|
||||
): Promise<IngestDocumentResponse> {
|
||||
const metadata = this.metadata.build({ correlationId: options.correlationId });
|
||||
return new Promise<IngestDocumentResponse>((resolve, reject) => {
|
||||
this.grpc.ingestDocument(request, metadata, (err, response) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getDocument(
|
||||
request: GetDocumentRequest,
|
||||
options: { correlationId?: string } = {},
|
||||
): Promise<DocumentSummary> {
|
||||
const metadata = this.metadata.build({ correlationId: options.correlationId });
|
||||
return new Promise<DocumentSummary>((resolve, reject) => {
|
||||
this.grpc.getDocument(request, metadata, (err, response) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteDocument(
|
||||
request: DeleteDocumentRequest,
|
||||
options: { correlationId?: string } = {},
|
||||
): Promise<DeleteDocumentResponse> {
|
||||
const metadata = this.metadata.build({ correlationId: options.correlationId });
|
||||
return new Promise<DeleteDocumentResponse>((resolve, reject) => {
|
||||
this.grpc.deleteDocument(request, metadata, (err, response) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
ListModelsRequest,
|
||||
ListModelsResponse,
|
||||
ModelsServiceClient,
|
||||
} from '../gen/apf-ai/models';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { AI_MODELS_GRPC_CLIENT } from './tokens';
|
||||
|
||||
/**
|
||||
* Wrapper around the generated `ModelsServiceClient` for the unary
|
||||
* `ListModels` RPC. Surfaces the active provider + the configured
|
||||
* provider catalogue for use by a future admin UI tile or a v1
|
||||
* "which models are available" health probe.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModelsClient {
|
||||
constructor(
|
||||
@Inject(AI_MODELS_GRPC_CLIENT) private readonly grpc: ModelsServiceClient,
|
||||
private readonly metadata: GrpcMetadataBuilder,
|
||||
) {}
|
||||
|
||||
listModels(
|
||||
request: ListModelsRequest = {},
|
||||
options: { correlationId?: string } = {},
|
||||
): Promise<ListModelsResponse> {
|
||||
const metadata = this.metadata.build({ correlationId: options.correlationId });
|
||||
return new Promise<ListModelsResponse>((resolve, reject) => {
|
||||
this.grpc.listModels(request, metadata, (err, response) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { describe, it, expect, beforeEach, afterAll } from '@jest/globals';
|
||||
import { HashUserIdService } from '../../audit/hash-user-id.service';
|
||||
import { PrincipalMapper } from './principal.mapper';
|
||||
|
||||
/**
|
||||
* Locks the Principal-mapping contract per ADR-0024 §"Sub-decision 4":
|
||||
*
|
||||
* - subject = HashUserIdService(oid) — same salt, same algo as the
|
||||
* portal's audit and Pino log streams. This is the
|
||||
* join key between portal and apf-ai-service audit
|
||||
* trails.
|
||||
* - roles = pass-through (inclusive expansion lands in a future
|
||||
* ADR; the wire contract on the AI side does not
|
||||
* change).
|
||||
* - attrs = tenantId is reserved; consumers can layer extra
|
||||
* key/value pairs but tenantId wins on collision.
|
||||
*/
|
||||
|
||||
const STRONG_SALT = randomBytes(32).toString('base64url');
|
||||
const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_SALT === undefined) {
|
||||
delete process.env['LOG_USER_ID_SALT'];
|
||||
} else {
|
||||
process.env['LOG_USER_ID_SALT'] = ORIGINAL_SALT;
|
||||
}
|
||||
});
|
||||
|
||||
function makeMapper(): { mapper: PrincipalMapper; hashService: HashUserIdService } {
|
||||
const hashService = new HashUserIdService();
|
||||
return { mapper: new PrincipalMapper(hashService), hashService };
|
||||
}
|
||||
|
||||
describe('PrincipalMapper', () => {
|
||||
it('hashes the Entra oid into the subject via HashUserIdService', () => {
|
||||
const { mapper, hashService } = makeMapper();
|
||||
const principal = mapper.fromInputs({
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
roles: ['admin'],
|
||||
});
|
||||
expect(principal.subject).toBe(hashService.hash('user-oid-42'));
|
||||
expect(principal.subject).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('passes roles through verbatim (no expansion in v1)', () => {
|
||||
const { mapper } = makeMapper();
|
||||
const principal = mapper.fromInputs({
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
roles: ['directeur', 'rh'],
|
||||
});
|
||||
expect(principal.roles).toEqual(['directeur', 'rh']);
|
||||
});
|
||||
|
||||
it('copies roles so caller mutations do not leak into the proto', () => {
|
||||
const { mapper } = makeMapper();
|
||||
const roles: string[] = ['admin'];
|
||||
const principal = mapper.fromInputs({
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
roles,
|
||||
});
|
||||
roles.push('mutated');
|
||||
expect(principal.roles).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('always emits tenantId in attributes', () => {
|
||||
const { mapper } = makeMapper();
|
||||
const principal = mapper.fromInputs({
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
roles: [],
|
||||
});
|
||||
expect(principal.attributes).toEqual({ tenantId: 'tenant-1' });
|
||||
});
|
||||
|
||||
it('merges extraAttributes into the attribute map', () => {
|
||||
const { mapper } = makeMapper();
|
||||
const principal = mapper.fromInputs({
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
roles: [],
|
||||
extraAttributes: { delegation: 'aquitaine', region: 'fr-sw' },
|
||||
});
|
||||
expect(principal.attributes).toEqual({
|
||||
tenantId: 'tenant-1',
|
||||
delegation: 'aquitaine',
|
||||
region: 'fr-sw',
|
||||
});
|
||||
});
|
||||
|
||||
it('tenantId from `tid` wins over a colliding extraAttributes entry', () => {
|
||||
const { mapper } = makeMapper();
|
||||
const principal = mapper.fromInputs({
|
||||
oid: 'user-oid-42',
|
||||
tid: 'tenant-1',
|
||||
roles: [],
|
||||
extraAttributes: { tenantId: 'tenant-spoofed' },
|
||||
});
|
||||
expect(principal.attributes['tenantId']).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('produces the same subject for the same oid + salt across instances', () => {
|
||||
const { mapper: m1 } = makeMapper();
|
||||
const { mapper: m2 } = makeMapper();
|
||||
expect(m1.fromInputs({ oid: 'x', tid: 't', roles: [] }).subject).toBe(
|
||||
m2.fromInputs({ oid: 'x', tid: 't', roles: [] }).subject,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HashUserIdService } from '../../audit/hash-user-id.service';
|
||||
import type { Principal } from '../gen/apf-ai/common';
|
||||
|
||||
/**
|
||||
* Read-only view of the session fields the mapper consumes. Decoupled
|
||||
* from `AuthenticatedUser` so callers can build a Principal in tests
|
||||
* or in non-session contexts (a future background-job consumer) by
|
||||
* supplying a plain object — without dragging the full session shape
|
||||
* along.
|
||||
*
|
||||
* Keep the shape minimal: anything beyond `oid + tid + roles` should
|
||||
* land in `attributes` rather than as a new top-level field, so the
|
||||
* proto contract stays stable as the BFF grows new claim sources.
|
||||
*/
|
||||
export interface PrincipalInputs {
|
||||
/** Entra Object ID. Hashed via `HashUserIdService` into the proto `subject` field. */
|
||||
readonly oid: string;
|
||||
/** Entra tenant id. Surfaced as `attributes.tenantId`. */
|
||||
readonly tid: string;
|
||||
/** Roles already resolved upstream by the auth flow. */
|
||||
readonly roles: readonly string[];
|
||||
/**
|
||||
* Free-form extra attributes (delegation slug, region, …) merged
|
||||
* into `attributes` after `tenantId`. Caller-controlled; the
|
||||
* mapper does not enrich. Reserved keys (`tenantId`) overwrite
|
||||
* the caller-provided value to keep the proto contract single-
|
||||
* sourced.
|
||||
*/
|
||||
readonly extraAttributes?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `apf.ai.v1.Principal` proto from a portal session,
|
||||
* per ADR-0024 §"Sub-decision 4 — unsigned Principal in the proto body".
|
||||
*
|
||||
* The `subject` field MUST be the same hash the audit module writes
|
||||
* to `audit.events.actor_id_hash` (per [ADR-0013](../../docs/decisions/0013-audit-trail-separated-postgres-append-only.md))
|
||||
* and that Pino's `user_id_hash` log field carries (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)).
|
||||
* The two services' audit trails only join cleanly when both sides
|
||||
* compute the same value — same Entra `oid`, same salt, same algo.
|
||||
* The mapper delegates to `HashUserIdService` to make that contract
|
||||
* explicit at the wire boundary.
|
||||
*
|
||||
* The `roles` field is passed through verbatim in v1. A future ADR
|
||||
* on role hierarchy (proposed alongside the stargate migration
|
||||
* analysis) will introduce inclusive expansion (`Admin` ⇒
|
||||
* `[admin, directeur, rh, collaborateur]`). At that point the
|
||||
* mapper grows a `RoleExpander` collaborator; the wire shape and
|
||||
* the upstream contract on the AI side stay unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrincipalMapper {
|
||||
constructor(private readonly hashUserId: HashUserIdService) {}
|
||||
|
||||
fromInputs(inputs: PrincipalInputs): Principal {
|
||||
return {
|
||||
subject: this.hashUserId.hash(inputs.oid),
|
||||
roles: [...inputs.roles],
|
||||
attributes: {
|
||||
...(inputs.extraAttributes ?? {}),
|
||||
tenantId: inputs.tid,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
||||
import {
|
||||
ChannelCredentials,
|
||||
Server,
|
||||
ServerCredentials,
|
||||
status as GrpcStatus,
|
||||
type Metadata,
|
||||
type sendUnaryData,
|
||||
type ServerUnaryCall,
|
||||
} from '@grpc/grpc-js';
|
||||
import {
|
||||
RagServiceClient,
|
||||
RagServiceService,
|
||||
type RagSearchRequest,
|
||||
type RagSearchResponse,
|
||||
} from '../gen/apf-ai/rag';
|
||||
import { Struct } from '../gen/apf-ai/google/protobuf/struct';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { RagClient } from './rag.client';
|
||||
import type { AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
|
||||
/**
|
||||
* Locks the unary RAG contract: the wrapper promisifies the
|
||||
* callback-style stub, forwards metadata to the server, and
|
||||
* surfaces `ServiceError` rejections with the original gRPC status
|
||||
* code intact.
|
||||
*/
|
||||
|
||||
const CONFIG: AiServiceConfig = {
|
||||
endpoint: '',
|
||||
clientId: 'apf-portal-test',
|
||||
useTls: false,
|
||||
};
|
||||
|
||||
interface ServerObservations {
|
||||
request: RagSearchRequest | null;
|
||||
metadata: Metadata | null;
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let serverObservations: ServerObservations;
|
||||
let searchHandler: (
|
||||
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
|
||||
callback: sendUnaryData<RagSearchResponse>,
|
||||
) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new Server();
|
||||
server.addService(RagServiceService, {
|
||||
search: (call, callback) => {
|
||||
serverObservations.request = call.request;
|
||||
serverObservations.metadata = call.metadata;
|
||||
searchHandler(call, callback);
|
||||
},
|
||||
});
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(bound);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.tryShutdown((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
serverObservations = { request: null, metadata: null };
|
||||
});
|
||||
|
||||
function buildClient(): RagClient {
|
||||
const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` };
|
||||
const grpc = new RagServiceClient(config.endpoint, ChannelCredentials.createInsecure());
|
||||
return new RagClient(grpc, new GrpcMetadataBuilder(config));
|
||||
}
|
||||
|
||||
describe('RagClient (in-process fake server)', () => {
|
||||
it('resolves with the server response on a happy unary call', async () => {
|
||||
searchHandler = (_call, callback) => {
|
||||
callback(null, {
|
||||
chunks: [
|
||||
{
|
||||
id: 'chunk-1',
|
||||
documentId: 'doc-1',
|
||||
content: 'Lorem ipsum',
|
||||
source: 'rag-test',
|
||||
score: 0.99,
|
||||
metadata: Struct.fromPartial({}),
|
||||
},
|
||||
],
|
||||
correlationId: 'echo-corr',
|
||||
});
|
||||
};
|
||||
|
||||
const client = buildClient();
|
||||
const response = await client.search(
|
||||
{
|
||||
query: 'hello',
|
||||
topK: 3,
|
||||
principal: { subject: 'subj-1', roles: [], attributes: {} },
|
||||
filters: { source: '', documentId: '' },
|
||||
},
|
||||
{ correlationId: 'caller-corr' },
|
||||
);
|
||||
|
||||
expect(response.chunks).toHaveLength(1);
|
||||
expect(response.chunks[0]?.id).toBe('chunk-1');
|
||||
expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['caller-corr']);
|
||||
});
|
||||
|
||||
it('rejects with the original gRPC ServiceError on server error', async () => {
|
||||
searchHandler = (_call, callback) => {
|
||||
callback({
|
||||
code: GrpcStatus.PERMISSION_DENIED,
|
||||
details: 'forbidden',
|
||||
metadata: undefined as unknown as Metadata,
|
||||
name: 'Error',
|
||||
message: 'forbidden',
|
||||
});
|
||||
};
|
||||
|
||||
const client = buildClient();
|
||||
await expect(
|
||||
client.search({
|
||||
query: 'q',
|
||||
topK: 1,
|
||||
principal: { subject: 'subj-2', roles: [], attributes: {} },
|
||||
filters: { source: '', documentId: '' },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: GrpcStatus.PERMISSION_DENIED });
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { RagSearchRequest, RagSearchResponse, RagServiceClient } from '../gen/apf-ai/rag';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import { AI_RAG_GRPC_CLIENT } from './tokens';
|
||||
|
||||
/**
|
||||
* Wrapper around the generated `RagServiceClient` for the unary
|
||||
* `Search` RPC, per ADR-0024.
|
||||
*
|
||||
* Promisifies the callback-style gRPC stub and injects the standard
|
||||
* metadata. Errors propagate as the underlying `ServiceError` (with
|
||||
* `code`, `details`, etc.) so callers can map them to portal-side
|
||||
* HTTP statuses without losing the gRPC status code.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RagClient {
|
||||
constructor(
|
||||
@Inject(AI_RAG_GRPC_CLIENT) private readonly grpc: RagServiceClient,
|
||||
private readonly metadata: GrpcMetadataBuilder,
|
||||
) {}
|
||||
|
||||
search(
|
||||
request: RagSearchRequest,
|
||||
options: { correlationId?: string } = {},
|
||||
): Promise<RagSearchResponse> {
|
||||
const metadata = this.metadata.build({ correlationId: options.correlationId });
|
||||
return new Promise<RagSearchResponse>((resolve, reject) => {
|
||||
this.grpc.search(request, metadata, (err, response) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Dependency-injection tokens for `AiClientModule`, per ADR-0024.
|
||||
*
|
||||
* The module exposes three classes of provider:
|
||||
*
|
||||
* 1. `AI_CONFIG` — the parsed env vars from `assertAiServiceConfig`.
|
||||
* 2. `AI_CREDENTIALS` — the `ChannelCredentials` (insecure in dev
|
||||
* h2c, SSL in prod h2 + TLS).
|
||||
* 3. Generated gRPC stub instances — one per RPC service from
|
||||
* `apf.ai.v1.*` (Chat, Rag, Ingestion, Models). Wrapped by the
|
||||
* `*.client.ts` services in this folder; the raw stubs stay
|
||||
* behind tokens so the wrapper layer is the only place that
|
||||
* knows about the codegen output.
|
||||
*/
|
||||
|
||||
export const AI_CONFIG = Symbol('AI_CONFIG');
|
||||
export const AI_CREDENTIALS = Symbol('AI_CREDENTIALS');
|
||||
export const AI_CHAT_GRPC_CLIENT = Symbol('AI_CHAT_GRPC_CLIENT');
|
||||
export const AI_RAG_GRPC_CLIENT = Symbol('AI_RAG_GRPC_CLIENT');
|
||||
export const AI_INGESTION_GRPC_CLIENT = Symbol('AI_INGESTION_GRPC_CLIENT');
|
||||
export const AI_MODELS_GRPC_CLIENT = Symbol('AI_MODELS_GRPC_CLIENT');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,765 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.8
|
||||
// protoc v3.19.1
|
||||
// source: common.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import Long from "long";
|
||||
import { Struct } from "./google/protobuf/struct";
|
||||
|
||||
export enum ChatRole {
|
||||
CHAT_ROLE_UNSPECIFIED = 0,
|
||||
CHAT_ROLE_SYSTEM = 1,
|
||||
CHAT_ROLE_USER = 2,
|
||||
CHAT_ROLE_ASSISTANT = 3,
|
||||
CHAT_ROLE_TOOL = 4,
|
||||
UNRECOGNIZED = -1,
|
||||
}
|
||||
|
||||
export function chatRoleFromJSON(object: any): ChatRole {
|
||||
switch (object) {
|
||||
case 0:
|
||||
case "CHAT_ROLE_UNSPECIFIED":
|
||||
return ChatRole.CHAT_ROLE_UNSPECIFIED;
|
||||
case 1:
|
||||
case "CHAT_ROLE_SYSTEM":
|
||||
return ChatRole.CHAT_ROLE_SYSTEM;
|
||||
case 2:
|
||||
case "CHAT_ROLE_USER":
|
||||
return ChatRole.CHAT_ROLE_USER;
|
||||
case 3:
|
||||
case "CHAT_ROLE_ASSISTANT":
|
||||
return ChatRole.CHAT_ROLE_ASSISTANT;
|
||||
case 4:
|
||||
case "CHAT_ROLE_TOOL":
|
||||
return ChatRole.CHAT_ROLE_TOOL;
|
||||
case -1:
|
||||
case "UNRECOGNIZED":
|
||||
default:
|
||||
return ChatRole.UNRECOGNIZED;
|
||||
}
|
||||
}
|
||||
|
||||
export function chatRoleToJSON(object: ChatRole): string {
|
||||
switch (object) {
|
||||
case ChatRole.CHAT_ROLE_UNSPECIFIED:
|
||||
return "CHAT_ROLE_UNSPECIFIED";
|
||||
case ChatRole.CHAT_ROLE_SYSTEM:
|
||||
return "CHAT_ROLE_SYSTEM";
|
||||
case ChatRole.CHAT_ROLE_USER:
|
||||
return "CHAT_ROLE_USER";
|
||||
case ChatRole.CHAT_ROLE_ASSISTANT:
|
||||
return "CHAT_ROLE_ASSISTANT";
|
||||
case ChatRole.CHAT_ROLE_TOOL:
|
||||
return "CHAT_ROLE_TOOL";
|
||||
case ChatRole.UNRECOGNIZED:
|
||||
default:
|
||||
return "UNRECOGNIZED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity-bearing principal shipped with every request (carried in the JWT for
|
||||
* internal callers, or in the request body for API-key callers — same shape).
|
||||
*/
|
||||
export interface Principal {
|
||||
subject: string;
|
||||
roles: string[];
|
||||
attributes: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface Principal_AttributesEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default-deny ACL attached to every ingested chunk.
|
||||
* At least one of allowed_roles or allowed_subjects must be non-empty,
|
||||
* enforced at the controller, the DB CHECK constraint, and the post-filter.
|
||||
*/
|
||||
export interface AclSpec {
|
||||
allowedRoles: string[];
|
||||
allowedSubjects: string[];
|
||||
deniedRoles: string[];
|
||||
requiredAttributes: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface AclSpec_RequiredAttributesEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-schema descriptor for caller-side tool execution. The AI service is
|
||||
* tool-blind: it emits ToolCall events; the caller executes and posts back.
|
||||
*/
|
||||
export interface ToolDescriptor {
|
||||
name: string;
|
||||
description: string;
|
||||
schema?: { [key: string]: any } | undefined;
|
||||
}
|
||||
|
||||
function createBasePrincipal(): Principal {
|
||||
return { subject: "", roles: [], attributes: {} };
|
||||
}
|
||||
|
||||
export const Principal: MessageFns<Principal> = {
|
||||
encode(message: Principal, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.subject !== "") {
|
||||
writer.uint32(10).string(message.subject);
|
||||
}
|
||||
for (const v of message.roles) {
|
||||
writer.uint32(18).string(v!);
|
||||
}
|
||||
globalThis.Object.entries(message.attributes).forEach(([key, value]: [string, string]) => {
|
||||
Principal_AttributesEntry.encode({ key: key as any, value }, writer.uint32(26).fork()).join();
|
||||
});
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Principal {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBasePrincipal();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.subject = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.roles.push(reader.string());
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
const entry3 = Principal_AttributesEntry.decode(reader, reader.uint32());
|
||||
if (entry3.value !== undefined) {
|
||||
message.attributes[entry3.key] = entry3.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Principal {
|
||||
return {
|
||||
subject: isSet(object.subject) ? globalThis.String(object.subject) : "",
|
||||
roles: globalThis.Array.isArray(object?.roles) ? object.roles.map((e: any) => globalThis.String(e)) : [],
|
||||
attributes: isObject(object.attributes)
|
||||
? (globalThis.Object.entries(object.attributes) as [string, any][]).reduce(
|
||||
(acc: { [key: string]: string }, [key, value]: [string, any]) => {
|
||||
acc[key] = globalThis.String(value);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
)
|
||||
: {},
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Principal): unknown {
|
||||
const obj: any = {};
|
||||
if (message.subject !== "") {
|
||||
obj.subject = message.subject;
|
||||
}
|
||||
if (message.roles?.length) {
|
||||
obj.roles = message.roles;
|
||||
}
|
||||
if (message.attributes) {
|
||||
const entries = globalThis.Object.entries(message.attributes) as [string, string][];
|
||||
if (entries.length > 0) {
|
||||
obj.attributes = {};
|
||||
entries.forEach(([k, v]) => {
|
||||
obj.attributes[k] = v;
|
||||
});
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Principal>, I>>(base?: I): Principal {
|
||||
return Principal.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Principal>, I>>(object: I): Principal {
|
||||
const message = createBasePrincipal();
|
||||
message.subject = object.subject ?? "";
|
||||
message.roles = object.roles?.map((e) => e) || [];
|
||||
message.attributes = (globalThis.Object.entries(object.attributes ?? {}) as [string, string][]).reduce(
|
||||
(acc: { [key: string]: string }, [key, value]: [string, string]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key] = globalThis.String(value);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBasePrincipal_AttributesEntry(): Principal_AttributesEntry {
|
||||
return { key: "", value: "" };
|
||||
}
|
||||
|
||||
export const Principal_AttributesEntry: MessageFns<Principal_AttributesEntry> = {
|
||||
encode(message: Principal_AttributesEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.key !== "") {
|
||||
writer.uint32(10).string(message.key);
|
||||
}
|
||||
if (message.value !== "") {
|
||||
writer.uint32(18).string(message.value);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Principal_AttributesEntry {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBasePrincipal_AttributesEntry();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.key = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.value = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Principal_AttributesEntry {
|
||||
return {
|
||||
key: isSet(object.key) ? globalThis.String(object.key) : "",
|
||||
value: isSet(object.value) ? globalThis.String(object.value) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Principal_AttributesEntry): unknown {
|
||||
const obj: any = {};
|
||||
if (message.key !== "") {
|
||||
obj.key = message.key;
|
||||
}
|
||||
if (message.value !== "") {
|
||||
obj.value = message.value;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Principal_AttributesEntry>, I>>(base?: I): Principal_AttributesEntry {
|
||||
return Principal_AttributesEntry.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Principal_AttributesEntry>, I>>(object: I): Principal_AttributesEntry {
|
||||
const message = createBasePrincipal_AttributesEntry();
|
||||
message.key = object.key ?? "";
|
||||
message.value = object.value ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseAclSpec(): AclSpec {
|
||||
return { allowedRoles: [], allowedSubjects: [], deniedRoles: [], requiredAttributes: {} };
|
||||
}
|
||||
|
||||
export const AclSpec: MessageFns<AclSpec> = {
|
||||
encode(message: AclSpec, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
for (const v of message.allowedRoles) {
|
||||
writer.uint32(10).string(v!);
|
||||
}
|
||||
for (const v of message.allowedSubjects) {
|
||||
writer.uint32(18).string(v!);
|
||||
}
|
||||
for (const v of message.deniedRoles) {
|
||||
writer.uint32(26).string(v!);
|
||||
}
|
||||
globalThis.Object.entries(message.requiredAttributes).forEach(([key, value]: [string, string]) => {
|
||||
AclSpec_RequiredAttributesEntry.encode({ key: key as any, value }, writer.uint32(34).fork()).join();
|
||||
});
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): AclSpec {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseAclSpec();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.allowedRoles.push(reader.string());
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.allowedSubjects.push(reader.string());
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.deniedRoles.push(reader.string());
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
const entry4 = AclSpec_RequiredAttributesEntry.decode(reader, reader.uint32());
|
||||
if (entry4.value !== undefined) {
|
||||
message.requiredAttributes[entry4.key] = entry4.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): AclSpec {
|
||||
return {
|
||||
allowedRoles: globalThis.Array.isArray(object?.allowedRoles)
|
||||
? object.allowedRoles.map((e: any) => globalThis.String(e))
|
||||
: globalThis.Array.isArray(object?.allowed_roles)
|
||||
? object.allowed_roles.map((e: any) => globalThis.String(e))
|
||||
: [],
|
||||
allowedSubjects: globalThis.Array.isArray(object?.allowedSubjects)
|
||||
? object.allowedSubjects.map((e: any) => globalThis.String(e))
|
||||
: globalThis.Array.isArray(object?.allowed_subjects)
|
||||
? object.allowed_subjects.map((e: any) => globalThis.String(e))
|
||||
: [],
|
||||
deniedRoles: globalThis.Array.isArray(object?.deniedRoles)
|
||||
? object.deniedRoles.map((e: any) => globalThis.String(e))
|
||||
: globalThis.Array.isArray(object?.denied_roles)
|
||||
? object.denied_roles.map((e: any) => globalThis.String(e))
|
||||
: [],
|
||||
requiredAttributes: isObject(object.requiredAttributes)
|
||||
? (globalThis.Object.entries(object.requiredAttributes) as [string, any][]).reduce(
|
||||
(acc: { [key: string]: string }, [key, value]: [string, any]) => {
|
||||
acc[key] = globalThis.String(value);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
)
|
||||
: isObject(object.required_attributes)
|
||||
? (globalThis.Object.entries(object.required_attributes) as [string, any][]).reduce(
|
||||
(acc: { [key: string]: string }, [key, value]: [string, any]) => {
|
||||
acc[key] = globalThis.String(value);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
)
|
||||
: {},
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: AclSpec): unknown {
|
||||
const obj: any = {};
|
||||
if (message.allowedRoles?.length) {
|
||||
obj.allowedRoles = message.allowedRoles;
|
||||
}
|
||||
if (message.allowedSubjects?.length) {
|
||||
obj.allowedSubjects = message.allowedSubjects;
|
||||
}
|
||||
if (message.deniedRoles?.length) {
|
||||
obj.deniedRoles = message.deniedRoles;
|
||||
}
|
||||
if (message.requiredAttributes) {
|
||||
const entries = globalThis.Object.entries(message.requiredAttributes) as [string, string][];
|
||||
if (entries.length > 0) {
|
||||
obj.requiredAttributes = {};
|
||||
entries.forEach(([k, v]) => {
|
||||
obj.requiredAttributes[k] = v;
|
||||
});
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<AclSpec>, I>>(base?: I): AclSpec {
|
||||
return AclSpec.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<AclSpec>, I>>(object: I): AclSpec {
|
||||
const message = createBaseAclSpec();
|
||||
message.allowedRoles = object.allowedRoles?.map((e) => e) || [];
|
||||
message.allowedSubjects = object.allowedSubjects?.map((e) => e) || [];
|
||||
message.deniedRoles = object.deniedRoles?.map((e) => e) || [];
|
||||
message.requiredAttributes = (globalThis.Object.entries(object.requiredAttributes ?? {}) as [string, string][])
|
||||
.reduce((acc: { [key: string]: string }, [key, value]: [string, string]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key] = globalThis.String(value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseAclSpec_RequiredAttributesEntry(): AclSpec_RequiredAttributesEntry {
|
||||
return { key: "", value: "" };
|
||||
}
|
||||
|
||||
export const AclSpec_RequiredAttributesEntry: MessageFns<AclSpec_RequiredAttributesEntry> = {
|
||||
encode(message: AclSpec_RequiredAttributesEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.key !== "") {
|
||||
writer.uint32(10).string(message.key);
|
||||
}
|
||||
if (message.value !== "") {
|
||||
writer.uint32(18).string(message.value);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): AclSpec_RequiredAttributesEntry {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseAclSpec_RequiredAttributesEntry();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.key = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.value = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): AclSpec_RequiredAttributesEntry {
|
||||
return {
|
||||
key: isSet(object.key) ? globalThis.String(object.key) : "",
|
||||
value: isSet(object.value) ? globalThis.String(object.value) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: AclSpec_RequiredAttributesEntry): unknown {
|
||||
const obj: any = {};
|
||||
if (message.key !== "") {
|
||||
obj.key = message.key;
|
||||
}
|
||||
if (message.value !== "") {
|
||||
obj.value = message.value;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<AclSpec_RequiredAttributesEntry>, I>>(base?: I): AclSpec_RequiredAttributesEntry {
|
||||
return AclSpec_RequiredAttributesEntry.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<AclSpec_RequiredAttributesEntry>, I>>(
|
||||
object: I,
|
||||
): AclSpec_RequiredAttributesEntry {
|
||||
const message = createBaseAclSpec_RequiredAttributesEntry();
|
||||
message.key = object.key ?? "";
|
||||
message.value = object.value ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseChatMessage(): ChatMessage {
|
||||
return { role: 0, content: "", toolCallId: "", name: "" };
|
||||
}
|
||||
|
||||
export const ChatMessage: MessageFns<ChatMessage> = {
|
||||
encode(message: ChatMessage, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.role !== 0) {
|
||||
writer.uint32(8).int32(message.role);
|
||||
}
|
||||
if (message.content !== "") {
|
||||
writer.uint32(18).string(message.content);
|
||||
}
|
||||
if (message.toolCallId !== "") {
|
||||
writer.uint32(26).string(message.toolCallId);
|
||||
}
|
||||
if (message.name !== "") {
|
||||
writer.uint32(34).string(message.name);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ChatMessage {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseChatMessage();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.role = reader.int32() as any;
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.toolCallId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.name = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): ChatMessage {
|
||||
return {
|
||||
role: isSet(object.role) ? chatRoleFromJSON(object.role) : 0,
|
||||
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||
toolCallId: isSet(object.toolCallId)
|
||||
? globalThis.String(object.toolCallId)
|
||||
: isSet(object.tool_call_id)
|
||||
? globalThis.String(object.tool_call_id)
|
||||
: "",
|
||||
name: isSet(object.name) ? globalThis.String(object.name) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: ChatMessage): unknown {
|
||||
const obj: any = {};
|
||||
if (message.role !== 0) {
|
||||
obj.role = chatRoleToJSON(message.role);
|
||||
}
|
||||
if (message.content !== "") {
|
||||
obj.content = message.content;
|
||||
}
|
||||
if (message.toolCallId !== "") {
|
||||
obj.toolCallId = message.toolCallId;
|
||||
}
|
||||
if (message.name !== "") {
|
||||
obj.name = message.name;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ChatMessage>, I>>(base?: I): ChatMessage {
|
||||
return ChatMessage.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ChatMessage>, I>>(object: I): ChatMessage {
|
||||
const message = createBaseChatMessage();
|
||||
message.role = object.role ?? 0;
|
||||
message.content = object.content ?? "";
|
||||
message.toolCallId = object.toolCallId ?? "";
|
||||
message.name = object.name ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseToolDescriptor(): ToolDescriptor {
|
||||
return { name: "", description: "", schema: undefined };
|
||||
}
|
||||
|
||||
export const ToolDescriptor: MessageFns<ToolDescriptor> = {
|
||||
encode(message: ToolDescriptor, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.name !== "") {
|
||||
writer.uint32(10).string(message.name);
|
||||
}
|
||||
if (message.description !== "") {
|
||||
writer.uint32(18).string(message.description);
|
||||
}
|
||||
if (message.schema !== undefined) {
|
||||
Struct.encode(Struct.wrap(message.schema), writer.uint32(26).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ToolDescriptor {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseToolDescriptor();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.name = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.description = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.schema = Struct.unwrap(Struct.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): ToolDescriptor {
|
||||
return {
|
||||
name: isSet(object.name) ? globalThis.String(object.name) : "",
|
||||
description: isSet(object.description) ? globalThis.String(object.description) : "",
|
||||
schema: isObject(object.schema) ? object.schema : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: ToolDescriptor): unknown {
|
||||
const obj: any = {};
|
||||
if (message.name !== "") {
|
||||
obj.name = message.name;
|
||||
}
|
||||
if (message.description !== "") {
|
||||
obj.description = message.description;
|
||||
}
|
||||
if (message.schema !== undefined) {
|
||||
obj.schema = message.schema;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ToolDescriptor>, I>>(base?: I): ToolDescriptor {
|
||||
return ToolDescriptor.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ToolDescriptor>, I>>(object: I): ToolDescriptor {
|
||||
const message = createBaseToolDescriptor();
|
||||
message.name = object.name ?? "";
|
||||
message.description = object.description ?? "";
|
||||
message.schema = object.schema ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.8
|
||||
// protoc v3.19.1
|
||||
// source: google/protobuf/struct.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import Long from "long";
|
||||
|
||||
/**
|
||||
* `NullValue` is a singleton enumeration to represent the null value for the
|
||||
* `Value` type union.
|
||||
*
|
||||
* The JSON representation for `NullValue` is JSON `null`.
|
||||
*/
|
||||
export enum NullValue {
|
||||
/** NULL_VALUE - Null value. */
|
||||
NULL_VALUE = 0,
|
||||
UNRECOGNIZED = -1,
|
||||
}
|
||||
|
||||
export function nullValueFromJSON(object: any): NullValue {
|
||||
switch (object) {
|
||||
case 0:
|
||||
case "NULL_VALUE":
|
||||
return NullValue.NULL_VALUE;
|
||||
case -1:
|
||||
case "UNRECOGNIZED":
|
||||
default:
|
||||
return NullValue.UNRECOGNIZED;
|
||||
}
|
||||
}
|
||||
|
||||
export function nullValueToJSON(object: NullValue): string {
|
||||
switch (object) {
|
||||
case NullValue.NULL_VALUE:
|
||||
return "NULL_VALUE";
|
||||
case NullValue.UNRECOGNIZED:
|
||||
default:
|
||||
return "UNRECOGNIZED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Struct` represents a structured data value, consisting of fields
|
||||
* which map to dynamically typed values. In some languages, `Struct`
|
||||
* might be supported by a native representation. For example, in
|
||||
* scripting languages like JS a struct is represented as an
|
||||
* object. The details of that representation are described together
|
||||
* with the proto support for the language.
|
||||
*
|
||||
* The JSON representation for `Struct` is JSON object.
|
||||
*/
|
||||
export interface Struct {
|
||||
/** Unordered map of dynamically typed values. */
|
||||
fields: { [key: string]: any | undefined };
|
||||
}
|
||||
|
||||
export interface Struct_FieldsEntry {
|
||||
key: string;
|
||||
value?: any | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Value` represents a dynamically typed value which can be either
|
||||
* null, a number, a string, a boolean, a recursive struct value, or a
|
||||
* list of values. A producer of value is expected to set one of these
|
||||
* variants. Absence of any variant indicates an error.
|
||||
*
|
||||
* The JSON representation for `Value` is JSON value.
|
||||
*/
|
||||
export interface Value {
|
||||
/** Represents a null value. */
|
||||
nullValue?:
|
||||
| NullValue
|
||||
| undefined;
|
||||
/** Represents a double value. */
|
||||
numberValue?:
|
||||
| number
|
||||
| undefined;
|
||||
/** Represents a string value. */
|
||||
stringValue?:
|
||||
| string
|
||||
| undefined;
|
||||
/** Represents a boolean value. */
|
||||
boolValue?:
|
||||
| boolean
|
||||
| undefined;
|
||||
/** Represents a structured value. */
|
||||
structValue?:
|
||||
| { [key: string]: any }
|
||||
| undefined;
|
||||
/** Represents a repeated `Value`. */
|
||||
listValue?: Array<any> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ListValue` is a wrapper around a repeated field of values.
|
||||
*
|
||||
* The JSON representation for `ListValue` is JSON array.
|
||||
*/
|
||||
export interface ListValue {
|
||||
/** Repeated field of dynamically typed values. */
|
||||
values: any[];
|
||||
}
|
||||
|
||||
function createBaseStruct(): Struct {
|
||||
return { fields: {} };
|
||||
}
|
||||
|
||||
export const Struct: MessageFns<Struct> & StructWrapperFns = {
|
||||
encode(message: Struct, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
globalThis.Object.entries(message.fields).forEach(([key, value]: [string, any | undefined]) => {
|
||||
if (value !== undefined) {
|
||||
Struct_FieldsEntry.encode({ key: key as any, value }, writer.uint32(10).fork()).join();
|
||||
}
|
||||
});
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Struct {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseStruct();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
const entry1 = Struct_FieldsEntry.decode(reader, reader.uint32());
|
||||
if (entry1.value !== undefined) {
|
||||
message.fields[entry1.key] = entry1.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Struct {
|
||||
return {
|
||||
fields: isObject(object.fields)
|
||||
? (globalThis.Object.entries(object.fields) as [string, any][]).reduce(
|
||||
(acc: { [key: string]: any | undefined }, [key, value]: [string, any]) => {
|
||||
acc[key] = value as any | undefined;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
)
|
||||
: {},
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Struct): unknown {
|
||||
const obj: any = {};
|
||||
if (message.fields) {
|
||||
const entries = globalThis.Object.entries(message.fields) as [string, any | undefined][];
|
||||
if (entries.length > 0) {
|
||||
obj.fields = {};
|
||||
entries.forEach(([k, v]) => {
|
||||
obj.fields[k] = v;
|
||||
});
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Struct>, I>>(base?: I): Struct {
|
||||
return Struct.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Struct>, I>>(object: I): Struct {
|
||||
const message = createBaseStruct();
|
||||
message.fields = (globalThis.Object.entries(object.fields ?? {}) as [string, any | undefined][]).reduce(
|
||||
(acc: { [key: string]: any | undefined }, [key, value]: [string, any | undefined]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
return message;
|
||||
},
|
||||
|
||||
wrap(object: { [key: string]: any } | undefined): Struct {
|
||||
const struct = createBaseStruct();
|
||||
|
||||
if (object !== undefined) {
|
||||
for (const key of globalThis.Object.keys(object)) {
|
||||
struct.fields[key] = object[key];
|
||||
}
|
||||
}
|
||||
return struct;
|
||||
},
|
||||
|
||||
unwrap(message: Struct): { [key: string]: any } {
|
||||
const object: { [key: string]: any } = {};
|
||||
if (message.fields) {
|
||||
for (const key of globalThis.Object.keys(message.fields)) {
|
||||
object[key] = message.fields[key];
|
||||
}
|
||||
}
|
||||
return object;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseStruct_FieldsEntry(): Struct_FieldsEntry {
|
||||
return { key: "", value: undefined };
|
||||
}
|
||||
|
||||
export const Struct_FieldsEntry: MessageFns<Struct_FieldsEntry> = {
|
||||
encode(message: Struct_FieldsEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.key !== "") {
|
||||
writer.uint32(10).string(message.key);
|
||||
}
|
||||
if (message.value !== undefined) {
|
||||
Value.encode(Value.wrap(message.value), writer.uint32(18).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Struct_FieldsEntry {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseStruct_FieldsEntry();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.key = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.value = Value.unwrap(Value.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Struct_FieldsEntry {
|
||||
return {
|
||||
key: isSet(object.key) ? globalThis.String(object.key) : "",
|
||||
value: isSet(object?.value) ? object.value : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Struct_FieldsEntry): unknown {
|
||||
const obj: any = {};
|
||||
if (message.key !== "") {
|
||||
obj.key = message.key;
|
||||
}
|
||||
if (message.value !== undefined) {
|
||||
obj.value = message.value;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Struct_FieldsEntry>, I>>(base?: I): Struct_FieldsEntry {
|
||||
return Struct_FieldsEntry.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Struct_FieldsEntry>, I>>(object: I): Struct_FieldsEntry {
|
||||
const message = createBaseStruct_FieldsEntry();
|
||||
message.key = object.key ?? "";
|
||||
message.value = object.value ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseValue(): Value {
|
||||
return {
|
||||
nullValue: undefined,
|
||||
numberValue: undefined,
|
||||
stringValue: undefined,
|
||||
boolValue: undefined,
|
||||
structValue: undefined,
|
||||
listValue: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const Value: MessageFns<Value> & AnyValueWrapperFns = {
|
||||
encode(message: Value, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.nullValue !== undefined) {
|
||||
writer.uint32(8).int32(message.nullValue);
|
||||
}
|
||||
if (message.numberValue !== undefined) {
|
||||
writer.uint32(17).double(message.numberValue);
|
||||
}
|
||||
if (message.stringValue !== undefined) {
|
||||
writer.uint32(26).string(message.stringValue);
|
||||
}
|
||||
if (message.boolValue !== undefined) {
|
||||
writer.uint32(32).bool(message.boolValue);
|
||||
}
|
||||
if (message.structValue !== undefined) {
|
||||
Struct.encode(Struct.wrap(message.structValue), writer.uint32(42).fork()).join();
|
||||
}
|
||||
if (message.listValue !== undefined) {
|
||||
ListValue.encode(ListValue.wrap(message.listValue), writer.uint32(50).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Value {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseValue();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.nullValue = reader.int32() as any;
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 17) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.numberValue = reader.double();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.stringValue = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 32) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.boolValue = reader.bool();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.structValue = Struct.unwrap(Struct.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 50) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.listValue = ListValue.unwrap(ListValue.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Value {
|
||||
return {
|
||||
nullValue: isSet(object.nullValue)
|
||||
? nullValueFromJSON(object.nullValue)
|
||||
: isSet(object.null_value)
|
||||
? nullValueFromJSON(object.null_value)
|
||||
: undefined,
|
||||
numberValue: isSet(object.numberValue)
|
||||
? globalThis.Number(object.numberValue)
|
||||
: isSet(object.number_value)
|
||||
? globalThis.Number(object.number_value)
|
||||
: undefined,
|
||||
stringValue: isSet(object.stringValue)
|
||||
? globalThis.String(object.stringValue)
|
||||
: isSet(object.string_value)
|
||||
? globalThis.String(object.string_value)
|
||||
: undefined,
|
||||
boolValue: isSet(object.boolValue)
|
||||
? globalThis.Boolean(object.boolValue)
|
||||
: isSet(object.bool_value)
|
||||
? globalThis.Boolean(object.bool_value)
|
||||
: undefined,
|
||||
structValue: isObject(object.structValue)
|
||||
? object.structValue
|
||||
: isObject(object.struct_value)
|
||||
? object.struct_value
|
||||
: undefined,
|
||||
listValue: globalThis.Array.isArray(object.listValue)
|
||||
? [...object.listValue]
|
||||
: globalThis.Array.isArray(object.list_value)
|
||||
? [...object.list_value]
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Value): unknown {
|
||||
const obj: any = {};
|
||||
if (message.nullValue !== undefined) {
|
||||
obj.nullValue = nullValueToJSON(message.nullValue);
|
||||
}
|
||||
if (message.numberValue !== undefined) {
|
||||
obj.numberValue = message.numberValue;
|
||||
}
|
||||
if (message.stringValue !== undefined) {
|
||||
obj.stringValue = message.stringValue;
|
||||
}
|
||||
if (message.boolValue !== undefined) {
|
||||
obj.boolValue = message.boolValue;
|
||||
}
|
||||
if (message.structValue !== undefined) {
|
||||
obj.structValue = message.structValue;
|
||||
}
|
||||
if (message.listValue !== undefined) {
|
||||
obj.listValue = message.listValue;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Value>, I>>(base?: I): Value {
|
||||
return Value.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Value>, I>>(object: I): Value {
|
||||
const message = createBaseValue();
|
||||
message.nullValue = object.nullValue ?? undefined;
|
||||
message.numberValue = object.numberValue ?? undefined;
|
||||
message.stringValue = object.stringValue ?? undefined;
|
||||
message.boolValue = object.boolValue ?? undefined;
|
||||
message.structValue = object.structValue ?? undefined;
|
||||
message.listValue = object.listValue ?? undefined;
|
||||
return message;
|
||||
},
|
||||
|
||||
wrap(value: any): Value {
|
||||
const result = createBaseValue();
|
||||
if (value === null) {
|
||||
result.nullValue = NullValue.NULL_VALUE;
|
||||
} else if (typeof value === "boolean") {
|
||||
result.boolValue = value;
|
||||
} else if (typeof value === "number") {
|
||||
result.numberValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
result.stringValue = value;
|
||||
} else if (globalThis.Array.isArray(value)) {
|
||||
result.listValue = value;
|
||||
} else if (typeof value === "object") {
|
||||
result.structValue = value;
|
||||
} else if (typeof value !== "undefined") {
|
||||
throw new globalThis.Error("Unsupported any value type: " + typeof value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
unwrap(message: any): string | number | boolean | Object | null | Array<any> | undefined {
|
||||
if (message.stringValue !== undefined) {
|
||||
return message.stringValue;
|
||||
} else if (message?.numberValue !== undefined) {
|
||||
return message.numberValue;
|
||||
} else if (message?.boolValue !== undefined) {
|
||||
return message.boolValue;
|
||||
} else if (message?.structValue !== undefined) {
|
||||
return message.structValue as any;
|
||||
} else if (message?.listValue !== undefined) {
|
||||
return message.listValue;
|
||||
} else if (message?.nullValue !== undefined) {
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseListValue(): ListValue {
|
||||
return { values: [] };
|
||||
}
|
||||
|
||||
export const ListValue: MessageFns<ListValue> & ListValueWrapperFns = {
|
||||
encode(message: ListValue, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
for (const v of message.values) {
|
||||
Value.encode(Value.wrap(v!), writer.uint32(10).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ListValue {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseListValue();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.values.push(Value.unwrap(Value.decode(reader, reader.uint32())));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): ListValue {
|
||||
return { values: globalThis.Array.isArray(object?.values) ? [...object.values] : [] };
|
||||
},
|
||||
|
||||
toJSON(message: ListValue): unknown {
|
||||
const obj: any = {};
|
||||
if (message.values?.length) {
|
||||
obj.values = message.values;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ListValue>, I>>(base?: I): ListValue {
|
||||
return ListValue.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ListValue>, I>>(object: I): ListValue {
|
||||
const message = createBaseListValue();
|
||||
message.values = object.values?.map((e) => e) || [];
|
||||
return message;
|
||||
},
|
||||
|
||||
wrap(array: Array<any> | undefined): ListValue {
|
||||
const result = createBaseListValue();
|
||||
result.values = array ?? [];
|
||||
return result;
|
||||
},
|
||||
|
||||
unwrap(message: ListValue): Array<any> {
|
||||
if (message?.hasOwnProperty("values") && globalThis.Array.isArray(message.values)) {
|
||||
return message.values;
|
||||
} else {
|
||||
return message as any;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
|
||||
interface StructWrapperFns {
|
||||
wrap(object: { [key: string]: any } | undefined): Struct;
|
||||
unwrap(message: Struct): { [key: string]: any };
|
||||
}
|
||||
|
||||
interface AnyValueWrapperFns {
|
||||
wrap(value: any): Value;
|
||||
unwrap(message: any): string | number | boolean | Object | null | Array<any> | undefined;
|
||||
}
|
||||
|
||||
interface ListValueWrapperFns {
|
||||
wrap(array: Array<any> | undefined): ListValue;
|
||||
unwrap(message: ListValue): Array<any>;
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.8
|
||||
// protoc v3.19.1
|
||||
// source: google/protobuf/timestamp.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import Long from "long";
|
||||
|
||||
/**
|
||||
* A Timestamp represents a point in time independent of any time zone or local
|
||||
* calendar, encoded as a count of seconds and fractions of seconds at
|
||||
* nanosecond resolution. The count is relative to an epoch at UTC midnight on
|
||||
* January 1, 1970, in the proleptic Gregorian calendar which extends the
|
||||
* Gregorian calendar backwards to year one.
|
||||
*
|
||||
* All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
|
||||
* second table is needed for interpretation, using a [24-hour linear
|
||||
* smear](https://developers.google.com/time/smear).
|
||||
*
|
||||
* The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
|
||||
* restricting to that range, we ensure that we can convert to and from [RFC
|
||||
* 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
|
||||
*
|
||||
* # Examples
|
||||
*
|
||||
* Example 1: Compute Timestamp from POSIX `time()`.
|
||||
*
|
||||
* Timestamp timestamp;
|
||||
* timestamp.set_seconds(time(NULL));
|
||||
* timestamp.set_nanos(0);
|
||||
*
|
||||
* Example 2: Compute Timestamp from POSIX `gettimeofday()`.
|
||||
*
|
||||
* struct timeval tv;
|
||||
* gettimeofday(&tv, NULL);
|
||||
*
|
||||
* Timestamp timestamp;
|
||||
* timestamp.set_seconds(tv.tv_sec);
|
||||
* timestamp.set_nanos(tv.tv_usec * 1000);
|
||||
*
|
||||
* Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
|
||||
*
|
||||
* FILETIME ft;
|
||||
* GetSystemTimeAsFileTime(&ft);
|
||||
* UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
|
||||
*
|
||||
* // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
|
||||
* // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
|
||||
* Timestamp timestamp;
|
||||
* timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
|
||||
* timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
|
||||
*
|
||||
* Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
|
||||
*
|
||||
* long millis = System.currentTimeMillis();
|
||||
*
|
||||
* Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
|
||||
* .setNanos((int) ((millis % 1000) * 1000000)).build();
|
||||
*
|
||||
* Example 5: Compute Timestamp from Java `Instant.now()`.
|
||||
*
|
||||
* Instant now = Instant.now();
|
||||
*
|
||||
* Timestamp timestamp =
|
||||
* Timestamp.newBuilder().setSeconds(now.getEpochSecond())
|
||||
* .setNanos(now.getNano()).build();
|
||||
*
|
||||
* Example 6: Compute Timestamp from current time in Python.
|
||||
*
|
||||
* timestamp = Timestamp()
|
||||
* timestamp.GetCurrentTime()
|
||||
*
|
||||
* # JSON Mapping
|
||||
*
|
||||
* In JSON format, the Timestamp type is encoded as a string in the
|
||||
* [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
|
||||
* format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
|
||||
* where {year} is always expressed using four digits while {month}, {day},
|
||||
* {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
|
||||
* seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
|
||||
* are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
|
||||
* is required. A proto3 JSON serializer should always use UTC (as indicated by
|
||||
* "Z") when printing the Timestamp type and a proto3 JSON parser should be
|
||||
* able to accept both UTC and other timezones (as indicated by an offset).
|
||||
*
|
||||
* For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
|
||||
* 01:30 UTC on January 15, 2017.
|
||||
*
|
||||
* In JavaScript, one can convert a Date object to this format using the
|
||||
* standard
|
||||
* [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
|
||||
* method. In Python, a standard `datetime.datetime` object can be converted
|
||||
* to this format using
|
||||
* [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
|
||||
* the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
|
||||
* the Joda Time's [`ISODateTimeFormat.dateTime()`](
|
||||
* http://www.joda.org/joda-time/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime%2D%2D
|
||||
* ) to obtain a formatter capable of generating timestamps in this format.
|
||||
*/
|
||||
export interface Timestamp {
|
||||
/**
|
||||
* Represents seconds of UTC time since Unix epoch
|
||||
* 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
|
||||
* 9999-12-31T23:59:59Z inclusive.
|
||||
*/
|
||||
seconds: Long;
|
||||
/**
|
||||
* Non-negative fractions of a second at nanosecond resolution. Negative
|
||||
* second values with fractions must still have non-negative nanos values
|
||||
* that count forward in time. Must be from 0 to 999,999,999
|
||||
* inclusive.
|
||||
*/
|
||||
nanos: number;
|
||||
}
|
||||
|
||||
function createBaseTimestamp(): Timestamp {
|
||||
return { seconds: Long.ZERO, nanos: 0 };
|
||||
}
|
||||
|
||||
export const Timestamp: MessageFns<Timestamp> = {
|
||||
encode(message: Timestamp, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (!message.seconds.equals(Long.ZERO)) {
|
||||
writer.uint32(8).int64(message.seconds.toString());
|
||||
}
|
||||
if (message.nanos !== 0) {
|
||||
writer.uint32(16).int32(message.nanos);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Timestamp {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseTimestamp();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.seconds = Long.fromString(reader.int64().toString());
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.nanos = reader.int32();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Timestamp {
|
||||
return {
|
||||
seconds: isSet(object.seconds) ? Long.fromValue(object.seconds) : Long.ZERO,
|
||||
nanos: isSet(object.nanos) ? globalThis.Number(object.nanos) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Timestamp): unknown {
|
||||
const obj: any = {};
|
||||
if (!message.seconds.equals(Long.ZERO)) {
|
||||
obj.seconds = (message.seconds || Long.ZERO).toString();
|
||||
}
|
||||
if (message.nanos !== 0) {
|
||||
obj.nanos = Math.round(message.nanos);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Timestamp>, I>>(base?: I): Timestamp {
|
||||
return Timestamp.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Timestamp>, I>>(object: I): Timestamp {
|
||||
const message = createBaseTimestamp();
|
||||
message.seconds = (object.seconds !== undefined && object.seconds !== null)
|
||||
? Long.fromValue(object.seconds)
|
||||
: Long.ZERO;
|
||||
message.nanos = object.nanos ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
@@ -1,858 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.8
|
||||
// protoc v3.19.1
|
||||
// source: ingestion.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import {
|
||||
type CallOptions,
|
||||
type ChannelCredentials,
|
||||
Client,
|
||||
type ClientOptions,
|
||||
type ClientUnaryCall,
|
||||
type handleUnaryCall,
|
||||
makeGenericClientConstructor,
|
||||
type Metadata,
|
||||
type ServiceError,
|
||||
type UntypedServiceImplementation,
|
||||
} from "@grpc/grpc-js";
|
||||
import Long from "long";
|
||||
import { AclSpec, Principal } from "./common";
|
||||
import { Struct } from "./google/protobuf/struct";
|
||||
import { Timestamp } from "./google/protobuf/timestamp";
|
||||
|
||||
export interface IngestChunk {
|
||||
content: string;
|
||||
metadata?: { [key: string]: any } | undefined;
|
||||
acl?: AclSpec | undefined;
|
||||
}
|
||||
|
||||
export interface IngestDocumentRequest {
|
||||
source: string;
|
||||
externalId: string;
|
||||
title: string;
|
||||
chunks: IngestChunk[];
|
||||
principal?: Principal | undefined;
|
||||
}
|
||||
|
||||
export interface IngestDocumentResponse {
|
||||
documentId: string;
|
||||
chunksIngested: number;
|
||||
}
|
||||
|
||||
export interface GetDocumentRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
id: string;
|
||||
sourceUri: string;
|
||||
title: string;
|
||||
externalId: string;
|
||||
ingestedAt?: Date | undefined;
|
||||
chunkCount: number;
|
||||
}
|
||||
|
||||
export interface DeleteDocumentRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DeleteDocumentResponse {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
function createBaseIngestChunk(): IngestChunk {
|
||||
return { content: "", metadata: undefined, acl: undefined };
|
||||
}
|
||||
|
||||
export const IngestChunk: MessageFns<IngestChunk> = {
|
||||
encode(message: IngestChunk, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.content !== "") {
|
||||
writer.uint32(10).string(message.content);
|
||||
}
|
||||
if (message.metadata !== undefined) {
|
||||
Struct.encode(Struct.wrap(message.metadata), writer.uint32(18).fork()).join();
|
||||
}
|
||||
if (message.acl !== undefined) {
|
||||
AclSpec.encode(message.acl, writer.uint32(26).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): IngestChunk {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseIngestChunk();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.metadata = Struct.unwrap(Struct.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.acl = AclSpec.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): IngestChunk {
|
||||
return {
|
||||
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||
metadata: isObject(object.metadata) ? object.metadata : undefined,
|
||||
acl: isSet(object.acl) ? AclSpec.fromJSON(object.acl) : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: IngestChunk): unknown {
|
||||
const obj: any = {};
|
||||
if (message.content !== "") {
|
||||
obj.content = message.content;
|
||||
}
|
||||
if (message.metadata !== undefined) {
|
||||
obj.metadata = message.metadata;
|
||||
}
|
||||
if (message.acl !== undefined) {
|
||||
obj.acl = AclSpec.toJSON(message.acl);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<IngestChunk>, I>>(base?: I): IngestChunk {
|
||||
return IngestChunk.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<IngestChunk>, I>>(object: I): IngestChunk {
|
||||
const message = createBaseIngestChunk();
|
||||
message.content = object.content ?? "";
|
||||
message.metadata = object.metadata ?? undefined;
|
||||
message.acl = (object.acl !== undefined && object.acl !== null) ? AclSpec.fromPartial(object.acl) : undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseIngestDocumentRequest(): IngestDocumentRequest {
|
||||
return { source: "", externalId: "", title: "", chunks: [], principal: undefined };
|
||||
}
|
||||
|
||||
export const IngestDocumentRequest: MessageFns<IngestDocumentRequest> = {
|
||||
encode(message: IngestDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.source !== "") {
|
||||
writer.uint32(10).string(message.source);
|
||||
}
|
||||
if (message.externalId !== "") {
|
||||
writer.uint32(18).string(message.externalId);
|
||||
}
|
||||
if (message.title !== "") {
|
||||
writer.uint32(26).string(message.title);
|
||||
}
|
||||
for (const v of message.chunks) {
|
||||
IngestChunk.encode(v!, writer.uint32(34).fork()).join();
|
||||
}
|
||||
if (message.principal !== undefined) {
|
||||
Principal.encode(message.principal, writer.uint32(42).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): IngestDocumentRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseIngestDocumentRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.source = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.externalId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.title = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.chunks.push(IngestChunk.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.principal = Principal.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): IngestDocumentRequest {
|
||||
return {
|
||||
source: isSet(object.source) ? globalThis.String(object.source) : "",
|
||||
externalId: isSet(object.externalId)
|
||||
? globalThis.String(object.externalId)
|
||||
: isSet(object.external_id)
|
||||
? globalThis.String(object.external_id)
|
||||
: "",
|
||||
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
||||
chunks: globalThis.Array.isArray(object?.chunks) ? object.chunks.map((e: any) => IngestChunk.fromJSON(e)) : [],
|
||||
principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: IngestDocumentRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.source !== "") {
|
||||
obj.source = message.source;
|
||||
}
|
||||
if (message.externalId !== "") {
|
||||
obj.externalId = message.externalId;
|
||||
}
|
||||
if (message.title !== "") {
|
||||
obj.title = message.title;
|
||||
}
|
||||
if (message.chunks?.length) {
|
||||
obj.chunks = message.chunks.map((e) => IngestChunk.toJSON(e));
|
||||
}
|
||||
if (message.principal !== undefined) {
|
||||
obj.principal = Principal.toJSON(message.principal);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<IngestDocumentRequest>, I>>(base?: I): IngestDocumentRequest {
|
||||
return IngestDocumentRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<IngestDocumentRequest>, I>>(object: I): IngestDocumentRequest {
|
||||
const message = createBaseIngestDocumentRequest();
|
||||
message.source = object.source ?? "";
|
||||
message.externalId = object.externalId ?? "";
|
||||
message.title = object.title ?? "";
|
||||
message.chunks = object.chunks?.map((e) => IngestChunk.fromPartial(e)) || [];
|
||||
message.principal = (object.principal !== undefined && object.principal !== null)
|
||||
? Principal.fromPartial(object.principal)
|
||||
: undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseIngestDocumentResponse(): IngestDocumentResponse {
|
||||
return { documentId: "", chunksIngested: 0 };
|
||||
}
|
||||
|
||||
export const IngestDocumentResponse: MessageFns<IngestDocumentResponse> = {
|
||||
encode(message: IngestDocumentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.documentId !== "") {
|
||||
writer.uint32(10).string(message.documentId);
|
||||
}
|
||||
if (message.chunksIngested !== 0) {
|
||||
writer.uint32(16).int32(message.chunksIngested);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): IngestDocumentResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseIngestDocumentResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.documentId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.chunksIngested = reader.int32();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): IngestDocumentResponse {
|
||||
return {
|
||||
documentId: isSet(object.documentId)
|
||||
? globalThis.String(object.documentId)
|
||||
: isSet(object.document_id)
|
||||
? globalThis.String(object.document_id)
|
||||
: "",
|
||||
chunksIngested: isSet(object.chunksIngested)
|
||||
? globalThis.Number(object.chunksIngested)
|
||||
: isSet(object.chunks_ingested)
|
||||
? globalThis.Number(object.chunks_ingested)
|
||||
: 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: IngestDocumentResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.documentId !== "") {
|
||||
obj.documentId = message.documentId;
|
||||
}
|
||||
if (message.chunksIngested !== 0) {
|
||||
obj.chunksIngested = Math.round(message.chunksIngested);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<IngestDocumentResponse>, I>>(base?: I): IngestDocumentResponse {
|
||||
return IngestDocumentResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<IngestDocumentResponse>, I>>(object: I): IngestDocumentResponse {
|
||||
const message = createBaseIngestDocumentResponse();
|
||||
message.documentId = object.documentId ?? "";
|
||||
message.chunksIngested = object.chunksIngested ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseGetDocumentRequest(): GetDocumentRequest {
|
||||
return { id: "" };
|
||||
}
|
||||
|
||||
export const GetDocumentRequest: MessageFns<GetDocumentRequest> = {
|
||||
encode(message: GetDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.id !== "") {
|
||||
writer.uint32(10).string(message.id);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): GetDocumentRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseGetDocumentRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.id = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): GetDocumentRequest {
|
||||
return { id: isSet(object.id) ? globalThis.String(object.id) : "" };
|
||||
},
|
||||
|
||||
toJSON(message: GetDocumentRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.id !== "") {
|
||||
obj.id = message.id;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<GetDocumentRequest>, I>>(base?: I): GetDocumentRequest {
|
||||
return GetDocumentRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<GetDocumentRequest>, I>>(object: I): GetDocumentRequest {
|
||||
const message = createBaseGetDocumentRequest();
|
||||
message.id = object.id ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseDocumentSummary(): DocumentSummary {
|
||||
return { id: "", sourceUri: "", title: "", externalId: "", ingestedAt: undefined, chunkCount: 0 };
|
||||
}
|
||||
|
||||
export const DocumentSummary: MessageFns<DocumentSummary> = {
|
||||
encode(message: DocumentSummary, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.id !== "") {
|
||||
writer.uint32(10).string(message.id);
|
||||
}
|
||||
if (message.sourceUri !== "") {
|
||||
writer.uint32(18).string(message.sourceUri);
|
||||
}
|
||||
if (message.title !== "") {
|
||||
writer.uint32(26).string(message.title);
|
||||
}
|
||||
if (message.externalId !== "") {
|
||||
writer.uint32(34).string(message.externalId);
|
||||
}
|
||||
if (message.ingestedAt !== undefined) {
|
||||
Timestamp.encode(toTimestamp(message.ingestedAt), writer.uint32(42).fork()).join();
|
||||
}
|
||||
if (message.chunkCount !== 0) {
|
||||
writer.uint32(48).int32(message.chunkCount);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): DocumentSummary {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseDocumentSummary();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.id = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.sourceUri = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.title = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.externalId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.ingestedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 48) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.chunkCount = reader.int32();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): DocumentSummary {
|
||||
return {
|
||||
id: isSet(object.id) ? globalThis.String(object.id) : "",
|
||||
sourceUri: isSet(object.sourceUri)
|
||||
? globalThis.String(object.sourceUri)
|
||||
: isSet(object.source_uri)
|
||||
? globalThis.String(object.source_uri)
|
||||
: "",
|
||||
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
||||
externalId: isSet(object.externalId)
|
||||
? globalThis.String(object.externalId)
|
||||
: isSet(object.external_id)
|
||||
? globalThis.String(object.external_id)
|
||||
: "",
|
||||
ingestedAt: isSet(object.ingestedAt)
|
||||
? fromJsonTimestamp(object.ingestedAt)
|
||||
: isSet(object.ingested_at)
|
||||
? fromJsonTimestamp(object.ingested_at)
|
||||
: undefined,
|
||||
chunkCount: isSet(object.chunkCount)
|
||||
? globalThis.Number(object.chunkCount)
|
||||
: isSet(object.chunk_count)
|
||||
? globalThis.Number(object.chunk_count)
|
||||
: 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: DocumentSummary): unknown {
|
||||
const obj: any = {};
|
||||
if (message.id !== "") {
|
||||
obj.id = message.id;
|
||||
}
|
||||
if (message.sourceUri !== "") {
|
||||
obj.sourceUri = message.sourceUri;
|
||||
}
|
||||
if (message.title !== "") {
|
||||
obj.title = message.title;
|
||||
}
|
||||
if (message.externalId !== "") {
|
||||
obj.externalId = message.externalId;
|
||||
}
|
||||
if (message.ingestedAt !== undefined) {
|
||||
obj.ingestedAt = message.ingestedAt.toISOString();
|
||||
}
|
||||
if (message.chunkCount !== 0) {
|
||||
obj.chunkCount = Math.round(message.chunkCount);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DocumentSummary>, I>>(base?: I): DocumentSummary {
|
||||
return DocumentSummary.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<DocumentSummary>, I>>(object: I): DocumentSummary {
|
||||
const message = createBaseDocumentSummary();
|
||||
message.id = object.id ?? "";
|
||||
message.sourceUri = object.sourceUri ?? "";
|
||||
message.title = object.title ?? "";
|
||||
message.externalId = object.externalId ?? "";
|
||||
message.ingestedAt = object.ingestedAt ?? undefined;
|
||||
message.chunkCount = object.chunkCount ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseDeleteDocumentRequest(): DeleteDocumentRequest {
|
||||
return { id: "" };
|
||||
}
|
||||
|
||||
export const DeleteDocumentRequest: MessageFns<DeleteDocumentRequest> = {
|
||||
encode(message: DeleteDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.id !== "") {
|
||||
writer.uint32(10).string(message.id);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): DeleteDocumentRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseDeleteDocumentRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.id = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): DeleteDocumentRequest {
|
||||
return { id: isSet(object.id) ? globalThis.String(object.id) : "" };
|
||||
},
|
||||
|
||||
toJSON(message: DeleteDocumentRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.id !== "") {
|
||||
obj.id = message.id;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DeleteDocumentRequest>, I>>(base?: I): DeleteDocumentRequest {
|
||||
return DeleteDocumentRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<DeleteDocumentRequest>, I>>(object: I): DeleteDocumentRequest {
|
||||
const message = createBaseDeleteDocumentRequest();
|
||||
message.id = object.id ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseDeleteDocumentResponse(): DeleteDocumentResponse {
|
||||
return { deleted: false };
|
||||
}
|
||||
|
||||
export const DeleteDocumentResponse: MessageFns<DeleteDocumentResponse> = {
|
||||
encode(message: DeleteDocumentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.deleted !== false) {
|
||||
writer.uint32(8).bool(message.deleted);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): DeleteDocumentResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseDeleteDocumentResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.deleted = reader.bool();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): DeleteDocumentResponse {
|
||||
return { deleted: isSet(object.deleted) ? globalThis.Boolean(object.deleted) : false };
|
||||
},
|
||||
|
||||
toJSON(message: DeleteDocumentResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.deleted !== false) {
|
||||
obj.deleted = message.deleted;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DeleteDocumentResponse>, I>>(base?: I): DeleteDocumentResponse {
|
||||
return DeleteDocumentResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<DeleteDocumentResponse>, I>>(object: I): DeleteDocumentResponse {
|
||||
const message = createBaseDeleteDocumentResponse();
|
||||
message.deleted = object.deleted ?? false;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Document ingestion. Every chunk must carry an explicit ACL: empty
|
||||
* ACL is default-deny and rejected with InvalidArgument (gRPC) /
|
||||
* 422 (HTTP).
|
||||
*/
|
||||
export type IngestionServiceService = typeof IngestionServiceService;
|
||||
export const IngestionServiceService = {
|
||||
ingestDocument: {
|
||||
path: "/apf.ai.v1.IngestionService/IngestDocument" as const,
|
||||
requestStream: false as const,
|
||||
responseStream: false as const,
|
||||
requestSerialize: (value: IngestDocumentRequest): Buffer =>
|
||||
Buffer.from(IngestDocumentRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): IngestDocumentRequest => IngestDocumentRequest.decode(value),
|
||||
responseSerialize: (value: IngestDocumentResponse): Buffer =>
|
||||
Buffer.from(IngestDocumentResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): IngestDocumentResponse => IngestDocumentResponse.decode(value),
|
||||
},
|
||||
getDocument: {
|
||||
path: "/apf.ai.v1.IngestionService/GetDocument" as const,
|
||||
requestStream: false as const,
|
||||
responseStream: false as const,
|
||||
requestSerialize: (value: GetDocumentRequest): Buffer => Buffer.from(GetDocumentRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): GetDocumentRequest => GetDocumentRequest.decode(value),
|
||||
responseSerialize: (value: DocumentSummary): Buffer => Buffer.from(DocumentSummary.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): DocumentSummary => DocumentSummary.decode(value),
|
||||
},
|
||||
deleteDocument: {
|
||||
path: "/apf.ai.v1.IngestionService/DeleteDocument" as const,
|
||||
requestStream: false as const,
|
||||
responseStream: false as const,
|
||||
requestSerialize: (value: DeleteDocumentRequest): Buffer =>
|
||||
Buffer.from(DeleteDocumentRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): DeleteDocumentRequest => DeleteDocumentRequest.decode(value),
|
||||
responseSerialize: (value: DeleteDocumentResponse): Buffer =>
|
||||
Buffer.from(DeleteDocumentResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): DeleteDocumentResponse => DeleteDocumentResponse.decode(value),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface IngestionServiceServer extends UntypedServiceImplementation {
|
||||
ingestDocument: handleUnaryCall<IngestDocumentRequest, IngestDocumentResponse>;
|
||||
getDocument: handleUnaryCall<GetDocumentRequest, DocumentSummary>;
|
||||
deleteDocument: handleUnaryCall<DeleteDocumentRequest, DeleteDocumentResponse>;
|
||||
}
|
||||
|
||||
export interface IngestionServiceClient extends Client {
|
||||
ingestDocument(
|
||||
request: IngestDocumentRequest,
|
||||
callback: (error: ServiceError | null, response: IngestDocumentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
ingestDocument(
|
||||
request: IngestDocumentRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: IngestDocumentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
ingestDocument(
|
||||
request: IngestDocumentRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: IngestDocumentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
getDocument(
|
||||
request: GetDocumentRequest,
|
||||
callback: (error: ServiceError | null, response: DocumentSummary) => void,
|
||||
): ClientUnaryCall;
|
||||
getDocument(
|
||||
request: GetDocumentRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: DocumentSummary) => void,
|
||||
): ClientUnaryCall;
|
||||
getDocument(
|
||||
request: GetDocumentRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: DocumentSummary) => void,
|
||||
): ClientUnaryCall;
|
||||
deleteDocument(
|
||||
request: DeleteDocumentRequest,
|
||||
callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
deleteDocument(
|
||||
request: DeleteDocumentRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
deleteDocument(
|
||||
request: DeleteDocumentRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
}
|
||||
|
||||
export const IngestionServiceClient = makeGenericClientConstructor(
|
||||
IngestionServiceService,
|
||||
"apf.ai.v1.IngestionService",
|
||||
) as unknown as {
|
||||
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): IngestionServiceClient;
|
||||
service: typeof IngestionServiceService;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function toTimestamp(date: Date): Timestamp {
|
||||
const seconds = numberToLong(Math.trunc(date.getTime() / 1_000));
|
||||
const nanos = (date.getTime() % 1_000) * 1_000_000;
|
||||
return { seconds, nanos };
|
||||
}
|
||||
|
||||
function fromTimestamp(t: Timestamp): Date {
|
||||
let millis = (t.seconds.toNumber() || 0) * 1_000;
|
||||
millis += (t.nanos || 0) / 1_000_000;
|
||||
return new globalThis.Date(millis);
|
||||
}
|
||||
|
||||
function fromJsonTimestamp(o: any): Date {
|
||||
if (o instanceof globalThis.Date) {
|
||||
return o;
|
||||
} else if (typeof o === "string") {
|
||||
return new globalThis.Date(o);
|
||||
} else {
|
||||
return fromTimestamp(Timestamp.fromJSON(o));
|
||||
}
|
||||
}
|
||||
|
||||
function numberToLong(number: number) {
|
||||
return Long.fromNumber(number);
|
||||
}
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.8
|
||||
// protoc v3.19.1
|
||||
// source: models.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import {
|
||||
type CallOptions,
|
||||
type ChannelCredentials,
|
||||
Client,
|
||||
type ClientOptions,
|
||||
type ClientUnaryCall,
|
||||
type handleUnaryCall,
|
||||
makeGenericClientConstructor,
|
||||
type Metadata,
|
||||
type ServiceError,
|
||||
type UntypedServiceImplementation,
|
||||
} from "@grpc/grpc-js";
|
||||
import Long from "long";
|
||||
|
||||
export interface ListModelsRequest {
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
discriminator: string;
|
||||
capabilities: string;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
embeddingModel: string;
|
||||
}
|
||||
|
||||
export interface ListModelsResponse {
|
||||
active: string;
|
||||
providers: ProviderInfo[];
|
||||
}
|
||||
|
||||
function createBaseListModelsRequest(): ListModelsRequest {
|
||||
return {};
|
||||
}
|
||||
|
||||
export const ListModelsRequest: MessageFns<ListModelsRequest> = {
|
||||
encode(_: ListModelsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ListModelsRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseListModelsRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(_: any): ListModelsRequest {
|
||||
return {};
|
||||
},
|
||||
|
||||
toJSON(_: ListModelsRequest): unknown {
|
||||
const obj: any = {};
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ListModelsRequest>, I>>(base?: I): ListModelsRequest {
|
||||
return ListModelsRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ListModelsRequest>, I>>(_: I): ListModelsRequest {
|
||||
const message = createBaseListModelsRequest();
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseProviderInfo(): ProviderInfo {
|
||||
return { discriminator: "", capabilities: "", endpoint: "", model: "", embeddingModel: "" };
|
||||
}
|
||||
|
||||
export const ProviderInfo: MessageFns<ProviderInfo> = {
|
||||
encode(message: ProviderInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.discriminator !== "") {
|
||||
writer.uint32(10).string(message.discriminator);
|
||||
}
|
||||
if (message.capabilities !== "") {
|
||||
writer.uint32(18).string(message.capabilities);
|
||||
}
|
||||
if (message.endpoint !== "") {
|
||||
writer.uint32(26).string(message.endpoint);
|
||||
}
|
||||
if (message.model !== "") {
|
||||
writer.uint32(34).string(message.model);
|
||||
}
|
||||
if (message.embeddingModel !== "") {
|
||||
writer.uint32(42).string(message.embeddingModel);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ProviderInfo {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseProviderInfo();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.discriminator = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.capabilities = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.endpoint = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.model = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.embeddingModel = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): ProviderInfo {
|
||||
return {
|
||||
discriminator: isSet(object.discriminator) ? globalThis.String(object.discriminator) : "",
|
||||
capabilities: isSet(object.capabilities) ? globalThis.String(object.capabilities) : "",
|
||||
endpoint: isSet(object.endpoint) ? globalThis.String(object.endpoint) : "",
|
||||
model: isSet(object.model) ? globalThis.String(object.model) : "",
|
||||
embeddingModel: isSet(object.embeddingModel)
|
||||
? globalThis.String(object.embeddingModel)
|
||||
: isSet(object.embedding_model)
|
||||
? globalThis.String(object.embedding_model)
|
||||
: "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: ProviderInfo): unknown {
|
||||
const obj: any = {};
|
||||
if (message.discriminator !== "") {
|
||||
obj.discriminator = message.discriminator;
|
||||
}
|
||||
if (message.capabilities !== "") {
|
||||
obj.capabilities = message.capabilities;
|
||||
}
|
||||
if (message.endpoint !== "") {
|
||||
obj.endpoint = message.endpoint;
|
||||
}
|
||||
if (message.model !== "") {
|
||||
obj.model = message.model;
|
||||
}
|
||||
if (message.embeddingModel !== "") {
|
||||
obj.embeddingModel = message.embeddingModel;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ProviderInfo>, I>>(base?: I): ProviderInfo {
|
||||
return ProviderInfo.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ProviderInfo>, I>>(object: I): ProviderInfo {
|
||||
const message = createBaseProviderInfo();
|
||||
message.discriminator = object.discriminator ?? "";
|
||||
message.capabilities = object.capabilities ?? "";
|
||||
message.endpoint = object.endpoint ?? "";
|
||||
message.model = object.model ?? "";
|
||||
message.embeddingModel = object.embeddingModel ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseListModelsResponse(): ListModelsResponse {
|
||||
return { active: "", providers: [] };
|
||||
}
|
||||
|
||||
export const ListModelsResponse: MessageFns<ListModelsResponse> = {
|
||||
encode(message: ListModelsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.active !== "") {
|
||||
writer.uint32(10).string(message.active);
|
||||
}
|
||||
for (const v of message.providers) {
|
||||
ProviderInfo.encode(v!, writer.uint32(18).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ListModelsResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseListModelsResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.active = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.providers.push(ProviderInfo.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): ListModelsResponse {
|
||||
return {
|
||||
active: isSet(object.active) ? globalThis.String(object.active) : "",
|
||||
providers: globalThis.Array.isArray(object?.providers)
|
||||
? object.providers.map((e: any) => ProviderInfo.fromJSON(e))
|
||||
: [],
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: ListModelsResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.active !== "") {
|
||||
obj.active = message.active;
|
||||
}
|
||||
if (message.providers?.length) {
|
||||
obj.providers = message.providers.map((e) => ProviderInfo.toJSON(e));
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ListModelsResponse>, I>>(base?: I): ListModelsResponse {
|
||||
return ListModelsResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ListModelsResponse>, I>>(object: I): ListModelsResponse {
|
||||
const message = createBaseListModelsResponse();
|
||||
message.active = object.active ?? "";
|
||||
message.providers = object.providers?.map((e) => ProviderInfo.fromPartial(e)) || [];
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export type ModelsServiceService = typeof ModelsServiceService;
|
||||
export const ModelsServiceService = {
|
||||
listModels: {
|
||||
path: "/apf.ai.v1.ModelsService/ListModels" as const,
|
||||
requestStream: false as const,
|
||||
responseStream: false as const,
|
||||
requestSerialize: (value: ListModelsRequest): Buffer => Buffer.from(ListModelsRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): ListModelsRequest => ListModelsRequest.decode(value),
|
||||
responseSerialize: (value: ListModelsResponse): Buffer => Buffer.from(ListModelsResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): ListModelsResponse => ListModelsResponse.decode(value),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ModelsServiceServer extends UntypedServiceImplementation {
|
||||
listModels: handleUnaryCall<ListModelsRequest, ListModelsResponse>;
|
||||
}
|
||||
|
||||
export interface ModelsServiceClient extends Client {
|
||||
listModels(
|
||||
request: ListModelsRequest,
|
||||
callback: (error: ServiceError | null, response: ListModelsResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
listModels(
|
||||
request: ListModelsRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: ListModelsResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
listModels(
|
||||
request: ListModelsRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: ListModelsResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
}
|
||||
|
||||
export const ModelsServiceClient = makeGenericClientConstructor(
|
||||
ModelsServiceService,
|
||||
"apf.ai.v1.ModelsService",
|
||||
) as unknown as {
|
||||
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): ModelsServiceClient;
|
||||
service: typeof ModelsServiceService;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.8
|
||||
// protoc v3.19.1
|
||||
// source: rag.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import {
|
||||
type CallOptions,
|
||||
type ChannelCredentials,
|
||||
Client,
|
||||
type ClientOptions,
|
||||
type ClientUnaryCall,
|
||||
type handleUnaryCall,
|
||||
makeGenericClientConstructor,
|
||||
type Metadata,
|
||||
type ServiceError,
|
||||
type UntypedServiceImplementation,
|
||||
} from "@grpc/grpc-js";
|
||||
import Long from "long";
|
||||
import { Principal } from "./common";
|
||||
import { Struct } from "./google/protobuf/struct";
|
||||
|
||||
export interface RagSearchFilters {
|
||||
source: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export interface RagSearchRequest {
|
||||
query: string;
|
||||
topK: number;
|
||||
filters?: RagSearchFilters | undefined;
|
||||
principal?: Principal | undefined;
|
||||
}
|
||||
|
||||
export interface Chunk {
|
||||
id: string;
|
||||
documentId: string;
|
||||
content: string;
|
||||
source: string;
|
||||
score: number;
|
||||
metadata?: { [key: string]: any } | undefined;
|
||||
}
|
||||
|
||||
export interface RagSearchResponse {
|
||||
chunks: Chunk[];
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
function createBaseRagSearchFilters(): RagSearchFilters {
|
||||
return { source: "", documentId: "" };
|
||||
}
|
||||
|
||||
export const RagSearchFilters: MessageFns<RagSearchFilters> = {
|
||||
encode(message: RagSearchFilters, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.source !== "") {
|
||||
writer.uint32(10).string(message.source);
|
||||
}
|
||||
if (message.documentId !== "") {
|
||||
writer.uint32(18).string(message.documentId);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): RagSearchFilters {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseRagSearchFilters();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.source = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.documentId = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): RagSearchFilters {
|
||||
return {
|
||||
source: isSet(object.source) ? globalThis.String(object.source) : "",
|
||||
documentId: isSet(object.documentId)
|
||||
? globalThis.String(object.documentId)
|
||||
: isSet(object.document_id)
|
||||
? globalThis.String(object.document_id)
|
||||
: "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: RagSearchFilters): unknown {
|
||||
const obj: any = {};
|
||||
if (message.source !== "") {
|
||||
obj.source = message.source;
|
||||
}
|
||||
if (message.documentId !== "") {
|
||||
obj.documentId = message.documentId;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<RagSearchFilters>, I>>(base?: I): RagSearchFilters {
|
||||
return RagSearchFilters.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<RagSearchFilters>, I>>(object: I): RagSearchFilters {
|
||||
const message = createBaseRagSearchFilters();
|
||||
message.source = object.source ?? "";
|
||||
message.documentId = object.documentId ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseRagSearchRequest(): RagSearchRequest {
|
||||
return { query: "", topK: 0, filters: undefined, principal: undefined };
|
||||
}
|
||||
|
||||
export const RagSearchRequest: MessageFns<RagSearchRequest> = {
|
||||
encode(message: RagSearchRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.query !== "") {
|
||||
writer.uint32(10).string(message.query);
|
||||
}
|
||||
if (message.topK !== 0) {
|
||||
writer.uint32(16).int32(message.topK);
|
||||
}
|
||||
if (message.filters !== undefined) {
|
||||
RagSearchFilters.encode(message.filters, writer.uint32(26).fork()).join();
|
||||
}
|
||||
if (message.principal !== undefined) {
|
||||
Principal.encode(message.principal, writer.uint32(34).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): RagSearchRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseRagSearchRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.query = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.topK = reader.int32();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.filters = RagSearchFilters.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.principal = Principal.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): RagSearchRequest {
|
||||
return {
|
||||
query: isSet(object.query) ? globalThis.String(object.query) : "",
|
||||
topK: isSet(object.topK)
|
||||
? globalThis.Number(object.topK)
|
||||
: isSet(object.top_k)
|
||||
? globalThis.Number(object.top_k)
|
||||
: 0,
|
||||
filters: isSet(object.filters) ? RagSearchFilters.fromJSON(object.filters) : undefined,
|
||||
principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: RagSearchRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.query !== "") {
|
||||
obj.query = message.query;
|
||||
}
|
||||
if (message.topK !== 0) {
|
||||
obj.topK = Math.round(message.topK);
|
||||
}
|
||||
if (message.filters !== undefined) {
|
||||
obj.filters = RagSearchFilters.toJSON(message.filters);
|
||||
}
|
||||
if (message.principal !== undefined) {
|
||||
obj.principal = Principal.toJSON(message.principal);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<RagSearchRequest>, I>>(base?: I): RagSearchRequest {
|
||||
return RagSearchRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<RagSearchRequest>, I>>(object: I): RagSearchRequest {
|
||||
const message = createBaseRagSearchRequest();
|
||||
message.query = object.query ?? "";
|
||||
message.topK = object.topK ?? 0;
|
||||
message.filters = (object.filters !== undefined && object.filters !== null)
|
||||
? RagSearchFilters.fromPartial(object.filters)
|
||||
: undefined;
|
||||
message.principal = (object.principal !== undefined && object.principal !== null)
|
||||
? Principal.fromPartial(object.principal)
|
||||
: undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseChunk(): Chunk {
|
||||
return { id: "", documentId: "", content: "", source: "", score: 0, metadata: undefined };
|
||||
}
|
||||
|
||||
export const Chunk: MessageFns<Chunk> = {
|
||||
encode(message: Chunk, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.id !== "") {
|
||||
writer.uint32(10).string(message.id);
|
||||
}
|
||||
if (message.documentId !== "") {
|
||||
writer.uint32(18).string(message.documentId);
|
||||
}
|
||||
if (message.content !== "") {
|
||||
writer.uint32(26).string(message.content);
|
||||
}
|
||||
if (message.source !== "") {
|
||||
writer.uint32(34).string(message.source);
|
||||
}
|
||||
if (message.score !== 0) {
|
||||
writer.uint32(41).double(message.score);
|
||||
}
|
||||
if (message.metadata !== undefined) {
|
||||
Struct.encode(Struct.wrap(message.metadata), writer.uint32(50).fork()).join();
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Chunk {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseChunk();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.id = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.documentId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.source = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 41) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.score = reader.double();
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 50) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.metadata = Struct.unwrap(Struct.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Chunk {
|
||||
return {
|
||||
id: isSet(object.id) ? globalThis.String(object.id) : "",
|
||||
documentId: isSet(object.documentId)
|
||||
? globalThis.String(object.documentId)
|
||||
: isSet(object.document_id)
|
||||
? globalThis.String(object.document_id)
|
||||
: "",
|
||||
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||
source: isSet(object.source) ? globalThis.String(object.source) : "",
|
||||
score: isSet(object.score) ? globalThis.Number(object.score) : 0,
|
||||
metadata: isObject(object.metadata) ? object.metadata : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Chunk): unknown {
|
||||
const obj: any = {};
|
||||
if (message.id !== "") {
|
||||
obj.id = message.id;
|
||||
}
|
||||
if (message.documentId !== "") {
|
||||
obj.documentId = message.documentId;
|
||||
}
|
||||
if (message.content !== "") {
|
||||
obj.content = message.content;
|
||||
}
|
||||
if (message.source !== "") {
|
||||
obj.source = message.source;
|
||||
}
|
||||
if (message.score !== 0) {
|
||||
obj.score = message.score;
|
||||
}
|
||||
if (message.metadata !== undefined) {
|
||||
obj.metadata = message.metadata;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Chunk>, I>>(base?: I): Chunk {
|
||||
return Chunk.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Chunk>, I>>(object: I): Chunk {
|
||||
const message = createBaseChunk();
|
||||
message.id = object.id ?? "";
|
||||
message.documentId = object.documentId ?? "";
|
||||
message.content = object.content ?? "";
|
||||
message.source = object.source ?? "";
|
||||
message.score = object.score ?? 0;
|
||||
message.metadata = object.metadata ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseRagSearchResponse(): RagSearchResponse {
|
||||
return { chunks: [], correlationId: "" };
|
||||
}
|
||||
|
||||
export const RagSearchResponse: MessageFns<RagSearchResponse> = {
|
||||
encode(message: RagSearchResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
for (const v of message.chunks) {
|
||||
Chunk.encode(v!, writer.uint32(10).fork()).join();
|
||||
}
|
||||
if (message.correlationId !== "") {
|
||||
writer.uint32(18).string(message.correlationId);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): RagSearchResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseRagSearchResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.chunks.push(Chunk.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.correlationId = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): RagSearchResponse {
|
||||
return {
|
||||
chunks: globalThis.Array.isArray(object?.chunks) ? object.chunks.map((e: any) => Chunk.fromJSON(e)) : [],
|
||||
correlationId: isSet(object.correlationId)
|
||||
? globalThis.String(object.correlationId)
|
||||
: isSet(object.correlation_id)
|
||||
? globalThis.String(object.correlation_id)
|
||||
: "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: RagSearchResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.chunks?.length) {
|
||||
obj.chunks = message.chunks.map((e) => Chunk.toJSON(e));
|
||||
}
|
||||
if (message.correlationId !== "") {
|
||||
obj.correlationId = message.correlationId;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<RagSearchResponse>, I>>(base?: I): RagSearchResponse {
|
||||
return RagSearchResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<RagSearchResponse>, I>>(object: I): RagSearchResponse {
|
||||
const message = createBaseRagSearchResponse();
|
||||
message.chunks = object.chunks?.map((e) => Chunk.fromPartial(e)) || [];
|
||||
message.correlationId = object.correlationId ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stateless retrieval. The server runs a single SQL query that combines
|
||||
* vector similarity with the principal's ACL clause — there is no
|
||||
* retrieval path that bypasses the ACL filter.
|
||||
*/
|
||||
export type RagServiceService = typeof RagServiceService;
|
||||
export const RagServiceService = {
|
||||
search: {
|
||||
path: "/apf.ai.v1.RagService/Search" as const,
|
||||
requestStream: false as const,
|
||||
responseStream: false as const,
|
||||
requestSerialize: (value: RagSearchRequest): Buffer => Buffer.from(RagSearchRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): RagSearchRequest => RagSearchRequest.decode(value),
|
||||
responseSerialize: (value: RagSearchResponse): Buffer => Buffer.from(RagSearchResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): RagSearchResponse => RagSearchResponse.decode(value),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface RagServiceServer extends UntypedServiceImplementation {
|
||||
search: handleUnaryCall<RagSearchRequest, RagSearchResponse>;
|
||||
}
|
||||
|
||||
export interface RagServiceClient extends Client {
|
||||
search(
|
||||
request: RagSearchRequest,
|
||||
callback: (error: ServiceError | null, response: RagSearchResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
search(
|
||||
request: RagSearchRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: RagSearchResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
search(
|
||||
request: RagSearchRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: RagSearchResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
}
|
||||
|
||||
export const RagServiceClient = makeGenericClientConstructor(RagServiceService, "apf.ai.v1.RagService") as unknown as {
|
||||
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): RagServiceClient;
|
||||
service: typeof RagServiceService;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package apf.ai.v1;
|
||||
|
||||
import "common.proto";
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
option csharp_namespace = "Apf.Ai.Grpc.V1";
|
||||
|
||||
// Streaming chat. Server returns a stream of ChatEvent; one event per
|
||||
// model delta, citation, agent step, tool-call request, error, or done.
|
||||
// Conversation state is NOT persisted — the caller sends the full
|
||||
// message history every call. `conversation_id` exists only for audit
|
||||
// correlation.
|
||||
service ChatService {
|
||||
rpc Chat(ChatRequest) returns (stream ChatEvent);
|
||||
}
|
||||
|
||||
message RagOptions {
|
||||
bool enabled = 1;
|
||||
int32 top_k = 2; // 0 = use server default
|
||||
}
|
||||
|
||||
message ChatRequest {
|
||||
repeated ChatMessage messages = 1;
|
||||
string conversation_id = 2;
|
||||
string model = 3;
|
||||
string provider = 4;
|
||||
repeated ToolDescriptor tools_available = 5;
|
||||
RagOptions rag = 6;
|
||||
// Optional for JWT auth (overridden by JWT claims). Required for
|
||||
// API-key auth — but API-key callers should use the HTTP surface.
|
||||
Principal principal = 7;
|
||||
}
|
||||
|
||||
message TokenEvent {
|
||||
string token = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message CitationEvent {
|
||||
string chunk_id = 1;
|
||||
string document_id = 2;
|
||||
string source = 3;
|
||||
double score = 4;
|
||||
string snippet = 5;
|
||||
}
|
||||
|
||||
message AgentStepEvent {
|
||||
string agent = 1;
|
||||
string step = 2;
|
||||
string step_id = 3;
|
||||
}
|
||||
|
||||
message ToolCallEvent {
|
||||
string call_id = 1;
|
||||
string name = 2;
|
||||
google.protobuf.Struct args = 3;
|
||||
}
|
||||
|
||||
message ErrorEvent {
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
bool retriable = 3;
|
||||
}
|
||||
|
||||
message DoneStats {
|
||||
int32 tokens_in = 1;
|
||||
int32 tokens_out = 2;
|
||||
int32 chunks_retrieved = 3;
|
||||
}
|
||||
|
||||
message DoneEvent {
|
||||
DoneStats stats = 1;
|
||||
}
|
||||
|
||||
// Polymorphic stream payload. Mirrors the SSE shapes from the OpenAPI
|
||||
// surface (kept side-by-side for third-party callers). gRPC's `oneof`
|
||||
// gives us a tagged union without the SSE JSON discriminator.
|
||||
message ChatEvent {
|
||||
oneof event {
|
||||
TokenEvent token = 1;
|
||||
CitationEvent citation = 2;
|
||||
AgentStepEvent agent_step = 3;
|
||||
ToolCallEvent tool_call = 4;
|
||||
ErrorEvent error = 5;
|
||||
DoneEvent done = 6;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package apf.ai.v1;
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
option csharp_namespace = "Apf.Ai.Grpc.V1";
|
||||
|
||||
// Identity-bearing principal shipped with every request (carried in the JWT for
|
||||
// internal callers, or in the request body for API-key callers — same shape).
|
||||
message Principal {
|
||||
string subject = 1;
|
||||
repeated string roles = 2;
|
||||
map<string, string> attributes = 3;
|
||||
}
|
||||
|
||||
// Default-deny ACL attached to every ingested chunk.
|
||||
// At least one of allowed_roles or allowed_subjects must be non-empty,
|
||||
// enforced at the controller, the DB CHECK constraint, and the post-filter.
|
||||
message AclSpec {
|
||||
repeated string allowed_roles = 1;
|
||||
repeated string allowed_subjects = 2;
|
||||
repeated string denied_roles = 3;
|
||||
map<string, string> required_attributes = 4;
|
||||
}
|
||||
|
||||
enum ChatRole {
|
||||
CHAT_ROLE_UNSPECIFIED = 0;
|
||||
CHAT_ROLE_SYSTEM = 1;
|
||||
CHAT_ROLE_USER = 2;
|
||||
CHAT_ROLE_ASSISTANT = 3;
|
||||
CHAT_ROLE_TOOL = 4;
|
||||
}
|
||||
|
||||
message ChatMessage {
|
||||
ChatRole role = 1;
|
||||
string content = 2;
|
||||
string tool_call_id = 3;
|
||||
string name = 4;
|
||||
}
|
||||
|
||||
// JSON-schema descriptor for caller-side tool execution. The AI service is
|
||||
// tool-blind: it emits ToolCall events; the caller executes and posts back.
|
||||
message ToolDescriptor {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
google.protobuf.Struct schema = 3;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user