diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example index d1021d2..6fdacb7 100644 --- a/.cargo/config.toml.example +++ b/.cargo/config.toml.example @@ -25,4 +25,3 @@ dpp-plugin-traits = { path = "../dpp-core/crates/dpp-plugin-traits" } dpp-registry = { path = "../dpp-core/crates/dpp-registry" } dpp-calc = { path = "../dpp-core/crates/dpp-calc" } dpp-rules = { path = "../dpp-core/crates/dpp-rules" } -dpp-evidence = { path = "../dpp-core/crates/dpp-evidence" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8625d11..4596229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,45 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): **minor** bump may contain breaking changes, each listed below under a **Breaking** heading with a migration note. -## [Unreleased] +## [0.6.0] - 2026-07-10 + +### Added + +- Evidence dossiers are now persisted: migration `0021_evidence_dossier.sql` + adds an append-only `odal.evidence_dossier` table, backed by + `PgEvidenceDossierRepo`. +- New evidence endpoints: `POST /api/v1/dpp/{dppId}/evidence` generates and + stores a dossier; `GET /api/v1/evidence/{id}` fetches one; `POST + /api/v1/evidence/{id}/verify` verifies a stored dossier; `POST + /api/v1/evidence/verify` verifies an uploaded dossier document. +- `odal verify ` now verifies against the node instead of + reading a local file only — same exit-code convention (0 verified, 1 + tamper, 2 unreadable/unparseable/unreachable). + +### Changed + +- The evidence dossier wire format (`DossierV1`, `DossierManifest`, + `SignedLayer`) and the audit-trail wire type (`AuditEntry`) are now defined + in this repo's `dpp-types` crate. The verification engine (signature, + hash-chain, and transfer-chain checks) now lives in `dpp-vault`'s + `domain::verify` module and verifies JWS signatures via `dpp-crypto` + directly. +- `odal passport evidence ` now generates and stores a dossier (`POST`) + instead of exporting one on the fly (`GET`). + +### Removed + +- The `dpp-evidence` crate dependency. Its dossier format and verification + engine are dissolved into this repository (see Changed); the crate itself + was removed from `dpp-core` and its crates.io release deleted. + +### Breaking + +- `GET /api/v1/dpp/{dppId}/evidence` now returns stored-dossier summaries + instead of assembling a dossier on the fly. To get a dossier document, + `POST` to the same path first, then `GET /api/v1/evidence/{id}`. +- `odal verify` requires a reachable node; it no longer verifies a local + file with zero network. ## [0.5.0] - 2026-07-08 diff --git a/CLAUDE.md b/CLAUDE.md index d34f766..fcab46b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ When editing existing code: When your changes create orphans: - Remove imports/variables/functions that YOUR changes made unused. + - Don't remove pre-existing dead code unless asked. The test: Every changed line should trace directly to the user's request. @@ -268,6 +269,11 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | POST | `/vault/api/v1/dpp/{dppId}/suspend` | Bearer | Suspend | | POST | `/vault/api/v1/dpp/{dppId}/archive` | Bearer | Archive | | GET | `/vault/api/v1/dpp/{dppId}/history` | Bearer | Audit trail | +| POST | `/vault/api/v1/dpp/{dppId}/evidence` | Bearer | Generate + store an evidence dossier | +| GET | `/vault/api/v1/dpp/{dppId}/evidence` | Bearer | List stored dossier summaries | +| GET | `/vault/api/v1/evidence/{id}` | Bearer | Fetch a stored dossier document | +| POST | `/vault/api/v1/evidence/{id}/verify` | Bearer | Verify a stored dossier | +| POST | `/vault/api/v1/evidence/verify` | Bearer | Verify an uploaded dossier document | | GET | `/vault/api/v1/node/state` | Bearer | Node setup state (claimed / configured) | | GET | `/vault/api/v1/operator` | Bearer | Get operator config | | PATCH | `/vault/api/v1/operator` | Bearer | Update operator branding | diff --git a/Cargo.toml b/Cargo.toml index 8b2aa26..dbfe83f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ ] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" authors = ["Odal Node "] license = "BSL-1.1" @@ -40,7 +40,6 @@ dpp-plugin-traits = "0.7.0" dpp-registry = "0.7.0" dpp-calc = "0.7.0" dpp-rules = { version = "0.7.0", features = ["bundle"] } -dpp-evidence = "0.7.0" # Web framework axum = { version = "0.8", features = ["macros", "multipart"] } diff --git a/README.md b/README.md index fe2903b..779a01a 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,14 @@ The engine ships as a **single binary** (`dpp-node`) that fuses all services und |---|---|---| | `dpp-types` | lib | Platform-wide types — operator config, auth, audit, API keys | | `dpp-dal` | lib | PostgreSQL DAL — passport repo, migrations (single-tenant; no RLS) | -| `dpp-vault` | bin+lib | DPP write engine — create, versioned lifecycle (publish / suspend / archive / end-of-life), transfer-of-responsibility handshake, hash-chained audit, evidence-dossier export | +| `dpp-vault` | bin+lib | DPP write engine — create, versioned lifecycle (publish / suspend / archive / end-of-life), transfer-of-responsibility handshake, hash-chained audit, evidence-dossier generation + verification | | `dpp-identity` | bin+lib | `did:web` identity HTTP service — signing, key rotation | | `dpp-resolver` | bin+lib | Public QR / Digital Link resolver, JWS-verified fail-closed | | `dpp-integrator` | bin+lib | CSV/XLSX-to-DPP bulk import adapter with per-sector templates | | `dpp-common` | lib | Event bus trait + well-known subjects, telemetry, RFC 7807 HTTP errors | | `dpp-plugin-host` | lib | wasmtime sandbox — fuel metering, memory cap, deny-all WASI, signed-plugin policy | | `dpp-node` | bin | **The single binary — fuses all services**, boot trust-report, registry outbox drain, signed-ruleset loader | -| `dpp-cli` (`cli/`) | bin | `odal` — the operator control plane, from bootstrap to evidence export and offline verification | +| `dpp-cli` (`cli/`) | bin | `odal` — the operator control plane, from bootstrap to evidence dossier generation and verification | | `dpp-seal` | lib | eIDAS qualified-seal adapter (CSC/QTSP client scaffold) — resolves to a clearly-marked Ghost until a QTSP is configured; a production-profile node **refuses to boot** on ghost trust adapters | | `dpp-factor-data` | lib | Licensed LCI factor store — ghost provider until a dataset licence is signed; any ghost-derived result is marked `dataset_id="ghost"` | @@ -76,7 +76,6 @@ All core crates are consumed from crates.io (dpp-core is published independently | `dpp-calc` | EU-methodology calculators (CO2e, repairability) | | `dpp-plugin-traits` | Wasm plugin ABI | | `dpp-registry` | EU registry interface types | -| `dpp-evidence` | Evidence dossier wire format + offline verification engine (`odal verify`) | **Dependency direction**: dpp-engine -> dpp-core (one-way). dpp-core has zero knowledge of this repo. @@ -120,9 +119,9 @@ cargo build -p dpp-cli # builds target/debug/odal ``` ```bash -# Prove it, offline: export a signed evidence dossier and verify it with no server -./target/debug/odal passport evidence > dossier.json -./target/debug/odal verify dossier.json # 8+ independent checks, exit 0 = verified +# Generate a signed evidence dossier and verify it against the node +./target/debug/odal passport evidence # generates + stores a dossier +./target/debug/odal verify # 8+ independent checks, exit 0 = verified ``` Full command reference: **[cli/README.md](cli/README.md)**. @@ -152,13 +151,13 @@ Full command reference: **[cli/README.md](cli/README.md)**. Docker Compose: `docker/docker-compose.dev.yml` -Migrations: `ops/pg/0001_extensions_roles_schemas.sql` through `0017_passport_transfer.sql` — applied via `PgDal::migrate` at startup if `DATABASE_MIGRATE_URL` is set. The audit table is append-only (DB trigger) and hash-chained (`0015`); the registry outbox (`0006`) is written inside the publish transaction and drained with backoff. +Migrations: `ops/pg/0001_extensions_roles_schemas.sql` through `0021_evidence_dossier.sql` — applied via `PgDal::migrate` at startup if `DATABASE_MIGRATE_URL` is set. The audit table is append-only (DB trigger) and hash-chained (`0015`); the registry outbox (`0006`) is written inside the publish transaction and drained with backoff; evidence dossiers are append-only (`0021`). --- ## What Makes This Node Different (the trust layer, shipped) -**Honesty is enforced, not promised.** Every trust-critical port reports its tier (`ghost` / `sandbox` / `live`) in `/health`; under `NODE_PROFILE=production` the node **refuses to start** while any required port resolves to a placeholder — a node cannot claim trust services it doesn't have. **History is tamper-evident:** every audit entry is hash-chained; a superuser edit is detected at the exact index. **Registration is never lost:** EU-registry intent is committed to a durable outbox in the same transaction as publish, then drained with retry/backoff — kill the node mid-publish and nothing is lost (tested). **Regulation ships as signed data:** compliance rulesets arrive as Ed25519-signed bundles, verified fail-closed and hot-swapped atomically; the active version is visible in `/health`. **Anyone can verify, offline:** a signed evidence dossier (passport, JWS, DID document, audit chain, transfer chain) exports in one call and verifies with zero network via [`dpp-evidence`](https://github.com/odal-node/dpp-core/tree/main/crates/dpp-evidence). +**Honesty is enforced, not promised.** Every trust-critical port reports its tier (`ghost` / `sandbox` / `live`) in `/health`; under `NODE_PROFILE=production` the node **refuses to start** while any required port resolves to a placeholder — a node cannot claim trust services it doesn't have. **History is tamper-evident:** every audit entry is hash-chained; a superuser edit is detected at the exact index. **Registration is never lost:** EU-registry intent is committed to a durable outbox in the same transaction as publish, then drained with retry/backoff — kill the node mid-publish and nothing is lost (tested). **Regulation ships as signed data:** compliance rulesets arrive as Ed25519-signed bundles, verified fail-closed and hot-swapped atomically; the active version is visible in `/health`. **Evidence is generated and verified, not just claimed:** a signed dossier (passport, JWS, DID document, audit chain, transfer chain) is generated and stored in one call, then checked against its own signatures and hash chains via `odal verify` or the evidence API — see [docs/architecture/EVIDENCE-DOSSIER.md](docs/architecture/EVIDENCE-DOSSIER.md). --- diff --git a/api/openapi.yaml b/api/openapi.yaml index 18296c6..e9b2789 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -380,11 +380,13 @@ components: EvidenceDossier: type: object description: | - A self-contained, signed export of a passport's full proof chain, - verifiable offline with zero trust in the issuing node. See - `dpp-evidence`'s `spec/dossier-v1.md` (in the `dpp-core` repository) - for the full specification, including the trust model and why - `calcReceipts`/`checkpoint` are always empty/`null` in format v1. + A self-contained, signed snapshot of a passport's full proof chain, + persisted by the node at generation time. Verification + (`POST /evidence/{id}/verify`) is an integrity check of the stored + dossier against its own signatures and hash chains. See + `docs/architecture/EVIDENCE-DOSSIER.md` for the full specification, + including why `calcReceipts`/`checkpoint` are always empty/`null` in + format v1. required: [manifest, manifestJws, fullView, publicView, didDocuments, auditEntries] properties: manifest: @@ -422,6 +424,75 @@ components: items: type: object + EvidenceDossierRecord: + type: object + description: A stored dossier snapshot — the dossier plus its persistence envelope. + required: [id, passportId, actor, createdAt, docHash, dossier] + properties: + id: + type: string + format: uuid + passportId: + type: string + format: uuid + actor: + type: string + description: Who requested generation. + createdAt: + type: string + format: date-time + docHash: + type: string + description: Hex SHA-256 of the JCS-canonicalised stored dossier document. + dossier: + $ref: "#/components/schemas/EvidenceDossier" + + EvidenceDossierSummary: + type: object + description: Listing projection of a stored dossier — everything but the document. + required: [id, passportId, actor, createdAt, docHash] + properties: + id: + type: string + format: uuid + passportId: + type: string + format: uuid + actor: + type: string + createdAt: + type: string + format: date-time + docHash: + type: string + + CheckResult: + type: object + description: Outcome of a single named verification check. + required: [name, status] + properties: + name: + type: string + example: "audit_chain" + status: + type: string + enum: [pass, fail, absent] + detail: + type: string + description: Present when status is `fail` or `absent`. + + VerificationReport: + type: object + required: [trustAnchorNote, checks] + properties: + trustAnchorNote: + type: string + example: "trust anchored to the dossier's embedded DID-document snapshot dated 2026-07-10T00:00:00Z" + checks: + type: array + items: + $ref: "#/components/schemas/CheckResult" + OperatorConfig: type: object required: [operatorId, legalName, address, country, contactEmail] @@ -792,9 +863,13 @@ components: ImportSyncResponse: type: object - required: [totalRows, successCount, errorCount, created, errors] + required: [jobId, totalRows, successCount, errorCount, created, errors] description: Returned for synchronous imports (≤ 100 valid rows) and dry runs. properties: + jobId: + type: string + format: uuid + description: The job this response's report was persisted under — retrievable later via the job-status endpoint. totalRows: type: integer successCount: @@ -845,6 +920,10 @@ components: type: object nullable: true description: Populated on completion (created/errors) or failure (reason). + report: + type: object + nullable: true + description: The row-addressed findings report — populated for every job, dry-run or apply, independent of `result`. ApiError: type: object @@ -1027,6 +1106,48 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /vault/api/v1/dpp/by-identity: + get: + operationId: findDppByIdentity + summary: Find a DPP by exact compound identity + description: | + Look up a passport by exact (sector, GTIN, batch) match, across + `draft` and `active` statuses. Backs the import delta-matcher — + not intended as a general-purpose search (use `GET /dpps` for that). + `batchId` omitted matches only passports with no batch set. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: sector + in: query + required: true + schema: + type: string + example: "battery" + - name: gtin + in: query + required: true + schema: + type: string + - name: batchId + in: query + required: false + schema: + type: string + responses: + "200": + description: The matching DPP record. + content: + application/json: + schema: + $ref: "#/components/schemas/PassportResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /vault/api/v1/dpp/{dppId}: get: operationId: getDpp @@ -1247,19 +1368,54 @@ paths: "404": $ref: "#/components/responses/NotFound" + /vault/api/v1/dpp/{dppId}/lint: + post: + operationId: relintDpp + summary: Re-check plausibility-lint findings + description: | + Recomputes the `dpp-rules` plausibility lint pack against the DPP's + current sector data and persists the refreshed `lintResult` (pack + version, findings, assessed-at timestamp). Findings are non-binding — + arithmetic and physical-plausibility checks distinct from binding + compliance rules — and never gate publish or any other transition. + + Works regardless of DPP status, including `active` (published): + re-checking does not retroactively affect the passport's JWS + signature, which is frozen over whatever `lintResult` looked like at + publish time. No request body is required. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + responses: + "200": + description: Lint findings refreshed. Returns the full passport record. + content: + application/json: + schema: + $ref: "#/components/schemas/PassportResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /vault/api/v1/dpp/{dppId}/evidence: - get: - operationId: getDppEvidence - summary: Export a signed, offline-verifiable evidence dossier + post: + operationId: generateDppEvidence + summary: Generate and store a signed evidence dossier description: | Assembles a self-contained, signed dossier proving a passport's full proof chain — both JWS signatures, DID document snapshots, the hash-chained audit trail, and (when present) the transfer chain and - end-of-life record. Requires no trust in this node to verify: pair - with the `odal verify` CLI command, or the `dpp-evidence` crate - directly, to check it fully offline. See `dpp-evidence`'s - `spec/dossier-v1.md` in the `dpp-core` repository for the complete - format specification and trust model. + end-of-life record — and persists it. See + `docs/architecture/EVIDENCE-DOSSIER.md` for the complete format + specification. Authenticated tier only — the dossier's `fullView` member carries full-view (non-redacted) passport data. Requires the passport to have @@ -1275,18 +1431,130 @@ paths: schema: $ref: "#/components/schemas/DppId" responses: - "200": - description: The evidence dossier. + "201": + description: The stored dossier record. content: application/json: schema: - $ref: "#/components/schemas/EvidenceDossier" + $ref: "#/components/schemas/EvidenceDossierRecord" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" + get: + operationId: listDppEvidence + summary: List stored evidence dossiers for a passport + description: Summaries only (no document body), newest first. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: "#/components/schemas/DppId" + responses: + "200": + description: Stored dossier summaries. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/EvidenceDossierSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/evidence/{id}: + get: + operationId: getEvidenceDossier + summary: Fetch one stored dossier's document + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: The evidence dossier document. + content: + application/json: + schema: + $ref: "#/components/schemas/EvidenceDossier" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/evidence/{id}/verify: + post: + operationId: verifyStoredEvidenceDossier + summary: Verify a stored dossier + description: | + An integrity check against the stored dossier's own signatures and + hash chains. A tamper is still a `200` response — the report's + `checks` name which check failed. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: The verification report. + content: + application/json: + schema: + $ref: "#/components/schemas/VerificationReport" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /vault/api/v1/evidence/verify: + post: + operationId: verifyUploadedEvidenceDossier + summary: Verify an uploaded dossier document + description: Same checks as the stored-dossier verify endpoint, run against an uploaded document instead. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EvidenceDossier" + responses: + "200": + description: The verification report. + content: + application/json: + schema: + $ref: "#/components/schemas/VerificationReport" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + description: Not a valid dossier — malformed JSON or an unrecognised field. # ---- Operator Config (odal-vault) ---------------------------------------- @@ -1912,7 +2180,11 @@ paths: - ≤ 100 valid rows → processed synchronously, `200` with results. - `> 100` valid rows → an async job is queued, `202` with a `jobId`. - - `dry_run=true` → validate only, `200` with the would-be results. + - `mode=dry_run` → validate only, `200` with the would-be results. + + Every import — dry-run or apply, sync or async — mints a job id and + persists a row-addressed report retrievable via the job-status + endpoint, even when this endpoint's own response is synchronous. tags: [Integrator] security: - BearerApiKey: [] @@ -1935,9 +2207,9 @@ paths: type: string format: binary description: CSV or XLSX file. - dry_run: + mode: type: string - description: '"true" or "1" to validate without creating records.' + description: '"dry_run" to validate without creating records; any other value (or omitted) means apply.' responses: "200": description: Synchronous import (or dry-run) results. diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ecf8392..186f485 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,7 +13,7 @@ name = "odal" path = "src/main.rs" [dependencies] -dpp-evidence = { workspace = true } +dpp-types = { workspace = true } clap = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index 7125795..896c60d 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -99,12 +99,12 @@ pub enum Commands { #[command(subcommand)] command: SchemaCommands, }, - // ── Offline verification ───────────────────────────────────────────────── - /// Verify an evidence dossier fully offline, zero trust in the issuing - /// node (see `odal passport evidence` to export one) + // ── Evidence verification ──────────────────────────────────────────────── + /// Verify an evidence dossier against the node (see `odal passport + /// evidence` to generate one) Verify { - /// Path to the evidence dossier JSON file - file: String, + /// Stored dossier id, or path to a dossier JSON file + target: String, }, } @@ -156,9 +156,8 @@ pub enum PassportCommands { /// Passport ID id: String, }, - /// Export a signed, self-contained evidence dossier for offline - /// verification (`odal verify`) — proof + audit chain + transfer chain - /// in one file. + /// Generate and store a signed evidence dossier (`odal verify` checks + /// it) — proof + audit chain + transfer chain in one document. Evidence { /// Passport ID id: String, @@ -270,8 +269,10 @@ pub enum KeyCommands { }, /// Adopt an existing API key secret as this profile's active credential Use { - /// The `odal_sk_…` secret to save - secret: String, + /// The `odal_sk_…` secret to save. If omitted, it is read from + /// `ODAL_API_SECRET` or prompted for without echoing to the terminal — + /// keeping the secret out of shell history and the process table. + secret: Option, }, } diff --git a/cli/src/commands/evidence.rs b/cli/src/commands/evidence.rs index 8a85612..3f8f922 100644 --- a/cli/src/commands/evidence.rs +++ b/cli/src/commands/evidence.rs @@ -1,4 +1,4 @@ -//! `odal evidence` — fetch a passport's signed offline-verifiable evidence dossier. +//! `odal evidence` — generate and store a passport's signed evidence dossier. use anyhow::Result; diff --git a/cli/src/commands/verify.rs b/cli/src/commands/verify.rs index 25f55b1..b1faeed 100644 --- a/cli/src/commands/verify.rs +++ b/cli/src/commands/verify.rs @@ -1,19 +1,24 @@ -//! `odal verify` — verify an evidence dossier fully offline, zero trust in -//! the issuing node. +//! `odal verify` — verify a stored dossier (by id) or an uploaded dossier +//! file against the node. //! -//! Exit codes match `dpp_evidence::VerificationReport::exit_code`'s -//! documented convention: 0 verified, 1 tamper detected. A file that -//! couldn't even be read or parsed as a dossier is a third case, exit 2 — -//! `action_verify` returns `Err` for that, never a report. +//! Exit codes match `VerificationReport::exit_code`'s documented convention: +//! 0 verified, 1 tamper detected. A target that couldn't be reached, read, +//! or parsed as a dossier is a third case, exit 2 — `action_verify` returns +//! `Err` for that, never a report. use anyhow::Result; -use crate::{core::verify::action_verify, stateless::render::render_verification_report}; +use crate::{ + config::Config, core::verify::action_verify, http::OdalClient, + stateless::render::render_verification_report, +}; -pub fn run_verify(file: &str) -> Result<()> { - match action_verify(file) { +pub async fn run_verify(target: &str) -> Result<()> { + let cfg = Config::load()?; + let client = OdalClient::new(&cfg.api_key); + match action_verify(target, &client, &cfg).await { Ok(report) => { - render_verification_report(&report, file); + render_verification_report(&report, target); std::process::exit(report.exit_code()); } Err(e) => { diff --git a/cli/src/console/menu/verify.rs b/cli/src/console/menu/verify.rs index cb3c3d1..930c2cf 100644 --- a/cli/src/console/menu/verify.rs +++ b/cli/src/console/menu/verify.rs @@ -1,4 +1,5 @@ -//! Verify menu: check an exported evidence dossier fully offline. +//! Verify menu: check a stored dossier or an uploaded dossier file against +//! the node. use anyhow::Result; use inquire::{Select, Text}; @@ -6,19 +7,20 @@ use inquire::{Select, Text}; use crate::{core::verify::action_verify, stateless::render::render_verification_report}; use super::super::validators::valid_dossier_file; -use super::{ask, hint, print_err}; +use super::{ask, client, hint, print_err}; pub(super) async fn verify() -> Result<()> { - let file = match prompt_dossier_file().await? { - Some(f) => f, + let target = match prompt_target().await? { + Some(t) => t, None => return Ok(()), }; println!(); - match action_verify(&file) { + let (odal_client, cfg) = client()?; + match action_verify(&target, &odal_client, &cfg).await { Ok(report) => { - render_verification_report(&report, &file); - hint(&format!("odal verify {file}")); + render_verification_report(&report, &target); + hint(&format!("odal verify {target}")); println!(); } Err(e) => print_err(e), @@ -26,10 +28,31 @@ pub(super) async fn verify() -> Result<()> { Ok(()) } -/// Choose a dossier file. Offers the native OS file picker first, falling -/// back to a typed-path prompt if the operator prefers it or no display is -/// available (e.g. a headless/SSH session) — same pattern as `Passports › Import`. -async fn prompt_dossier_file() -> Result> { +/// Choose a stored dossier (by id) or a dossier file. Offers the native OS +/// file picker for the file path, falling back to a typed-path prompt if the +/// operator prefers it or no display is available (e.g. a headless/SSH +/// session) — same pattern as `Passports › Import`. +async fn prompt_target() -> Result> { + match ask(Select::new( + "Verify:", + vec!["Stored dossier (enter id)", "Dossier file…"], + ) + .with_help_message("↑↓ · ⏎ select · Esc to cancel") + .prompt())? + { + Some("Stored dossier (enter id)") => prompt_dossier_id(), + Some(_) => prompt_dossier_file_path().await, + None => Ok(None), + } +} + +fn prompt_dossier_id() -> Result> { + ask(Text::new("Stored dossier id:") + .with_help_message("Esc to cancel") + .prompt()) +} + +async fn prompt_dossier_file_path() -> Result> { match ask( Select::new("Choose the dossier file:", vec!["Browse…", "Type a path"]) .with_help_message("↑↓ · ⏎ select · Esc to cancel") diff --git a/cli/src/core/passport/evidence.rs b/cli/src/core/passport/evidence.rs index 58fe6b9..e2486a2 100644 --- a/cli/src/core/passport/evidence.rs +++ b/cli/src/core/passport/evidence.rs @@ -1,4 +1,4 @@ -//! Evidence: fetch a single passport's signed offline-verifiable dossier (N02). +//! Evidence: generate and store a signed dossier for a passport. use anyhow::{Context, Result}; @@ -11,13 +11,13 @@ pub async fn action_evidence( cfg: &crate::config::Config, ) -> Result { let url = format!("{}/api/v1/dpp/{id}/evidence", cfg.vault_url); - let (status, body) = client.get(&url).await?; + let (status, body) = client.post_empty(&url).await?; if !status.is_success() { - anyhow::bail!("Evidence export failed (HTTP {status}): {body}"); + anyhow::bail!("Evidence generation failed (HTTP {status}): {body}"); } - let dossier: serde_json::Value = + let record: serde_json::Value = serde_json::from_str(&body).context("Failed to parse vault response as JSON")?; Ok(ExportResult { - data: serde_json::to_string_pretty(&dossier)?, + data: serde_json::to_string_pretty(&record)?, }) } diff --git a/cli/src/core/passport/import.rs b/cli/src/core/passport/import.rs index efe30d5..d1a96f4 100644 --- a/cli/src/core/passport/import.rs +++ b/cli/src/core/passport/import.rs @@ -83,7 +83,7 @@ async fn import_json_records( "Record {}: HTTP {} — {}", i + 1, status, - &body[..body.len().min(200)] + crate::stateless::render::truncate(&body, 200) )); } Err(e) => { @@ -174,7 +174,7 @@ async fn summarize_import_response( if !status.is_success() { anyhow::bail!( "Import failed (HTTP {status}): {}", - &body[..body.len().min(300)] + crate::stateless::render::truncate(body, 300) ); } let resp: serde_json::Value = @@ -197,7 +197,7 @@ async fn poll_import_job(client: &OdalClient, cfg: &Config, job_id: &str) -> Res if !status.is_success() { anyhow::bail!( "Failed to poll import job (HTTP {status}): {}", - &body[..body.len().min(200)] + crate::stateless::render::truncate(&body, 200) ); } let resp: serde_json::Value = diff --git a/cli/src/core/passport/publish.rs b/cli/src/core/passport/publish.rs index 6b4fa01..9058f39 100644 --- a/cli/src/core/passport/publish.rs +++ b/cli/src/core/passport/publish.rs @@ -50,7 +50,7 @@ async fn publish_one(client: &OdalClient, vault_url: &str, id: &str) -> Result

Result { let err = format!( "{name}: HTTP {status} — {}", - &resp_body[..resp_body.len().min(200)] + crate::stateless::render::truncate(&resp_body, 200) ); errors.push(err.clone()); items.push(PassportPublishResult { diff --git a/cli/src/core/passport/validate.rs b/cli/src/core/passport/validate.rs index 119fdcd..719766e 100644 --- a/cli/src/core/passport/validate.rs +++ b/cli/src/core/passport/validate.rs @@ -71,6 +71,7 @@ pub fn find_issues(rec: &serde_json::Value) -> Vec { } Some("textile") => { for f in &[ + "gtin", "fibreComposition", "countryOfManufacturing", "careInstructions", @@ -101,6 +102,7 @@ mod tests { "productName": "T-Shirt", "sectorData": { "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], "countryOfManufacturing": "DE", "careInstructions": "Machine wash 30°C", @@ -145,6 +147,21 @@ mod tests { assert!(find_issues(&rec).iter().any(|i| i.contains("sectorData"))); } + #[test] + fn textile_missing_gtin() { + let rec = json!({ + "productName": "T-Shirt", + "sectorData": { + "sector": "textile", + "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], + "countryOfManufacturing": "DE", + "careInstructions": "wash", + "chemicalComplianceStandard": "REACH" + } + }); + assert!(find_issues(&rec).iter().any(|i| i.contains("gtin"))); + } + #[test] fn textile_missing_fibre_composition() { let rec = json!({ diff --git a/cli/src/core/verify.rs b/cli/src/core/verify.rs index 75bfc96..ad6f3df 100644 --- a/cli/src/core/verify.rs +++ b/cli/src/core/verify.rs @@ -1,11 +1,34 @@ -//! Verify: offline verification of an exported evidence dossier, zero trust -//! in the issuing node. +//! Verify: check a stored dossier (by id) or an uploaded dossier file +//! against the node. -use anyhow::{Context, Result}; -use dpp_evidence::{VerificationReport, VerifyMode, verify_dossier_json}; +use anyhow::{Context, Result, bail}; +use dpp_types::evidence::VerificationReport; -pub fn action_verify(file: &str) -> Result { - let bytes = std::fs::read(file).with_context(|| format!("Cannot read file: {file}"))?; - verify_dossier_json(&bytes, VerifyMode::Embedded) - .with_context(|| format!("{file} is not a valid evidence dossier")) +use crate::{config::Config, http::OdalClient}; + +/// `target` is either a path to a dossier JSON file on disk, or a stored +/// dossier's id — whichever resolves is used. +pub async fn action_verify( + target: &str, + client: &OdalClient, + cfg: &Config, +) -> Result { + let (status, body) = if std::path::Path::new(target).is_file() { + let bytes = std::fs::read(target).with_context(|| format!("Cannot read file: {target}"))?; + client + .post_bytes(&format!("{}/api/v1/evidence/verify", cfg.vault_url), bytes) + .await? + } else { + client + .post_empty(&format!( + "{}/api/v1/evidence/{target}/verify", + cfg.vault_url + )) + .await? + }; + + if !status.is_success() { + bail!("Verification failed (HTTP {status}): {body}"); + } + serde_json::from_str(&body).context("Failed to parse verification report as JSON") } diff --git a/cli/src/dispatch.rs b/cli/src/dispatch.rs index 71ebeb3..304fde5 100644 --- a/cli/src/dispatch.rs +++ b/cli/src/dispatch.rs @@ -106,7 +106,7 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { } => run_key_revoke(&id).await, Commands::Key { command: KeyCommands::Use { secret }, - } => run_key_use(&secret).await, + } => run_key_use(&resolve_api_secret(secret)?).await, Commands::Facility { command: FacilityCommands::List, } => run_facility_list().await, @@ -220,6 +220,25 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { Commands::Schema { command: SchemaCommands::Check, } => run_schema().await, - Commands::Verify { file } => run_verify(&file), + Commands::Verify { target } => run_verify(&target).await, } } + +/// Resolve the API secret for `odal key use`, in order: the explicit CLI +/// argument, the `ODAL_API_SECRET` environment variable, then an interactive +/// no-echo prompt. Accepting the secret anywhere but `argv` is deliberate — +/// a secret on the command line leaks into shell history and the process table. +fn resolve_api_secret(arg: Option) -> anyhow::Result { + if let Some(secret) = arg { + return Ok(secret); + } + if let Ok(secret) = std::env::var("ODAL_API_SECRET") + && !secret.is_empty() + { + return Ok(secret); + } + let secret = inquire::Password::new("API secret (odal_sk_…):") + .without_confirmation() + .prompt()?; + Ok(secret) +} diff --git a/cli/src/http.rs b/cli/src/http.rs index f661e19..82db31f 100644 --- a/cli/src/http.rs +++ b/cli/src/http.rs @@ -69,6 +69,37 @@ impl OdalClient { Ok((status, body)) } + /// POST raw JSON `bytes` to `url` with Bearer token, sent verbatim (no + /// reserialisation) — so a server-side content check sees exactly what + /// was on disk. + pub async fn post_bytes(&self, url: &str, bytes: Vec) -> Result<(StatusCode, String)> { + let resp = self + .inner + .post(url) + .bearer_auth(&self.bearer) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(bytes) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + Ok((status, body)) + } + + /// POST with an empty body to `url` with Bearer token — for endpoints + /// that take no request payload. + pub async fn post_empty(&self, url: &str) -> Result<(StatusCode, String)> { + let resp = self + .inner + .post(url) + .bearer_auth(&self.bearer) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + Ok((status, body)) + } + /// PATCH JSON `payload` to `url` with Bearer token. pub async fn patch_json( &self, diff --git a/cli/src/main.rs b/cli/src/main.rs index ef2d59b..4461b6a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -263,8 +263,8 @@ mod tests { #[test] fn parse_verify() { let cli = Cli::parse_from(["odal", "verify", "dossier.json"]); - if let Some(Commands::Verify { file }) = cli.command { - assert_eq!(file, "dossier.json"); + if let Some(Commands::Verify { target }) = cli.command { + assert_eq!(target, "dossier.json"); } else { panic!("expected Verify"); } diff --git a/cli/src/stateless/render.rs b/cli/src/stateless/render.rs index 5db7910..ecc48e3 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -7,7 +7,7 @@ use std::io::Write as _; use anyhow::Result; use console::style; -use dpp_evidence::{CheckStatus, VerificationReport}; +use dpp_types::evidence::{CheckStatus, VerificationReport}; use crate::config::{Config, EnvKind}; use crate::core::types::{ @@ -17,11 +17,16 @@ use crate::core::types::{ // ── Helpers ────────────────────────────────────────────────────────────────── -fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { +/// Truncate to at most `max` characters (not bytes), appending `…` when cut. +/// +/// Counts and slices by `char` so a multi-byte UTF-8 sequence landing on the +/// cutoff can never panic the CLI (the byte-index `&s[..n]` form does). +pub(crate) fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { s.to_owned() } else { - format!("{}…", &s[..max.saturating_sub(1)]) + let head: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{head}…") } } @@ -187,8 +192,8 @@ pub fn render_validation_report(report: &ValidationReport) { } /// Render an evidence dossier verification report (`odal verify`). -pub fn render_verification_report(report: &VerificationReport, file: &str) { - println!("Verifying: {file}"); +pub fn render_verification_report(report: &VerificationReport, target: &str) { + println!("Verifying: {target}"); println!("Trust anchor: {}\n", report.trust_anchor_note); for check in &report.checks { match &check.status { diff --git a/crates/dpp-common/src/event_codes.rs b/crates/dpp-common/src/event_codes.rs index b02d67b..1f031a5 100644 --- a/crates/dpp-common/src/event_codes.rs +++ b/crates/dpp-common/src/event_codes.rs @@ -102,4 +102,5 @@ pub const MUTABLE_FIELDS: &[&str] = &[ "publishedAt", "retentionLocked", "updatedAt", + "lintResult", ]; diff --git a/crates/dpp-common/src/request_id.rs b/crates/dpp-common/src/request_id.rs index 81efe8f..cfd09b9 100644 --- a/crates/dpp-common/src/request_id.rs +++ b/crates/dpp-common/src/request_id.rs @@ -9,6 +9,18 @@ use axum::{ }; use tower_http::request_id::{MakeRequestId, RequestId}; +/// Maximum error-body size buffered to inject `requestId`. Larger bodies are +/// passed through unmodified (the id remains in the `x-request-id` header). +const MAX_INJECT_BYTES: usize = 64 * 1024; + +/// Whether a `Content-Type` names a JSON media type: plain `application/json` +/// or any RFC 6839 `+json` structured suffix — including this platform's +/// `application/problem+json` error type, which `contains("application/json")` +/// alone does **not** match. +fn is_json_content_type(content_type: &str) -> bool { + content_type.contains("application/json") || content_type.contains("+json") +} + /// Generates a UUIDv7-based request ID for `SetRequestIdLayer`. #[derive(Clone, Default)] pub struct UuidRequestId; @@ -48,7 +60,7 @@ pub async fn inject_request_id(request: Request, next: Next) -> impl IntoRespons .headers() .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v.contains("application/json")); + .is_some_and(is_json_content_type); if !is_json { return response; @@ -56,9 +68,35 @@ pub async fn inject_request_id(request: Request, next: Next) -> impl IntoRespons let (parts, body) = response.into_parts(); - let bytes = match axum::body::to_bytes(body, 64 * 1024).await { + // A large error body (e.g. a batch-import validation report) can't be + // buffered to inject `requestId` without risking unbounded memory. When + // `Content-Length` already says it exceeds the limit, pass it through + // unmodified rather than dropping it — the id is still in the `x-request-id` + // header regardless. + if parts + .headers + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|len| len > MAX_INJECT_BYTES) + { + return Response::from_parts(parts, body); + } + + let bytes = match axum::body::to_bytes(body, MAX_INJECT_BYTES).await { Ok(b) => b, - Err(_) => return Response::from_parts(parts, Body::empty()), + Err(e) => { + // A streamed body with no Content-Length that overran the limit; it + // is consumed and can't be recovered, so return empty — but never + // silently, so the truncation is visible in logs. + tracing::warn!( + error = %e, + status = %parts.status, + "error response body exceeded {MAX_INJECT_BYTES} bytes; \ + requestId not injected and body dropped" + ); + return Response::from_parts(parts, Body::empty()); + } }; let mut json: serde_json::Value = match serde_json::from_slice(&bytes) { @@ -73,3 +111,24 @@ pub async fn inject_request_id(request: Request, next: Next) -> impl IntoRespons let new_body = serde_json::to_vec(&json).unwrap_or_else(|_| bytes.to_vec()); Response::from_parts(parts, Body::from(new_body)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_content_type_matches_problem_json() { + assert!(is_json_content_type("application/json")); + assert!(is_json_content_type("application/json; charset=utf-8")); + // The platform's actual error media type — the case the old + // `contains("application/json")` check silently missed. + assert!(is_json_content_type("application/problem+json")); + assert!(is_json_content_type( + "application/problem+json; charset=utf-8" + )); + assert!(is_json_content_type("application/ld+json")); + // Non-JSON must not match. + assert!(!is_json_content_type("text/html")); + assert!(!is_json_content_type("text/plain; charset=utf-8")); + } +} diff --git a/crates/dpp-dal/src/pg/mod.rs b/crates/dpp-dal/src/pg/mod.rs index a70d42d..35c2d6e 100644 --- a/crates/dpp-dal/src/pg/mod.rs +++ b/crates/dpp-dal/src/pg/mod.rs @@ -12,6 +12,7 @@ pub mod pool; pub mod repo_api_key; pub mod repo_audit; +pub mod repo_evidence; pub mod repo_operator_config; pub mod repo_passport; pub mod repo_registry_identity; @@ -22,6 +23,7 @@ pub use pool::PgDal; pub use repo_api_key::PgApiKeyRepo; pub use repo_audit::PgAuditRepo; +pub use repo_evidence::PgEvidenceDossierRepo; pub use repo_operator_config::PgOperatorConfigRepo; pub use repo_passport::PgPassportRepo; pub use repo_registry_identity::PgRegistryIdentityRepo; @@ -44,6 +46,9 @@ pub(crate) fn db_err(e: sqlx::Error) -> DppError { if msg.contains("ODAL_AUDIT") { return DppError::Internal("audit entries are append-only".into()); } + if msg.contains("ODAL_EVIDENCE") { + return DppError::Internal("evidence dossiers are append-only".into()); + } } DppError::Internal(format!("database: {e}")) } diff --git a/crates/dpp-dal/src/pg/repo_api_key.rs b/crates/dpp-dal/src/pg/repo_api_key.rs index eb42c0a..6e30860 100644 --- a/crates/dpp-dal/src/pg/repo_api_key.rs +++ b/crates/dpp-dal/src/pg/repo_api_key.rs @@ -25,8 +25,9 @@ impl PgApiKeyRepo { } fn key_from_row(r: &sqlx::postgres::PgRow) -> ApiKey { - // `scopes TEXT[]` may be NULL for keys written before scopes were - // enforced; `from_scopes` maps NULL/empty to Admin (pre-scope behaviour). + // `scopes TEXT[]` may be NULL for legacy pre-scope rows; `from_scopes` + // fails closed, mapping NULL/empty (and any unrecognized value) to the + // least-privilege Read rather than Admin. let scope = ApiKeyScope::from_scopes( &r.try_get::>, _>("scopes") .ok() diff --git a/crates/dpp-dal/src/pg/repo_evidence.rs b/crates/dpp-dal/src/pg/repo_evidence.rs new file mode 100644 index 0000000..2c68fbb --- /dev/null +++ b/crates/dpp-dal/src/pg/repo_evidence.rs @@ -0,0 +1,101 @@ +//! `EvidenceDossierRepository` on PostgreSQL — append-only by trigger, not +//! convention (`ops/pg/0021`). + +use async_trait::async_trait; +use sqlx::Row; + +use dpp_domain::{DppError, domain::passport::PassportId}; +use dpp_types::evidence::{ + DossierV1, EvidenceDossierRecord, EvidenceDossierRepository, EvidenceDossierSummary, +}; + +use super::{PgDal, db_err}; + +/// PostgreSQL implementation of [`EvidenceDossierRepository`]. +/// +/// The DB enforces append-only via a trigger; any UPDATE/DELETE raises +/// `ODAL_EVIDENCE` which `db_err` maps to `DppError::Internal`. +pub struct PgEvidenceDossierRepo { + dal: PgDal, +} + +impl PgEvidenceDossierRepo { + /// Construct a repo sharing the given pool handle. + pub fn new(dal: PgDal) -> Self { + Self { dal } + } +} + +#[async_trait] +impl EvidenceDossierRepository for PgEvidenceDossierRepo { + async fn insert(&self, record: &EvidenceDossierRecord) -> Result<(), DppError> { + let doc = serde_json::to_value(&record.dossier) + .map_err(|e| DppError::Internal(format!("serialize evidence dossier: {e}")))?; + sqlx::query( + r#"INSERT INTO odal.evidence_dossier + (id, passport_id, actor, doc_hash, doc, created_at) + VALUES ($1,$2,$3,$4,$5,$6)"#, + ) + .bind(record.id) + .bind(record.passport_id.0) + .bind(&record.actor) + .bind(&record.doc_hash) + .bind(&doc) + .bind(record.created_at) + .execute(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(()) + } + + async fn list_by_passport( + &self, + passport_id: PassportId, + ) -> Result, DppError> { + let rows = sqlx::query( + r#"SELECT id, passport_id, actor, doc_hash, created_at + FROM odal.evidence_dossier + WHERE passport_id = $1 + ORDER BY created_at DESC, id DESC"#, + ) + .bind(passport_id.0) + .fetch_all(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(rows + .into_iter() + .map(|r| EvidenceDossierSummary { + id: r.get("id"), + passport_id: PassportId(r.get("passport_id")), + actor: r.get("actor"), + created_at: r.get("created_at"), + doc_hash: r.get("doc_hash"), + }) + .collect()) + } + + async fn get(&self, id: uuid::Uuid) -> Result, DppError> { + let row = sqlx::query( + r#"SELECT id, passport_id, actor, doc_hash, doc, created_at + FROM odal.evidence_dossier + WHERE id = $1"#, + ) + .bind(id) + .fetch_optional(self.dal.pool()) + .await + .map_err(db_err)?; + row.map(|r| { + let dossier: DossierV1 = serde_json::from_value(r.get("doc")) + .map_err(|e| DppError::Internal(format!("deserialize evidence dossier: {e}")))?; + Ok(EvidenceDossierRecord { + id: r.get("id"), + passport_id: PassportId(r.get("passport_id")), + actor: r.get("actor"), + created_at: r.get("created_at"), + doc_hash: r.get("doc_hash"), + dossier, + }) + }) + .transpose() + } +} diff --git a/crates/dpp-dal/src/pg/repo_passport.rs b/crates/dpp-dal/src/pg/repo_passport.rs index bb22b5f..67dc645 100644 --- a/crates/dpp-dal/src/pg/repo_passport.rs +++ b/crates/dpp-dal/src/pg/repo_passport.rs @@ -15,6 +15,7 @@ use dpp_domain::{ domain::{ error::DppError, passport::{Passport, PassportId}, + product_identity::ProductIdentity, status::PassportStatus, }, ports::passport_repo::PassportRepository, @@ -22,6 +23,30 @@ use dpp_domain::{ use super::{PgDal, db_err}; +/// Fields `patch_fields` refuses to modify: passport identity, lifecycle state, +/// retention lock, signatures, and seal. Each is governed by the publish +/// pipeline / `update_status` and backs a scalar column that this JSONB-merge +/// path does not rewrite — allowing them here would both bypass the state +/// machine and desync the doc from its enforcing column (e.g. flipping +/// `retentionLocked` in the doc while the `retention_locked` column stays +/// `false`). Mirrors the `PassportRepository` default-impl guard in dpp-core. +/// Serialized (camelCase) names. +const PROTECTED_PATCH_FIELDS: [&str; 13] = [ + "id", + "sector", + "status", + "retentionLocked", + "retentionUntil", + "jwsSignature", + "publicJwsSignature", + "seal", + "version", + "publishedAt", + "createdAt", + "supersedesId", + "schemaVersion", +]; + /// Apply a passport update (scalar columns + `doc`) inside a caller-supplied /// transaction. Shared by [`PgPassportRepo::update`] and the transactional /// outbox's `commit_publish`, so the publish-write and the outbox insert commit @@ -138,6 +163,13 @@ impl PassportRepository for PgPassportRepo { /// /// O(n) over active passports — acceptable for single-tenant MVP scale. async fn find_published_by_gtin(&self, gtin: &str) -> Result, DppError> { + // A GTIN is purely numeric. Reject anything else so LIKE metacharacters + // (`%`/`_`) in an untrusted value can't widen the pattern to match — and + // return — an arbitrary passport. A non-numeric value can never match a + // real GS1 Digital Link URL anyway. + if gtin.is_empty() || !gtin.bytes().all(|b| b.is_ascii_digit()) { + return Ok(None); + } // Battery GS1 DL URL: https://id.odal-node.io/01/{gtin}/21/{serialId} let row = sqlx::query( "SELECT doc FROM odal.passport \ @@ -153,6 +185,38 @@ impl PassportRepository for PgPassportRepo { .transpose() } + /// Find a passport by exact compound identity (sector, GTIN, batch), + /// across `Draft` and `Published` — backs the import delta-matcher. + /// Indexed by `0019_passport_identity_index.sql`. GTIN is read from + /// `doc->'sectorData'->>'gtin'`: present for every sector except + /// `UnsoldGoods`/`Other`, which carry no GTIN field and so never match + /// here — a discard-event report and an untyped catch-all, not a query bug. + async fn find_by_identity( + &self, + identity: &ProductIdentity, + ) -> Result, DppError> { + let sector_str = serde_json::to_value(&identity.sector) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .ok_or_else(|| DppError::Internal("failed to serialise sector".into()))?; + let row = sqlx::query( + "SELECT doc FROM odal.passport \ + WHERE status IN ('draft','active') \ + AND sector = $1 \ + AND doc->'sectorData'->>'gtin' = $2 \ + AND doc->>'batchId' IS NOT DISTINCT FROM $3 \ + LIMIT 1", + ) + .bind(§or_str) + .bind(&identity.gtin) + .bind(identity.batch_id.as_deref()) + .fetch_optional(self.dal.pool()) + .await + .map_err(db_err)?; + row.map(|r| Self::from_doc(r.get::("doc"))) + .transpose() + } + /// Fetch by id without a status filter; equivalent to `find_by_id`. async fn find_by_id_any_status(&self, id: PassportId) -> Result, DppError> { let mut tx = self.dal.begin().await?; @@ -183,6 +247,28 @@ impl PassportRepository for PgPassportRepo { id: PassportId, delta: serde_json::Value, ) -> Result { + // Reject protected/state-machine fields up front: they are set only via + // the publish pipeline / update_status and back scalar columns this path + // does not rewrite, so patching them would bypass the state machine and + // desync the doc from its enforcing column. + if let Some(obj) = delta.as_object() { + let mut forbidden: Vec<&str> = PROTECTED_PATCH_FIELDS + .iter() + .copied() + .filter(|k| obj.contains_key(*k)) + .collect(); + if !forbidden.is_empty() { + forbidden.sort_unstable(); + return Err(DppError::Validation( + format!( + "patch_fields cannot modify protected field(s): {}", + forbidden.join(", ") + ) + .into(), + )); + } + } + let mut tx = self.dal.begin().await?; // Row lock makes concurrent patches serialise instead of clobbering. let row = sqlx::query("SELECT doc FROM odal.passport WHERE id = $1 FOR UPDATE") @@ -204,17 +290,14 @@ impl PassportRepository for PgPassportRepo { } } let passport = Self::from_doc(doc.clone())?; - sqlx::query( - r#"UPDATE odal.passport SET - status = COALESCE($2->>'status', status), - doc = $2 - WHERE id = $1"#, - ) - .bind(Self::uuid_of(id)) - .bind(&doc) - .execute(&mut *tx) - .await - .map_err(db_err)?; + // Doc-only write: the scalar columns are all protected fields (rejected + // above), so they can never drift from the doc via this path. + sqlx::query("UPDATE odal.passport SET doc = $2 WHERE id = $1") + .bind(Self::uuid_of(id)) + .bind(&doc) + .execute(&mut *tx) + .await + .map_err(db_err)?; tx.commit().await.map_err(db_err)?; Ok(passport) } diff --git a/crates/dpp-dal/src/pg/repo_registry_sync.rs b/crates/dpp-dal/src/pg/repo_registry_sync.rs index 7ca1dd6..324ad74 100644 --- a/crates/dpp-dal/src/pg/repo_registry_sync.rs +++ b/crates/dpp-dal/src/pg/repo_registry_sync.rs @@ -19,6 +19,22 @@ use dpp_types::{RegistrySyncCounts, RegistrySyncOutbox, RegistrySyncRow, Registr use super::{PgDal, db_err, repo_passport::update_passport_in_tx}; +/// Turn an `UPDATE` that matched no row into a `NotFound` rather than a silent +/// `Ok(())` — a status update against an absent `passport_id` is a real error, +/// not a no-op success. +fn require_updated( + res: &sqlx::postgres::PgQueryResult, + passport_id: PassportId, +) -> Result<(), DppError> { + if res.rows_affected() == 0 { + return Err(DppError::NotFound(format!( + "registry_sync row for passport {}", + passport_id.0 + ))); + } + Ok(()) +} + /// PostgreSQL implementation of [`RegistrySyncOutbox`]. pub struct PgRegistrySyncRepo { dal: PgDal, @@ -53,10 +69,21 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { let mut tx = self.dal.begin().await?; // Same transaction as the passport write — the atomicity guarantee. update_passport_in_tx(&mut tx, passport).await?; + // A previously *rejected* row is re-queued with the corrected payload so + // a fixed passport can actually be resubmitted (and `due()`, which only + // selects 'pending', picks it back up). Rows already 'registered', + // 'pending', or carrying a status-intent are left untouched. sqlx::query( r#"INSERT INTO odal.registry_sync (passport_id, payload, status) VALUES ($1, $2, 'pending') - ON CONFLICT (passport_id) DO NOTHING"#, + ON CONFLICT (passport_id) DO UPDATE SET + payload = EXCLUDED.payload, + status = 'pending', + attempts = 0, + next_attempt_at = now(), + message = NULL, + updated_at = now() + WHERE odal.registry_sync.status = 'rejected'"#, ) .bind(passport.id.0) .bind(&payload) @@ -109,7 +136,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { passport_id: PassportId, registry_id: String, ) -> Result<(), DppError> { - sqlx::query( + let res = sqlx::query( r#"UPDATE odal.registry_sync SET status = 'registered', registry_id = $2, @@ -124,7 +151,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { .execute(self.dal.pool()) .await .map_err(db_err)?; - Ok(()) + require_updated(&res, passport_id) } async fn mark_rejected( @@ -132,7 +159,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { passport_id: PassportId, message: String, ) -> Result<(), DppError> { - sqlx::query( + let res = sqlx::query( r#"UPDATE odal.registry_sync SET status = 'rejected', message = $2, @@ -145,7 +172,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { .execute(self.dal.pool()) .await .map_err(db_err)?; - Ok(()) + require_updated(&res, passport_id) } async fn mark_attempt_failed( @@ -156,7 +183,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { // Exponential backoff on the *new* attempt count, capped at 1h, with // 0.75–1.25× jitter to avoid thundering-herd retries. `attempts` in the // expression is the pre-increment value the row already holds. - sqlx::query( + let res = sqlx::query( r#"UPDATE odal.registry_sync SET attempts = attempts + 1, message = $2, @@ -171,7 +198,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { .execute(self.dal.pool()) .await .map_err(db_err)?; - Ok(()) + require_updated(&res, passport_id) } async fn pending_for( diff --git a/crates/dpp-dal/tests/pg_integration.rs b/crates/dpp-dal/tests/pg_integration.rs index c5e7d0e..bbbcedf 100644 --- a/crates/dpp-dal/tests/pg_integration.rs +++ b/crates/dpp-dal/tests/pg_integration.rs @@ -25,11 +25,13 @@ use testcontainers::{ }; use uuid::Uuid; -use dpp_dal::pg::{PgApiKeyRepo, PgAuditRepo, PgDal, PgPassportRepo, sqlx}; +use dpp_dal::pg::{PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgPassportRepo, sqlx}; use dpp_domain::{ domain::{ + gtin::Gtin, passport::{FacilitySnapshot, ManufacturerInfo, Passport, PassportId}, - sector::Sector, + product_identity::ProductIdentity, + sector::{BatteryChemistry, BatteryData, Sector, SectorData}, status::PassportStatus, }, ports::passport_repo::PassportRepository, @@ -37,7 +39,11 @@ use dpp_domain::{ use dpp_types::{ api_key::{ApiKey, ApiKeyRecord, ApiKeyRepository}, audit::{AuditEntry, AuditRepository}, + evidence::{ + DossierManifest, DossierV1, EvidenceDossierRecord, EvidenceDossierRepository, SignedLayer, + }, }; +use sqlx::Row; struct TestPg { dal: PgDal, @@ -111,6 +117,7 @@ fn make_passport() -> Passport { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, @@ -131,6 +138,50 @@ fn make_passport() -> Passport { } } +/// A battery passport carrying real `sectorData` (with `gtin`) and `status`, +/// for the identity-lookup test — `make_passport()` deliberately leaves +/// `sector_data: None`, which `find_by_identity` can never match. +fn battery_passport_with(gtin: &str, batch: Option<&str>, status: PassportStatus) -> Passport { + let mut p = make_passport(); + p.id = PassportId::new(); + p.batch_id = batch.map(str::to_owned); + p.status = status; + p.sector_data = Some(SectorData::Battery(BatteryData { + gtin: Gtin::parse(gtin).expect("valid test gtin"), + battery_chemistry: BatteryChemistry::Lfp, + nominal_voltage_v: 3.2, + nominal_capacity_ah: 100.0, + expected_lifetime_cycles: 3000, + co2e_per_unit_kg: 85.4, + recycled_content_cobalt_pct: None, + recycled_content_lithium_pct: None, + recycled_content_nickel_pct: None, + state_of_health_pct: None, + rated_capacity_kwh: None, + carbon_footprint_class: None, + due_diligence_url: None, + cathode_material: None, + anode_material: None, + electrolyte_material: None, + critical_raw_materials: None, + disassembly_instructions_url: None, + soh_methodology: None, + operating_temp_min_c: None, + operating_temp_max_c: None, + rated_energy_wh: None, + recycled_content_lead_pct: None, + battery_weight_kg: None, + battery_type: None, + round_trip_efficiency_pct: None, + internal_resistance_mohm: None, + manufacturing_date: None, + manufacturing_place: None, + battery_model_id: None, + battery_passport_number: None, + })); + p +} + /// Build a facility snapshot carrying `value` (other fields are placeholders) for /// the ADR-006 grouping-filter test. fn facility_with_value(value: &str) -> FacilitySnapshot { @@ -402,6 +453,52 @@ async fn t6_patch_fields_merge() { assert_eq!(reread.schema_version, "2.0.0", "untouched fields survive"); } +// patch_fields must reject state-machine / integrity fields so it can't bypass +// the state machine or desync the doc from its enforcing scalar column. +#[tokio::test] +async fn patch_fields_rejects_protected_fields() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let p = make_passport(); + let id = p.id; + repo.create(p).await.expect("create"); + + let err = repo + .patch_fields( + id, + serde_json::json!({ "retentionLocked": true, "status": "active" }), + ) + .await + .expect_err("protected fields must be rejected"); + assert!( + matches!(err, dpp_domain::DppError::Validation(_)), + "got: {err:?}" + ); + + // The passport is untouched — still a retention-unlocked draft. + let reread = repo.find_by_id(id).await.unwrap().unwrap(); + assert_eq!(reread.status, PassportStatus::Draft); + assert!(!reread.retention_locked); +} + +// A LIKE wildcard in the GTIN must not widen the match to arbitrary passports. +#[tokio::test] +async fn find_published_by_gtin_rejects_like_metacharacters() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + // Publish a passport so there's an active row a wildcard could otherwise hit. + let mut p = make_passport(); + p.status = PassportStatus::Published; + repo.create(p).await.expect("create"); + + for bad in ["%", "_", "not-a-gtin", ""] { + assert!( + repo.find_published_by_gtin(bad).await.unwrap().is_none(), + "non-numeric gtin {bad:?} must never match" + ); + } +} + // T8 — grant coverage: the app role can read every table a migration creates. // Catches the "0010's grants were a snapshot" lesson (0017 had to re-grant): a // migration that adds a table after 0010 must ship its own odal_app grant, or @@ -435,3 +532,262 @@ async fn t8_app_role_can_read_every_table() { .unwrap_or_else(|e| panic!("odal_app cannot SELECT from {table}: {e}")); } } + +// T9 — find_by_identity matches an exact (sector, gtin, batch) across both +// Draft and Published, ignores non-matching rows, and does so via +// 0019_passport_identity_index.sql rather than a sequential scan. +#[tokio::test] +async fn t9_find_by_identity_matches_draft_and_published_via_index() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + + // Enough decoy rows that a seq scan and an index scan would visibly differ + // in the query plan — a handful of rows can fool the planner into a seq + // scan regardless of the index's existence. + for i in 0..200 { + let gtin = format!("{i:013}{}", check_digit_for(&format!("{i:013}"))); + repo.create(battery_passport_with( + >in, + Some("DECOY"), + PassportStatus::Draft, + )) + .await + .expect("create decoy"); + } + + let draft = battery_passport_with("09506000134352", Some("BATCH-D"), PassportStatus::Draft); + let draft_id = draft.id; + repo.create(draft).await.expect("create draft"); + + let published = battery_passport_with("01234567890128", None, PassportStatus::Published); + let published_id = published.id; + repo.create(published).await.expect("create published"); + + let draft_identity = ProductIdentity { + sector: Sector::Battery, + gtin: "09506000134352".into(), + batch_id: Some("BATCH-D".into()), + }; + let found = repo + .find_by_identity(&draft_identity) + .await + .expect("query") + .expect("draft must match"); + assert_eq!(found.id, draft_id); + + // batch_id: None must match only passports with no batch set — not "any batch". + let published_identity = ProductIdentity { + sector: Sector::Battery, + gtin: "01234567890128".into(), + batch_id: None, + }; + let found = repo + .find_by_identity(&published_identity) + .await + .expect("query") + .expect("published must match"); + assert_eq!(found.id, published_id); + + let no_match = ProductIdentity { + sector: Sector::Battery, + gtin: "01234567890128".into(), + batch_id: Some("WRONG-BATCH".into()), + }; + assert!( + repo.find_by_identity(&no_match) + .await + .expect("query") + .is_none(), + "a batch mismatch must not fall back to matching on gtin alone" + ); + + let plan_rows = sqlx::query( + "EXPLAIN SELECT doc FROM odal.passport \ + WHERE status IN ('draft','active') \ + AND sector = 'battery' \ + AND doc->'sectorData'->>'gtin' = '09506000134352' \ + AND doc->>'batchId' IS NOT DISTINCT FROM 'BATCH-D' \ + LIMIT 1", + ) + .fetch_all(pg.dal.pool()) + .await + .expect("explain"); + let plan: String = plan_rows + .iter() + .map(|r| r.get::("QUERY PLAN")) + .collect::>() + .join("\n"); + assert!( + plan.contains("Index Scan") || plan.contains("Bitmap Index Scan"), + "expected idx_passport_identity to be used, got plan:\n{plan}" + ); + assert!( + !plan.contains("Seq Scan"), + "find_by_identity must not fall back to a sequential scan, got plan:\n{plan}" + ); +} + +/// GS1 mod-10 check digit for a 13-digit data prefix — lets the decoy loop +/// generate 200 distinct, individually valid GTIN-14s. +fn check_digit_for(data13: &str) -> u8 { + let digits: Vec = data13.bytes().map(|b| b - b'0').collect(); + dpp_domain::domain::gtin::gs1_check_digit(&digits) +} + +/// A structurally-valid but unsigned dossier — enough to persist/round-trip +/// through `doc JSONB`; these tests exercise storage, not verification. +fn minimal_dossier(passport_id: &str) -> DossierV1 { + DossierV1 { + manifest: DossierManifest { + format_version: "1".into(), + passport_id: passport_id.into(), + issuer_did: "did:web:pg-test.example".into(), + created_at: chrono::Utc::now(), + node_version: "test".into(), + ruleset_version: None, + content_hashes: std::collections::BTreeMap::new(), + }, + manifest_jws: "x.y.z".into(), + full_view: SignedLayer { + payload: serde_json::json!({"passportId": passport_id}), + jws: "x.y.z".into(), + }, + public_view: SignedLayer { + payload: serde_json::json!({"passportId": passport_id}), + jws: "x.y.z".into(), + }, + did_documents: std::collections::BTreeMap::new(), + audit_entries: vec![], + transfer_chain: None, + eol_event: None, + checkpoint: None, + calc_receipts: vec![], + } +} + +// T10 — evidence dossier round trip: insert, get, list summaries. +#[tokio::test] +async fn t10_evidence_dossier_round_trip() { + let pg = start_pg().await; + let passport_repo = PgPassportRepo::new(pg.dal.clone()); + let passport = passport_repo + .create(make_passport()) + .await + .expect("create passport"); + + let evidence = PgEvidenceDossierRepo::new(pg.dal.clone()); + let dossier = minimal_dossier(&passport.id.to_string()); + let record = EvidenceDossierRecord { + id: Uuid::now_v7(), + passport_id: passport.id, + actor: "test".into(), + created_at: chrono::Utc::now(), + doc_hash: "deadbeef".into(), + dossier, + }; + evidence.insert(&record).await.expect("insert"); + + let fetched = evidence + .get(record.id) + .await + .expect("get") + .expect("must exist"); + assert_eq!(fetched.doc_hash, record.doc_hash); + assert_eq!(fetched.actor, "test"); + assert_eq!( + fetched.dossier.manifest.passport_id, + passport.id.to_string() + ); + + let summaries = evidence.list_by_passport(passport.id).await.expect("list"); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, record.id); + + assert!( + evidence.get(Uuid::now_v7()).await.expect("get").is_none(), + "unknown id must return None, not error" + ); +} + +// T11 — evidence dossier rows are immutable at the database layer, and a +// dossier referencing an unknown passport is rejected by the FK. +#[tokio::test] +async fn t11_evidence_dossier_append_only_and_fk_enforced() { + let pg = start_pg().await; + let passport_repo = PgPassportRepo::new(pg.dal.clone()); + let passport = passport_repo + .create(make_passport()) + .await + .expect("create passport"); + + let evidence = PgEvidenceDossierRepo::new(pg.dal.clone()); + let record = EvidenceDossierRecord { + id: Uuid::now_v7(), + passport_id: passport.id, + actor: "test".into(), + created_at: chrono::Utc::now(), + doc_hash: "deadbeef".into(), + dossier: minimal_dossier(&passport.id.to_string()), + }; + evidence.insert(&record).await.expect("insert"); + + let admin = sqlx::postgres::PgPoolOptions::new() + .connect(&pg.admin_url) + .await + .expect("admin"); + let res = sqlx::query("UPDATE odal.evidence_dossier SET actor = 'evil' WHERE id = $1") + .bind(record.id) + .execute(&admin) + .await; + assert!(res.is_err(), "evidence UPDATE must be rejected by trigger"); + let res = sqlx::query("DELETE FROM odal.evidence_dossier WHERE id = $1") + .bind(record.id) + .execute(&admin) + .await; + assert!(res.is_err(), "evidence DELETE must be rejected by trigger"); + + let orphan = EvidenceDossierRecord { + id: Uuid::now_v7(), + passport_id: PassportId::new(), + actor: "test".into(), + created_at: chrono::Utc::now(), + doc_hash: "deadbeef".into(), + dossier: minimal_dossier(&Uuid::now_v7().to_string()), + }; + let res = evidence.insert(&orphan).await; + assert!( + res.is_err(), + "insert for an unknown passport_id must fail the FK constraint" + ); +} + +// T12 — list_by_passport orders newest-first. +#[tokio::test] +async fn t12_evidence_dossier_list_orders_newest_first() { + let pg = start_pg().await; + let passport_repo = PgPassportRepo::new(pg.dal.clone()); + let passport = passport_repo + .create(make_passport()) + .await + .expect("create passport"); + + let evidence = PgEvidenceDossierRepo::new(pg.dal.clone()); + let mk = || EvidenceDossierRecord { + id: Uuid::now_v7(), + passport_id: passport.id, + actor: "test".into(), + created_at: chrono::Utc::now(), + doc_hash: "deadbeef".into(), + dossier: minimal_dossier(&passport.id.to_string()), + }; + let first = mk(); + evidence.insert(&first).await.expect("insert first"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let second = mk(); + evidence.insert(&second).await.expect("insert second"); + + let summaries = evidence.list_by_passport(passport.id).await.expect("list"); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].id, second.id, "newest dossier must be first"); + assert_eq!(summaries[1].id, first.id); +} diff --git a/crates/dpp-identity/src/handlers/did_document.rs b/crates/dpp-identity/src/handlers/did_document.rs index e9a1a4a..46bef2f 100644 --- a/crates/dpp-identity/src/handlers/did_document.rs +++ b/crates/dpp-identity/src/handlers/did_document.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, State}, + extract::State, http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, response::IntoResponse, }; @@ -14,18 +14,16 @@ use crate::state::AppState; /// `must-revalidate` so nothing serves a stale key past that window. const DID_DOCUMENT_CACHE_CONTROL: &str = "public, max-age=60, must-revalidate"; -/// Serve the `did:web` DID document for a given operator. -/// Accessible at `/.well-known/did.json` (root operator) or `/operators/{id}/did.json`. -pub async fn did_document_handler( - State(state): State, - operator_path: Option>, -) -> impl IntoResponse { - let operator_id = match operator_path { - Some(Path(id)) => id, - None => "root".to_owned(), - }; +/// Serve the `did:web` DID document for this node's operator. +/// +/// Mounted only at `/.well-known/did.json`. The node is single-tenant, so there +/// is one operator (`root`) and no per-operator DID route — the previous +/// `Option` per-operator branch was dead code (no such route was ever +/// registered). +pub async fn did_document_handler(State(state): State) -> impl IntoResponse { + let operator_id = "root"; - match did_builder::build_did_document(&state.store, &state.did_web_base_url, &operator_id) { + match did_builder::build_did_document(&state.store, &state.did_web_base_url, operator_id) { Ok(doc) => ( StatusCode::OK, [( diff --git a/crates/dpp-identity/src/handlers/rotate_key.rs b/crates/dpp-identity/src/handlers/rotate_key.rs index 117208d..52c9aeb 100644 --- a/crates/dpp-identity/src/handlers/rotate_key.rs +++ b/crates/dpp-identity/src/handlers/rotate_key.rs @@ -37,13 +37,22 @@ pub async fn rotate_key_handler( State(state): State, Json(body): Json, ) -> impl IntoResponse { - if body.operator_id.is_empty() { - return http_problem::unprocessable("operator_id is required").into_response(); + if !super::sign::is_valid_operator_id(&body.operator_id) { + return http_problem::unprocessable( + "operator_id must be 1-64 characters of [A-Za-z0-9._:-]", + ) + .into_response(); } - // Archive first — best effort; log but don't abort on failure. + // Archive first — and ABORT if it fails. Generating a new key overwrites the + // current record, so proceeding without a successful archive would destroy + // the old key with no backup and permanently invalidate every JWS it signed. if let Err(e) = state.store.archive_key(&body.operator_id) { - tracing::warn!(operator_id = %body.operator_id, error = %e, "failed to archive old key before rotation"); + tracing::error!(operator_id = %body.operator_id, error = %e, "aborting rotation — could not archive the current signing key"); + return http_problem::internal_error( + "could not archive the current signing key; rotation aborted to avoid losing it", + ) + .into_response(); } let new_key = match state.store.generate_key(&body.operator_id) { diff --git a/crates/dpp-identity/src/handlers/sign.rs b/crates/dpp-identity/src/handlers/sign.rs index edd429c..13abb08 100644 --- a/crates/dpp-identity/src/handlers/sign.rs +++ b/crates/dpp-identity/src/handlers/sign.rs @@ -12,6 +12,19 @@ use dpp_crypto::jws::signer; use crate::state::AppState; +/// Whether `operator_id` is a well-formed identifier safe to key the on-disk +/// key store by: 1–64 characters from `[A-Za-z0-9._:-]`. +/// +/// Rejecting anything else bounds key auto-provisioning — a typo, retry, or +/// garbage/oversized value can no longer silently grow the (never-pruned) key +/// store with a brand-new key. +pub(crate) fn is_valid_operator_id(id: &str) -> bool { + (1..=64).contains(&id.len()) + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-')) +} + /// Request body for the signing endpoint. #[derive(Debug, Deserialize)] pub struct SignRequest { @@ -31,9 +44,14 @@ pub async fn sign_handler( State(state): State, Json(body): Json, ) -> impl IntoResponse { - if body.operator_id.is_empty() || body.passport_id.is_empty() || body.payload.is_empty() { - return http_problem::unprocessable("operator_id, passport_id, and payload are required") - .into_response(); + if !is_valid_operator_id(&body.operator_id) { + return http_problem::unprocessable( + "operator_id must be 1-64 characters of [A-Za-z0-9._:-]", + ) + .into_response(); + } + if body.passport_id.is_empty() || body.payload.is_empty() { + return http_problem::unprocessable("passport_id and payload are required").into_response(); } // Decode payload from base64 @@ -67,3 +85,20 @@ pub async fn sign_handler( } } } + +#[cfg(test)] +mod tests { + use super::is_valid_operator_id; + + #[test] + fn operator_id_validation_bounds_provisioning() { + assert!(is_valid_operator_id("self_hosted")); + assert!(is_valid_operator_id("did:web:acme.example")); + // Rejected: empty, too long, or unsafe characters. + assert!(!is_valid_operator_id("")); + assert!(!is_valid_operator_id(&"x".repeat(65))); + for bad in ["../etc", "a b", "a/b", "a\nb", "a;b"] { + assert!(!is_valid_operator_id(bad), "should reject: {bad}"); + } + } +} diff --git a/crates/dpp-identity/src/middleware/mtls.rs b/crates/dpp-identity/src/middleware/mtls.rs index 2258f8d..7dbad5c 100644 --- a/crates/dpp-identity/src/middleware/mtls.rs +++ b/crates/dpp-identity/src/middleware/mtls.rs @@ -52,6 +52,46 @@ fn required_issuer_cn() -> String { std::env::var("MTLS_REQUIRED_ISSUER_CN").unwrap_or_else(|_| "Odal Internal CA".to_owned()) } +/// Header the terminating proxy sets to prove that *it* — not a client that +/// reached this listener directly — is the origin of the forwarded cert headers. +pub const PROXY_AUTH_HEADER: &str = "X-Proxy-Auth"; + +/// Constant-time byte comparison, so a wrong secret can't be recovered by timing. +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + diff == 0 +} + +/// Bind trust in the forwarded `X-Client-Cert-*` headers to the terminating +/// proxy. When `MTLS_PROXY_SHARED_SECRET` is configured, the request must carry +/// a matching [`PROXY_AUTH_HEADER`], so a caller that reaches this listener +/// directly (bypassing the proxy, e.g. a network misconfiguration) cannot forge +/// the cert headers. When the secret is unset, binding is disabled — logged as a +/// warning so the deployment gap is visible rather than silent. +fn proxy_binding_ok(request: &Request) -> bool { + let secret = match std::env::var("MTLS_PROXY_SHARED_SECRET") { + Ok(s) if !s.is_empty() => s, + _ => { + tracing::warn!( + "mTLS: MTLS_PROXY_SHARED_SECRET is not set — forwarded client-certificate \ + headers are trusted without proxy binding (set it in production)" + ); + return true; + } + }; + request + .headers() + .get(PROXY_AUTH_HEADER) + .and_then(|h| h.to_str().ok()) + .is_some_and(|presented| ct_eq(presented.as_bytes(), secret.as_bytes())) +} + /// Extract the value of the `CN=` component from an RFC 4514 subject DN string. /// /// Matches both `CN=foo` and `cn=foo` (case-insensitive key). @@ -82,6 +122,15 @@ pub async fn mtls_middleware(request: Request, next: Next) -> Response { .unwrap_or(false); let enforce = !allow_insecure; + // Before trusting any forwarded cert header, confirm the request actually + // came through the terminating proxy (when a binding secret is configured). + if enforce && !proxy_binding_ok(&request) { + tracing::warn!("mTLS: rejecting request — missing or invalid proxy binding secret"); + return Problem::new(StatusCode::UNAUTHORIZED, "Unauthorized") + .with_detail("Request did not arrive through the trusted terminating proxy.") + .into_response(); + } + match request.headers().get(CLIENT_CERT_SUBJECT_HEADER) { Some(subject) => { let subject_str = subject.to_str().unwrap_or(""); @@ -259,6 +308,52 @@ mod http_tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + /// Proxy secret configured, but the request carries no `X-Proxy-Auth` — even + /// with otherwise-valid cert headers it is rejected (didn't come via proxy). + #[tokio::test] + #[serial] + async fn proxy_secret_configured_rejects_unbound_request() { + unsafe { std::env::set_var("MTLS_PROXY_SHARED_SECRET", "s3cr3t") }; + unsafe { std::env::set_var("MTLS_REQUIRED_ISSUER_CN", "Odal Internal CA") }; + let response = build_test_router() + .oneshot( + Request::builder() + .uri("/test") + .header(CLIENT_CERT_SUBJECT_HEADER, "CN=odal-vault, O=Odal") + .header(CLIENT_CERT_ISSUER_HEADER, "CN=Odal Internal CA, O=Odal") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + unsafe { std::env::remove_var("MTLS_PROXY_SHARED_SECRET") }; + unsafe { std::env::remove_var("MTLS_REQUIRED_ISSUER_CN") }; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + /// Proxy secret configured and the matching `X-Proxy-Auth` present → 200. + #[tokio::test] + #[serial] + async fn proxy_secret_configured_allows_bound_request() { + unsafe { std::env::set_var("MTLS_PROXY_SHARED_SECRET", "s3cr3t") }; + unsafe { std::env::set_var("MTLS_REQUIRED_ISSUER_CN", "Odal Internal CA") }; + let response = build_test_router() + .oneshot( + Request::builder() + .uri("/test") + .header(PROXY_AUTH_HEADER, "s3cr3t") + .header(CLIENT_CERT_SUBJECT_HEADER, "CN=odal-vault, O=Odal") + .header(CLIENT_CERT_ISSUER_HEADER, "CN=Odal Internal CA, O=Odal") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + unsafe { std::env::remove_var("MTLS_PROXY_SHARED_SECRET") }; + unsafe { std::env::remove_var("MTLS_REQUIRED_ISSUER_CN") }; + assert_eq!(response.status(), StatusCode::OK); + } + /// Correct CN and correct issuer → 200. #[tokio::test] #[serial] diff --git a/crates/dpp-integrator/Cargo.toml b/crates/dpp-integrator/Cargo.toml index f2a4024..a09f8dc 100644 --- a/crates/dpp-integrator/Cargo.toml +++ b/crates/dpp-integrator/Cargo.toml @@ -36,6 +36,7 @@ tracing-subscriber = { workspace = true } metrics = { workspace = true } reqwest = { workspace = true } base64 = { workspace = true } +sha2 = { workspace = true } calamine = { workspace = true } csv = { workspace = true } # Direct, version-matched to calamine's own deps (single version in the lockfile, diff --git a/crates/dpp-integrator/src/domain/batch_runner.rs b/crates/dpp-integrator/src/domain/batch_runner.rs index aa7ea93..38fdde9 100644 --- a/crates/dpp-integrator/src/domain/batch_runner.rs +++ b/crates/dpp-integrator/src/domain/batch_runner.rs @@ -1,11 +1,14 @@ -//! Concurrent batch runner — fans out validated passport rows to `dpp-vault`. +//! Concurrent batch runner — fans out validated passport rows to `dpp-vault`, +//! branching per row on the delta-matcher's classification. +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Semaphore; use tracing; use crate::{ + domain::matcher::{Classification, RowAction}, domain::request::CreatePassportRequest, infra::vault_client::{VaultClientError, VaultHttpClient}, }; @@ -21,6 +24,15 @@ pub struct CreatedItem { pub passport_id: String, } +/// A successfully updated draft passport entry in the batch result. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UpdatedItem { + /// 1-based row number from the uploaded file. + pub row: usize, + /// The matched passport's id. + pub passport_id: String, +} + /// A row-level error recorded during the batch run. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RowError { @@ -37,21 +49,36 @@ pub struct RowError { pub struct BatchResult { /// Rows that were successfully sent to the vault and created as passports. pub created: Vec, - /// Rows that failed validation, auth, or vault creation. + /// Rows that matched an existing draft and were updated in place. + pub updated: Vec, + /// Rows that failed validation, auth, or vault creation/update. pub errors: Vec, } // ─── Runner ─────────────────────────────────────────────────────────────────── -/// Fan out a batch of validated passport requests to the vault service. +enum RowOutcome { + Created(String), + Updated(String), +} + +/// Fan out a batch of validated passport requests to the vault service, +/// branching per row on `classifications`. `Unchanged` and +/// `ConflictPublished` rows make zero vault calls — the report already names +/// what would happen to them; a row missing from `classifications` (should +/// not happen — every valid row gets classified) defaults to `Create`. /// /// - Maximum `concurrency` requests run concurrently (Tokio semaphore). /// - Vault `429` responses are retried with exponential backoff (max 3 attempts). /// - Vault `422` responses are recorded as row errors; the batch continues. /// - Vault `5xx` responses are recorded as row errors. -#[tracing::instrument(skip(valid_rows, vault_client, auth_token), fields(row_count = valid_rows.len()))] +#[tracing::instrument( + skip(valid_rows, classifications, vault_client, auth_token), + fields(row_count = valid_rows.len()) +)] pub async fn run_batch( valid_rows: Vec<(usize, CreatePassportRequest)>, + classifications: &HashMap, vault_client: &VaultHttpClient, auth_token: &str, concurrency: usize, @@ -60,33 +87,69 @@ pub async fn run_batch( let mut handles = Vec::with_capacity(valid_rows.len()); for (row_num, req) in valid_rows { + let classification = classifications + .get(&row_num) + .cloned() + .unwrap_or(Classification { + action: RowAction::Create, + existing_id: None, + }); + if matches!( + classification.action, + RowAction::Unchanged | RowAction::ConflictPublished + ) { + continue; // zero vault calls — the report already names this row's action + } + let sem = sem.clone(); let client = vault_client.clone(); let token = auth_token.to_owned(); handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.expect("semaphore closed unexpectedly"); - let result = retry_create(&client, &req, &token).await; - (row_num, result) + let outcome = match classification.action { + RowAction::UpdateDraft => { + let id = classification + .existing_id + .expect("update_draft classification always carries the matched id"); + retry_update(&client, &id, &req, &token) + .await + .map(|_| RowOutcome::Updated(id)) + } + _ => retry_create(&client, &req, &token).await.and_then(|body| { + match body.get("id").and_then(|v| v.as_str()) { + // A 2xx response must carry a non-empty passport id; + // recording a missing/empty id as a success would report + // an unusable empty id and overstate success_count. + Some(id) if !id.is_empty() => Ok(RowOutcome::Created(id.to_owned())), + _ => Err(VaultClientError::Parse( + "vault returned success without a passport id".into(), + )), + } + }), + }; + (row_num, outcome) })); } let mut created: Vec = Vec::new(); + let mut updated: Vec = Vec::new(); let mut errors: Vec = Vec::new(); for handle in handles { match handle.await { - Ok((row_num, Ok(body))) => { - let passport_id = body - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); + Ok((row_num, Ok(RowOutcome::Created(passport_id)))) => { created.push(CreatedItem { row: row_num, passport_id, }); } + Ok((row_num, Ok(RowOutcome::Updated(passport_id)))) => { + updated.push(UpdatedItem { + row: row_num, + passport_id, + }); + } Ok((row_num, Err(VaultClientError::Validation(msg)))) => { errors.push(RowError { row: row_num, @@ -118,7 +181,11 @@ pub async fn run_batch( } } - BatchResult { created, errors } + BatchResult { + created, + updated, + errors, + } } // ─── Retry logic ───────────────────────────────────────────────────────────── @@ -146,3 +213,27 @@ async fn retry_create( Err(VaultClientError::RateLimit) } + +/// Same retry contract as `retry_create`, for the `update_draft` action. +async fn retry_update( + client: &VaultHttpClient, + id: &str, + req: &CreatePassportRequest, + token: &str, +) -> Result { + const MAX_RETRIES: u32 = 3; + const BASE_DELAY_MS: u64 = 100; + + for attempt in 0..MAX_RETRIES { + match client.update_passport(id, req, token).await { + Ok(resp) => return Ok(resp), + Err(VaultClientError::RateLimit) if attempt < MAX_RETRIES - 1 => { + let delay = BASE_DELAY_MS * (1u64 << attempt); + tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await; + } + Err(e) => return Err(e), + } + } + + Err(VaultClientError::RateLimit) +} diff --git a/crates/dpp-integrator/src/domain/fields.rs b/crates/dpp-integrator/src/domain/fields.rs index 8a98895..503f614 100644 --- a/crates/dpp-integrator/src/domain/fields.rs +++ b/crates/dpp-integrator/src/domain/fields.rs @@ -2,10 +2,31 @@ use std::collections::HashMap; +use dpp_domain::domain::gtin::Gtin; use dpp_domain::domain::passport::MaterialEntry; use super::request::RowError; +/// Push a `RowError` if `gtin` (when present) is not a structurally valid GS1 +/// GTIN-14 (14 digits + mod-10 check digit). Shared by the sector importers so +/// steel/aluminium/tyre validate the checksum the same way the battery importer +/// already does — a bad checksum must not pass through the pipeline unchecked. +pub(super) fn validate_gtin_checksum( + gtin: Option<&str>, + row_num: usize, + errors: &mut Vec, +) { + if let Some(g) = gtin + && let Err(e) = Gtin::parse(g) + { + errors.push(RowError { + row: row_num, + field: "gtin".into(), + message: e.to_string(), + }); + } +} + /// Normalize a header key for case/separator-insensitive matching: drop /// non-alphanumerics (`_`, `-`, spaces) and lowercase. So `manufacturerName`, /// `manufacturer_name`, and `Manufacturer Name` all map to `manufacturername`. diff --git a/crates/dpp-integrator/src/domain/import_report.rs b/crates/dpp-integrator/src/domain/import_report.rs new file mode 100644 index 0000000..67f2a13 --- /dev/null +++ b/crates/dpp-integrator/src/domain/import_report.rs @@ -0,0 +1,75 @@ +//! The persisted, row-addressed report every import job produces. +//! +//! Replaces the old dry-run behaviour of returning validation errors +//! synchronously with nothing stored: every import — sync or async, dry-run +//! or apply — now mints a job id and persists one [`ReportRow`] per input +//! row, retrievable via `GET /api/v1/imports/{jobId}`. + +use serde::{Deserialize, Serialize}; + +use crate::domain::matcher::RowAction; + +/// Which pass produced a report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImportMode { + /// Validate only — never calls the vault, never creates or touches a + /// passport. + DryRun, + /// Validate, then act on each valid row's `action` — create, update the + /// matched draft, skip (unchanged or conflict-published, report-only). + Apply, +} + +/// Whether a row finding came from schema/field validation (blocking — the +/// row is not created) or the `dpp-rules` plausibility lint pack (advisory — +/// never blocks, same non-gating contract as N10's `Passport::lint_result`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FindingKind { + Validation, + Lint, +} + +/// A single field-level finding on one row. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RowFinding { + pub kind: FindingKind, + pub field: String, + pub message: String, + /// Set only for `Lint` findings — validation findings have no severity + /// tier of their own (they simply block the row). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub severity: Option, +} + +/// One row's validation outcome. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportRow { + /// 1-based row number from the uploaded file. + pub row: usize, + pub valid: bool, + /// The delta-matcher's classification — `Some` only for valid rows + /// (there is nothing to classify for a row that failed validation). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + /// The matched passport's id, when `action` is `updateDraft`, + /// `conflictPublished`, or `unchanged` (all three matched something; + /// `create` didn't). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub existing_passport_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub findings: Vec, +} + +/// The full row-addressed report for one import job — the dry-run report and +/// the apply report share this shape (only `mode` differs). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportReport { + pub mode: ImportMode, + pub total_rows: usize, + pub rows: Vec, +} diff --git a/crates/dpp-integrator/src/domain/matcher.rs b/crates/dpp-integrator/src/domain/matcher.rs new file mode 100644 index 0000000..fb96ef0 --- /dev/null +++ b/crates/dpp-integrator/src/domain/matcher.rs @@ -0,0 +1,186 @@ +//! Delta-import matcher: classifies each valid row against existing passports +//! by exact compound identity (sector, GTIN, batch), so a re-uploaded sheet +//! updates what changed instead of creating duplicates. +//! +//! Classification names what should happen to each row +//! (`create`/`update_draft`/`conflict_published`/`unchanged`); that name (and, +//! for a match, the existing passport's id) is persisted into the import +//! report for both dry-run and apply, and `batch_runner::run_batch` reads it +//! back to decide what to actually write in apply mode. + +use std::collections::HashMap; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; +use tokio::sync::Semaphore; + +use dpp_domain::domain::product_identity::ProductIdentity; + +use crate::domain::request::CreatePassportRequest; +use crate::infra::vault_client::{VaultClientError, VaultHttpClient}; + +/// What should happen to a row, decided by identity match + content hash. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowAction { + /// No existing passport matches this identity. + Create, + /// Matches an existing `Draft` passport with different content. + UpdateDraft, + /// Matches an existing `Published` passport with different content. + /// Detect-and-report only — never mutated by the importer. + ConflictPublished, + /// Matches an existing passport (`Draft` or `Published`) with identical + /// content — nothing to do. + Unchanged, +} + +/// A row's classification, plus the matched passport's id when there is one +/// (`UpdateDraft`/`ConflictPublished`/`Unchanged` all matched *something*; +/// `Create` didn't). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Classification { + pub action: RowAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub existing_id: Option, +} + +/// Fields that define a row's content for change detection. Deliberately +/// excludes `co2ePerUnit`/`repairabilityScore`: `CreatePassportRequest` +/// carries these as bare numbers but the persisted `Passport` carries them as +/// computed objects (e.g. `CarbonFootprint`), so comparing them directly +/// would always report a spurious change. `sectorData` already carries the +/// source values these are usually derived from. +const COMPARABLE_FIELDS: &[&str] = &[ + "productName", + "sector", + "manufacturer", + "materials", + "sectorData", + "batchId", +]; + +/// Hash the comparable subset of a request or passport JSON body, so a +/// `CreatePassportRequest` and the persisted `Passport` it matches can be +/// compared without needing the same Rust type on both sides. +pub fn content_hash(value: &serde_json::Value) -> String { + let mut canonical = serde_json::Map::new(); + for &field in COMPARABLE_FIELDS { + if let Some(v) = value.get(field) { + canonical.insert(field.to_owned(), v.clone()); + } + } + let bytes = serde_json::to_vec(&serde_json::Value::Object(canonical)).unwrap_or_default(); + format!("{:x}", Sha256::digest(&bytes)) +} + +/// Derive the compound identity from a not-yet-created request, or `None` if +/// its sector carries no GTIN (mirrors `ProductIdentity::from_passport`, but +/// operates on the pre-create request shape). +pub fn identity_from_request(req: &CreatePassportRequest) -> Option { + let sector = req.sector.clone().or_else(|| { + req.sector_data + .as_ref() + .map(dpp_domain::domain::sector::SectorData::sector) + })?; + let gtin = req.sector_data.as_ref()?.gtin()?.to_owned(); + Some(ProductIdentity { + sector, + gtin, + batch_id: req.batch_id.clone(), + }) +} + +/// Classify one row against the vault's existing passports. +/// +/// Rows whose sector carries no GTIN (no identity derivable) always classify +/// as `Create` — there is nothing to match against. +pub async fn classify_row( + vault_client: &VaultHttpClient, + req: &CreatePassportRequest, + auth_token: &str, +) -> Result { + let Some(identity) = identity_from_request(req) else { + return Ok(Classification { + action: RowAction::Create, + existing_id: None, + }); + }; + + let existing = match vault_client.find_by_identity(&identity, auth_token).await? { + Some(p) => p, + None => { + return Ok(Classification { + action: RowAction::Create, + existing_id: None, + }); + } + }; + let existing_id = existing + .get("id") + .and_then(|v| v.as_str()) + .map(str::to_owned); + + let req_value = serde_json::to_value(req).unwrap_or_default(); + if content_hash(&req_value) == content_hash(&existing) { + return Ok(Classification { + action: RowAction::Unchanged, + existing_id, + }); + } + + let action = match existing.get("status").and_then(|s| s.as_str()) { + Some("active") => RowAction::ConflictPublished, + _ => RowAction::UpdateDraft, + }; + Ok(Classification { + action, + existing_id, + }) +} + +/// Classify a batch of valid rows concurrently (same bounded-concurrency +/// pattern as `batch_runner::run_batch`, since this is the same kind of +/// per-row vault HTTP call). A row whose classification call fails (network, +/// auth, vault error) falls back to `Create` — the same "nothing matched" +/// outcome as a genuine no-match, so a transient lookup failure degrades to +/// the always-safe default (a possible duplicate, never a missed conflict) +/// rather than silently dropping the row from the report. +pub async fn classify_batch( + rows: &[(usize, CreatePassportRequest)], + vault_client: &VaultHttpClient, + auth_token: &str, + concurrency: usize, +) -> HashMap { + let sem = Arc::new(Semaphore::new(concurrency.max(1))); + let mut handles = Vec::with_capacity(rows.len()); + + for (row_num, req) in rows { + let sem = sem.clone(); + let client = vault_client.clone(); + let token = auth_token.to_owned(); + let req = req.clone(); + let row_num = *row_num; + + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed unexpectedly"); + let classification = + classify_row(&client, &req, &token) + .await + .unwrap_or(Classification { + action: RowAction::Create, + existing_id: None, + }); + (row_num, classification) + })); + } + + let mut result = HashMap::with_capacity(rows.len()); + for handle in handles { + if let Ok((row_num, classification)) = handle.await { + result.insert(row_num, classification); + } + } + result +} diff --git a/crates/dpp-integrator/src/domain/mod.rs b/crates/dpp-integrator/src/domain/mod.rs index 59a0ab1..5beca99 100644 --- a/crates/dpp-integrator/src/domain/mod.rs +++ b/crates/dpp-integrator/src/domain/mod.rs @@ -3,6 +3,8 @@ pub mod batch_runner; pub mod csv_parser; pub mod fields; +pub mod import_report; +pub mod matcher; pub mod request; pub mod validate; pub mod xlsx_parser; diff --git a/crates/dpp-integrator/src/domain/request.rs b/crates/dpp-integrator/src/domain/request.rs index 1a234d5..63ab1fa 100644 --- a/crates/dpp-integrator/src/domain/request.rs +++ b/crates/dpp-integrator/src/domain/request.rs @@ -17,7 +17,7 @@ pub struct RowError { /// Serialisable request body sent to `POST /api/v1/dpp` on the vault service. /// /// Shape must match `dpp-vault::handlers::create::CreateRequest`. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreatePassportRequest { pub product_name: String, diff --git a/crates/dpp-integrator/src/domain/validate/aluminium.rs b/crates/dpp-integrator/src/domain/validate/aluminium.rs index f67b3b3..d5cf9bd 100644 --- a/crates/dpp-integrator/src/domain/validate/aluminium.rs +++ b/crates/dpp-integrator/src/domain/validate/aluminium.rs @@ -7,7 +7,9 @@ use dpp_domain::domain::{ sector::{AluminiumData, ProductionRoute, Sector, SectorData}, }; -use crate::domain::fields::{optional_f64, optional_str, require_f64, require_str}; +use crate::domain::fields::{ + optional_f64, optional_str, require_f64, require_str, validate_gtin_checksum, +}; use crate::domain::request::{CreatePassportRequest, RowError}; /// Validate a single aluminium row and convert it to a vault `CreatePassportRequest`. @@ -27,6 +29,7 @@ pub fn validate_aluminium_row( let manufacturer_name = require_str(row, "manufacturerName", row_num, &mut errors); let manufacturer_country = require_str(row, "manufacturerCountry", row_num, &mut errors); let gtin = require_str(row, "gtin", row_num, &mut errors); + validate_gtin_checksum(gtin.as_deref(), row_num, &mut errors); let alloy_grade = require_str(row, "alloyGrade", row_num, &mut errors); let production_route_raw = require_str(row, "productionRoute", row_num, &mut errors); let co2e = require_f64(row, "co2ePerTonneKg", row_num, &mut errors); diff --git a/crates/dpp-integrator/src/domain/validate/steel.rs b/crates/dpp-integrator/src/domain/validate/steel.rs index c122086..878f260 100644 --- a/crates/dpp-integrator/src/domain/validate/steel.rs +++ b/crates/dpp-integrator/src/domain/validate/steel.rs @@ -7,7 +7,9 @@ use dpp_domain::domain::{ sector::{ProductionRoute, Sector, SectorData, SteelData}, }; -use crate::domain::fields::{optional_f64, optional_str, require_f64, require_str}; +use crate::domain::fields::{ + optional_f64, optional_str, require_f64, require_str, validate_gtin_checksum, +}; use crate::domain::request::{CreatePassportRequest, RowError}; /// Validate a single steel row and convert it to a vault `CreatePassportRequest`. @@ -27,6 +29,7 @@ pub fn validate_steel_row( let manufacturer_name = require_str(row, "manufacturerName", row_num, &mut errors); let manufacturer_country = require_str(row, "manufacturerCountry", row_num, &mut errors); let gtin = require_str(row, "gtin", row_num, &mut errors); + validate_gtin_checksum(gtin.as_deref(), row_num, &mut errors); let co2e = require_f64(row, "co2ePerTonneSteel", row_num, &mut errors); let recycled = require_f64(row, "recycledScrapContentPct", row_num, &mut errors); let product_category = require_str(row, "productCategory", row_num, &mut errors); @@ -119,4 +122,14 @@ mod tests { let errs = validate_steel_row(&row, 2).expect_err("should fail"); assert!(errs.iter().any(|e| e.field == "co2ePerTonneSteel")); } + + #[test] + fn steel_row_bad_gtin_checksum_returns_error() { + // Right length/digits but a wrong GS1 mod-10 check digit — this must be + // caught (matching the battery importer), not passed through. + let mut row = steel_row(); + row.insert("gtin".into(), "09506000134353".into()); // valid is ...352 + let errs = validate_steel_row(&row, 3).expect_err("bad GTIN checksum must fail"); + assert!(errs.iter().any(|e| e.field == "gtin")); + } } diff --git a/crates/dpp-integrator/src/domain/validate/textile.rs b/crates/dpp-integrator/src/domain/validate/textile.rs index 867b7df..3a9b629 100644 --- a/crates/dpp-integrator/src/domain/validate/textile.rs +++ b/crates/dpp-integrator/src/domain/validate/textile.rs @@ -18,6 +18,7 @@ pub fn validate_textile_row( let mut errors: Vec = Vec::new(); let product_name = require_str(row, "productName", row_num, &mut errors); + let gtin = require_str(row, "gtin", row_num, &mut errors); let batch_id = require_str(row, "batchId", row_num, &mut errors); let manufacturer_name = require_str(row, "manufacturerName", row_num, &mut errors); let manufacturer_country = require_str(row, "manufacturerCountry", row_num, &mut errors); @@ -60,6 +61,7 @@ pub fn validate_textile_row( } let textile_data = SectorData::Textile(TextileData { + gtin: gtin.expect("field verified present by errors.is_empty() guard above"), fibre_composition: fibres.expect("field verified present by errors.is_empty() guard above"), country_of_manufacturing: country_of_manufacturing .expect("field verified present by errors.is_empty() guard above"), @@ -126,6 +128,7 @@ mod tests { fn snake_case_textile_row_validates_via_normalized_lookup() { let row = HashMap::from([ ("product_name".to_string(), "Organic Cotton Tee".to_string()), + ("gtin".to_string(), "09506000134352".to_string()), ("batch_id".to_string(), "BATCH-T-001".to_string()), ("manufacturer_name".to_string(), "EcoWear".to_string()), ("manufacturer_country".to_string(), "BD".to_string()), @@ -152,6 +155,7 @@ mod tests { fn textile_row() -> HashMap { HashMap::from([ ("productName".into(), "Organic Cotton Tee".into()), + ("gtin".into(), "09506000134352".into()), ("batchId".into(), "BATCH-T-001".into()), ("manufacturerName".into(), "EcoWear".into()), ("manufacturerCountry".into(), "BD".into()), @@ -172,6 +176,7 @@ mod tests { assert_eq!(req.product_name, "Organic Cotton Tee"); match req.sector_data.unwrap() { SectorData::Textile(t) => { + assert_eq!(t.gtin, "09506000134352"); assert_eq!(t.fibre_composition.len(), 1); assert_eq!(t.fibre_composition[0].fibre, "cotton"); } diff --git a/crates/dpp-integrator/src/domain/validate/tyre.rs b/crates/dpp-integrator/src/domain/validate/tyre.rs index 3022601..1aca276 100644 --- a/crates/dpp-integrator/src/domain/validate/tyre.rs +++ b/crates/dpp-integrator/src/domain/validate/tyre.rs @@ -7,7 +7,9 @@ use dpp_domain::domain::{ sector::{Sector, SectorData, TyreData}, }; -use crate::domain::fields::{optional_f64, optional_str, require_f64, require_str}; +use crate::domain::fields::{ + optional_f64, optional_str, require_f64, require_str, validate_gtin_checksum, +}; use crate::domain::request::{CreatePassportRequest, RowError}; /// Validate a single tyre row and convert it to a vault `CreatePassportRequest`. @@ -28,6 +30,7 @@ pub fn validate_tyre_row( let manufacturer_name = require_str(row, "manufacturerName", row_num, &mut errors); let manufacturer_country = require_str(row, "manufacturerCountry", row_num, &mut errors); let gtin = require_str(row, "gtin", row_num, &mut errors); + validate_gtin_checksum(gtin.as_deref(), row_num, &mut errors); let tyre_class = require_str(row, "tyreClass", row_num, &mut errors); let fuel_class = require_str(row, "fuelEfficiencyClass", row_num, &mut errors); let wet_class = require_str(row, "wetGripClass", row_num, &mut errors); diff --git a/crates/dpp-integrator/src/handlers/import.rs b/crates/dpp-integrator/src/handlers/import.rs index 8d96041..686b057 100644 --- a/crates/dpp-integrator/src/handlers/import.rs +++ b/crates/dpp-integrator/src/handlers/import.rs @@ -10,10 +10,14 @@ use dpp_common::http_problem::{self, Problem}; use serde::Serialize; use uuid::Uuid; +use std::collections::HashMap; + use crate::{ domain::{ batch_runner::{BatchResult, run_batch}, csv_parser, + import_report::{FindingKind, ImportMode, ImportReport, ReportRow, RowFinding}, + matcher::{self, Classification}, request::CreatePassportRequest, validate::{self, RowValidationError}, xlsx_parser, @@ -32,14 +36,22 @@ const SYNC_THRESHOLD: usize = 100; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncImportResponse { + /// Id of the import job this response's report was persisted under — + /// every import now mints one, sync or async, so the report is + /// retrievable later via `GET /api/v1/imports/{jobId}`. + pub job_id: String, /// Total data rows in the uploaded file (excluding header). pub total_rows: usize, - /// Number of rows that were successfully created as passports. + /// Rows that did not error: created + updated + unchanged + + /// conflict-published (the last two make no vault call but are not + /// failures either — see the persisted report for the per-row breakdown). pub success_count: usize, - /// Number of rows that failed validation or vault creation. + /// Number of rows that failed validation or vault creation/update. pub error_count: usize, - /// Successfully created passports with their row positions. + /// Newly created passports with their row positions. pub created: Vec, + /// Rows that matched an existing draft and were updated in place. + pub updated: Vec, /// Per-row errors with field names and messages. pub errors: Vec, } @@ -56,6 +68,16 @@ pub struct CreatedEntry { pub status: String, } +/// A successfully updated draft passport in the sync response. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatedEntry { + /// 1-based row number from the uploaded file. + pub row: usize, + /// The matched passport's id. + pub passport_id: String, +} + /// A per-row import error in the sync or dry-run response. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -86,8 +108,15 @@ pub struct AsyncImportResponse { /// `POST /api/v1/import/{sector}` /// /// Accepts a `multipart/form-data` upload with the following fields: -/// - `file` — CSV or XLSX file (required) -/// - `dry_run` — `"true"` or `"1"` to validate without creating records +/// - `file` — CSV or XLSX file (required) +/// - `mode` — `"dry_run"` to validate without creating records; any other +/// value (or the field's absence) means `"apply"`, today's only write +/// behaviour (create-only — delta upsert lands in a later segment). +/// +/// Every import — dry-run or apply, sync or async — mints a job id and +/// persists a row-addressed report retrievable via `GET +/// /api/v1/imports/{jobId}`; the dry-run report and the apply report share +/// the same shape. /// /// The caller's `Authorization: Bearer` JWT is forwarded to the vault service. pub async fn import_file( @@ -133,7 +162,7 @@ pub async fn import_file( // Parse multipart fields let mut file_bytes: Option> = None; let mut is_xlsx = false; - let mut dry_run = false; + let mut mode = ImportMode::Apply; loop { match multipart.next_field().await { @@ -168,9 +197,12 @@ pub async fn import_file( } } } - "dry_run" => { + "mode" => { let val = field.text().await.unwrap_or_default(); - dry_run = matches!(val.trim(), "true" | "1"); + mode = match val.trim() { + "dry_run" | "dryRun" => ImportMode::DryRun, + _ => ImportMode::Apply, + }; } _ => { // Unknown fields are silently ignored @@ -256,15 +288,88 @@ pub async fn import_file( } } + // Every import — dry-run or apply, sync or async — mints a job id and + // persists a row-addressed report, so it's retrievable via + // GET /api/v1/imports/{jobId} even when the response below is synchronous. + // Gate on the job actually being persisted: returning a job id the caller + // can never poll is worse than failing. + let job_id = Uuid::now_v7(); + if let Err(e) = state + .job_store + .insert(ImportJob::new(job_id, total_rows)) + .await + { + tracing::error!(%job_id, error = %e, "could not create import job"); + return http_problem::internal_error("Could not create the import job. Please retry.") + .into_response(); + } + // Advisory plausibility lint on every accepted row — non-gating + // (never removes a row from valid_rows, never invalidates it), same + // contract as the vault's own lint step. + let mut lint_findings: std::collections::HashMap> = + std::collections::HashMap::new(); + for (row_num, req) in &valid_rows { + let Some(ref sd) = req.sector_data else { + continue; + }; + let findings = dpp_domain::lint_sector_data(sd, chrono::Utc::now()); + if !findings.is_empty() { + lint_findings.insert( + *row_num, + findings + .into_iter() + .map(|f| RowFinding { + kind: FindingKind::Lint, + field: f.field, + message: f.message, + severity: Some(f.severity), + }) + .collect(), + ); + } + } + + // Delta-matcher: classify each valid row against existing passports by + // exact identity (sector, GTIN, batch). Apply's write path below reads + // this same map back to decide create/update/skip per row. + let classifications = matcher::classify_batch( + &valid_rows, + &state.vault_client, + &auth_token, + state.batch_concurrency, + ) + .await; + + let report = ImportReport { + mode, + total_rows, + rows: build_report_rows( + total_rows, + &valid_rows, + &row_errors, + lint_findings, + &classifications, + ), + }; + if let Err(e) = state.job_store.record_report(job_id, report).await { + tracing::error!(%job_id, error = %e, "failed to persist import report"); + } + // Dry run: return validation report without creating anything - if dry_run { + if mode == ImportMode::DryRun { + let _ = state + .job_store + .set_status(job_id, JobStatus::Completed) + .await; return ( StatusCode::OK, Json(SyncImportResponse { + job_id: job_id.to_string(), total_rows, success_count: 0, error_count: row_errors.len(), created: vec![], + updated: vec![], errors: row_errors, }), ) @@ -275,22 +380,28 @@ pub async fn import_file( if valid_rows.len() <= SYNC_THRESHOLD { let batch = run_batch( valid_rows, + &classifications, &state.vault_client, &auth_token, state.batch_concurrency, ) .await; - let (created, batch_errors) = batch_result_into_entries(batch); + let (created, updated, batch_errors) = batch_result_into_entries(batch.clone()); let mut all_errors = row_errors; all_errors.extend(batch_errors); + if let Err(e) = state.job_store.complete(job_id, batch).await { + tracing::error!(%job_id, error = %e, "failed to record import job completion"); + } return ( StatusCode::OK, Json(SyncImportResponse { + job_id: job_id.to_string(), total_rows, - success_count: created.len(), + success_count: total_rows - all_errors.len(), error_count: all_errors.len(), created, + updated, errors: all_errors, }), ) @@ -298,19 +409,6 @@ pub async fn import_file( } // ── Async path (> SYNC_THRESHOLD rows) ─────────────────────────────────── - let job_id = Uuid::now_v7(); - // Gate the 202 on the job actually being persisted. If we can't store the - // job, returning a job id the caller can never poll is worse than failing. - if let Err(e) = state - .job_store - .insert(ImportJob::new(job_id, total_rows)) - .await - { - tracing::error!(%job_id, error = %e, "could not create import job"); - return http_problem::internal_error("Could not create the import job. Please retry.") - .into_response(); - } - let vault_client = state.vault_client.clone(); let job_store = state.job_store.clone(); let concurrency = state.batch_concurrency; @@ -319,7 +417,14 @@ pub async fn import_file( if let Err(e) = job_store.set_status(job_id, JobStatus::Processing).await { tracing::error!(%job_id, error = %e, "failed to mark import job processing"); } - let batch = run_batch(valid_rows, &vault_client, &auth_token, concurrency).await; + let batch = run_batch( + valid_rows, + &classifications, + &vault_client, + &auth_token, + concurrency, + ) + .await; if let Err(e) = job_store.complete(job_id, batch).await { tracing::error!(%job_id, error = %e, "failed to record import job completion"); } @@ -351,7 +456,54 @@ pub(crate) fn extract_bearer_token(headers: &HeaderMap) -> Option { .map(|s| s.to_owned()) } -fn batch_result_into_entries(result: BatchResult) -> (Vec, Vec) { +/// Build the persisted, row-addressed report from this pass's validation, +/// lint, and matcher results. Every row from `1..=total_rows` lands in +/// exactly one of `valid_rows`/`row_errors` (the validation loop above +/// guarantees it), so this reconstructs a complete per-row outcome without a +/// third pass over the raw rows. `lint_findings` and `classifications` only +/// ever have entries for rows already in `valid_rows`; lint is advisory +/// (appends findings, never flips `valid` to `false`). `classifications` is +/// borrowed, not drained — the caller still needs it afterward to run apply. +fn build_report_rows( + total_rows: usize, + valid_rows: &[(usize, CreatePassportRequest)], + row_errors: &[ErrorEntry], + mut lint_findings: std::collections::HashMap>, + classifications: &HashMap, +) -> Vec { + let valid_row_nums: std::collections::HashSet = + valid_rows.iter().map(|(n, _)| *n).collect(); + let mut findings_by_row: std::collections::HashMap> = + std::collections::HashMap::new(); + for e in row_errors { + findings_by_row.entry(e.row).or_default().push(RowFinding { + kind: FindingKind::Validation, + field: e.field.clone(), + message: e.message.clone(), + severity: None, + }); + } + (1..=total_rows) + .map(|row| { + let mut findings = findings_by_row.remove(&row).unwrap_or_default(); + if let Some(mut lint) = lint_findings.remove(&row) { + findings.append(&mut lint); + } + let classification = classifications.get(&row); + ReportRow { + row, + valid: valid_row_nums.contains(&row), + action: classification.map(|c| c.action), + existing_passport_id: classification.and_then(|c| c.existing_id.clone()), + findings, + } + }) + .collect() +} + +fn batch_result_into_entries( + result: BatchResult, +) -> (Vec, Vec, Vec) { let created = result .created .into_iter() @@ -362,6 +514,15 @@ fn batch_result_into_entries(result: BatchResult) -> (Vec, Vec (Vec, Vec String { + format!("EV Battery 48V,{gtin},BATCH-1,Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + } + + fn battery_csv(num_rows: usize) -> String { + let mut s = String::from(BATTERY_CSV_HEADER); + s.push('\n'); + for _ in 0..num_rows { + s.push_str(&battery_csv_row(VALID_GTIN)); + s.push('\n'); + } + s + } + + /// Hand-builds a `multipart/form-data` body — no request-side multipart + /// builder is available as a dependency here. + fn multipart_body( + boundary: &str, + filename: &str, + file_contents: &str, + mode: Option<&str>, + ) -> Vec { + let mut body = String::new(); + body.push_str(&format!("--{boundary}\r\n")); + body.push_str(&format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n" + )); + body.push_str("Content-Type: text/csv\r\n\r\n"); + body.push_str(file_contents); + body.push_str("\r\n"); + if let Some(m) = mode { + body.push_str(&format!("--{boundary}\r\n")); + body.push_str("Content-Disposition: form-data; name=\"mode\"\r\n\r\n"); + body.push_str(m); + body.push_str("\r\n"); + } + body.push_str(&format!("--{boundary}--\r\n")); + body.into_bytes() + } + + /// Byte-oriented sibling of `multipart_body` — that one builds the body as + /// a `String`, which can't carry a binary XLSX payload (it isn't valid + /// UTF-8). Used only by the XLSX-parity test. + fn multipart_body_bytes( + boundary: &str, + filename: &str, + file_bytes: &[u8], + content_type: &str, + mode: Option<&str>, + ) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n") + .as_bytes(), + ); + body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes()); + body.extend_from_slice(file_bytes); + body.extend_from_slice(b"\r\n"); + if let Some(m) = mode { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"mode\"\r\n\r\n"); + body.extend_from_slice(m.as_bytes()); + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + body + } + + /// 0-based column index -> spreadsheet column letters (`0` -> `A`, `25` -> + /// `Z`, `26` -> `AA`, ...). Only ever called with single digits here (10 + /// battery columns), but written as the general bijective base-26 rule + /// rather than hardcoding `A`..`J`. + fn column_letter(mut idx: usize) -> String { + let mut letters = Vec::new(); + idx += 1; + while idx > 0 { + let rem = (idx - 1) % 26; + letters.push(b'A' + rem as u8); + idx = (idx - 1) / 26; + } + letters.reverse(); + String::from_utf8(letters).unwrap() + } + + /// Build a minimal, valid XLSX workbook (single sheet, every cell an + /// inline string) from the exact same comma-separated row text the CSV + /// tests already build — so the XLSX and CSV paths are proven against + /// byte-identical row content, not two fixtures written by hand that only + /// look equivalent. No `sharedStrings.xml`/`styles.xml` (both optional to + /// calamine); the five parts here are the ones it actually reads. + fn build_xlsx_from_csv(csv_text: &str) -> Vec { + let mut sheet_data = String::new(); + for (r, line) in csv_text.lines().filter(|l| !l.is_empty()).enumerate() { + let row_num = r + 1; + sheet_data.push_str(&format!(r#""#)); + for (c, value) in line.split(',').enumerate() { + let col = column_letter(c); + let escaped = value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + sheet_data.push_str(&format!( + r#"{escaped}"# + )); + } + sheet_data.push_str(""); + } + + let worksheet = format!( + r#"{sheet_data}"# + ); + + const CONTENT_TYPES: &str = r#""#; + + const ROOT_RELS: &str = r#""#; + + const WORKBOOK: &str = r#""#; + + const WORKBOOK_RELS: &str = r#""#; + + let mut buf = Vec::new(); + { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts = zip::write::FileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + zw.start_file("[Content_Types].xml", opts).unwrap(); + zw.write_all(CONTENT_TYPES.as_bytes()).unwrap(); + zw.start_file("_rels/.rels", opts).unwrap(); + zw.write_all(ROOT_RELS.as_bytes()).unwrap(); + zw.start_file("xl/workbook.xml", opts).unwrap(); + zw.write_all(WORKBOOK.as_bytes()).unwrap(); + zw.start_file("xl/_rels/workbook.xml.rels", opts).unwrap(); + zw.write_all(WORKBOOK_RELS.as_bytes()).unwrap(); + zw.start_file("xl/worksheets/sheet1.xml", opts).unwrap(); + zw.write_all(worksheet.as_bytes()).unwrap(); + zw.finish().unwrap(); + } + buf + } + + fn import_request(sector: &str, body: Vec) -> Request { + Request::builder() + .method("POST") + .uri(format!("/api/v1/import/{sector}")) + .header("authorization", "Bearer test-token") + .header("content-type", "multipart/form-data; boundary=X") + .body(Body::from(body)) + .unwrap() + } + + async fn response_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), 10 * 1024 * 1024) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + /// A mock vault: `GET /api/v1/dpps` answers `verify_token`, `POST /api/v1/dpp` + /// answers `create_passport` (and remembers what it created), `GET + /// /api/v1/dpp/by-identity` answers the matcher's lookup against whatever + /// has been created or directly seeded. This module is testing + /// `import_file`'s own logic (parsing, validation, sync/async dispatch, + /// matching), not `run_batch`'s retry behaviour (covered separately at the + /// vault-client level). + mod mock_vault { + use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post, put}, + }; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + pub(super) struct State_ { + pub(super) create_hits: AtomicUsize, + pub(super) update_hits: AtomicUsize, + /// `pub(super)` so tests can flip a created passport to `active` + /// directly — `create_handler` only ever produces `draft`. + pub(super) passports: Mutex>, + } + + async fn verify_handler() -> Response { + (StatusCode::OK, Json(serde_json::json!({"items": []}))).into_response() + } + + async fn create_handler( + State(state): State>, + Json(mut body): Json, + ) -> Response { + let n = state.create_hits.fetch_add(1, Ordering::SeqCst) + 1; + let id = format!("pp-{n}"); + body["id"] = serde_json::json!(id); + body["status"] = serde_json::json!("draft"); + state.passports.lock().unwrap().push(body.clone()); + (StatusCode::CREATED, Json(body)).into_response() + } + + /// Shallow merge-patch onto the matched passport, matching the real + /// vault's `PUT /api/v1/dpp/{dppId}` semantics. + async fn update_handler( + State(state): State>, + Path(id): Path, + Json(body): Json, + ) -> Response { + state.update_hits.fetch_add(1, Ordering::SeqCst); + let mut passports = state.passports.lock().unwrap(); + let Some(existing) = passports + .iter_mut() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id.as_str())) + else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"detail": "not found"})), + ) + .into_response(); + }; + if let (Some(existing_obj), Some(body_obj)) = + (existing.as_object_mut(), body.as_object()) + { + for (k, v) in body_obj { + existing_obj.insert(k.clone(), v.clone()); + } + } + (StatusCode::OK, Json(existing.clone())).into_response() + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct IdentityParams { + sector: String, + gtin: String, + #[serde(default)] + batch_id: Option, + } + + async fn find_by_identity_handler( + State(state): State>, + Query(q): Query, + ) -> Response { + let found = state + .passports + .lock() + .unwrap() + .iter() + .find(|p| { + p.get("sectorData") + .and_then(|sd| sd.get("sector")) + .and_then(|s| s.as_str()) + == Some(q.sector.as_str()) + && p.get("sectorData") + .and_then(|sd| sd.get("gtin")) + .and_then(|g| g.as_str()) + == Some(q.gtin.as_str()) + && p.get("batchId").and_then(|b| b.as_str()) == q.batch_id.as_deref() + }) + .cloned(); + match found { + Some(p) => (StatusCode::OK, Json(p)).into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"detail": "no passport matches that identity"})), + ) + .into_response(), + } + } + + pub(super) async fn spawn() -> (String, Arc) { + let state = Arc::new(State_::default()); + let app = Router::new() + .route("/api/v1/dpps", get(verify_handler)) + .route("/api/v1/dpp", post(create_handler)) + .route("/api/v1/dpp/by-identity", get(find_by_identity_handler)) + .route("/api/v1/dpp/{id}", put(update_handler)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), state) + } + } + + async fn live_vault_state() -> (AppState, Arc) { + let (base_url, mock_state) = mock_vault::spawn().await; + let state = AppState { + vault_client: Arc::new(VaultHttpClient::new(&base_url)), + job_store: Arc::new(InMemoryJobStore::new()), + batch_concurrency: 4, + }; + (state, mock_state) + } + + fn build_router(state: AppState) -> Router { + crate::router::build(state) + } + + #[tokio::test] + async fn sync_import_creates_passports_for_valid_rows() { + let (state, mock) = live_vault_state().await; + let app = build_router(state); + + let body = multipart_body("X", "battery.csv", &battery_csv(3), None); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + + let json = response_json(resp).await; + assert_eq!(json["totalRows"], 3); + assert_eq!(json["successCount"], 3); + assert_eq!(json["errorCount"], 0); + assert_eq!(mock.create_hits.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn dry_run_validates_without_creating_anything() { + let (state, mock) = live_vault_state().await; + let app = build_router(state); + + let body = multipart_body("X", "battery.csv", &battery_csv(2), Some("dry_run")); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + + let json = response_json(resp).await; + assert_eq!(json["successCount"], 0); + assert_eq!(json["created"].as_array().unwrap().len(), 0); + assert_eq!( + mock.create_hits.load(Ordering::SeqCst), + 0, + "dry run must never call the vault" + ); + } + + /// GS1 mod-10 check digit for a 13-digit data prefix — lets tests mint + /// several distinct, individually valid GTIN-14s (battery's row + /// validator runs a real checksum, not just a length check). + fn nth_valid_gtin(n: u32) -> String { + let data13 = format!("{n:013}"); + let digits: Vec = data13.bytes().map(|b| b - b'0').collect(); + let check = dpp_domain::domain::gtin::gs1_check_digit(&digits); + format!("{data13}{check}") + } + + /// Golden CSV pair — S3's own gate: dry-run names every action correctly + /// with zero writes, against real prior state (created through the same + /// import path, not hand-built fixtures). + #[tokio::test] + async fn golden_csv_pair_classifies_every_action_correctly() { + let (state, mock) = live_vault_state().await; + let job_store = state.job_store.clone(); + let app = build_router(state); + + let unchanged_gtin = nth_valid_gtin(1); + let edited_gtin = nth_valid_gtin(2); + let published_gtin = nth_valid_gtin(3); + let new_gtin = nth_valid_gtin(4); + + fn row(product_name: &str, gtin: &str, batch: &str) -> String { + format!("{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + } + + // ── "v1 sheet": apply-mode import creates three draft passports ────── + let mut v1 = String::from(BATTERY_CSV_HEADER); + v1.push('\n'); + v1.push_str(&row("Steady Battery", &unchanged_gtin, "BATCH-1")); + v1.push('\n'); + v1.push_str(&row("Original Name", &edited_gtin, "BATCH-2")); + v1.push('\n'); + v1.push_str(&row("Published Original", &published_gtin, "BATCH-3")); + v1.push('\n'); + + let body = multipart_body("X", "v1.csv", &v1, None); + let resp = app + .clone() + .oneshot(import_request("battery", body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + axum::http::StatusCode::OK, + "v1 import must succeed" + ); + assert_eq!(mock.create_hits.load(Ordering::SeqCst), 3); + + // Simulate that BATCH-3 was published since the v1 import. + { + let mut passports = mock.passports.lock().unwrap(); + let published = passports + .iter_mut() + .find(|p| p["sectorData"]["gtin"].as_str() == Some(published_gtin.as_str())) + .expect("BATCH-3 passport must exist after v1 import"); + published["status"] = serde_json::json!("active"); + } + + // ── "v2 sheet": one unchanged, one edited, one conflicting-published, + // one brand new ──────────────────────────────────────────────── + let mut v2 = String::from(BATTERY_CSV_HEADER); + v2.push('\n'); + v2.push_str(&row("Steady Battery", &unchanged_gtin, "BATCH-1")); // identical + v2.push('\n'); + v2.push_str(&row("Edited Name", &edited_gtin, "BATCH-2")); // draft, changed + v2.push('\n'); + v2.push_str(&row("Published Edited", &published_gtin, "BATCH-3")); // published, changed + v2.push('\n'); + v2.push_str(&row("Brand New Battery", &new_gtin, "BATCH-4")); // no match + v2.push('\n'); + + let body = multipart_body("X", "v2.csv", &v2, Some("dry_run")); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + assert_eq!( + mock.create_hits.load(Ordering::SeqCst), + 3, + "dry run must not create anything — v1's 3 creates must be the only ones" + ); + + let json = response_json(resp).await; + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + let job = job_store + .get(job_id) + .await + .expect("v2 job must be retrievable"); + let report = job.report.expect("v2 report must be persisted"); + assert_eq!(report.rows.len(), 4, "one report row per v2 row"); + + assert_eq!( + report.rows[0].action, + Some(RowAction::Unchanged), + "identical content against a draft match must be Unchanged" + ); + assert_eq!( + report.rows[1].action, + Some(RowAction::UpdateDraft), + "changed content against a draft match must be UpdateDraft" + ); + assert_eq!( + report.rows[2].action, + Some(RowAction::ConflictPublished), + "changed content against a published match must be ConflictPublished, never mutated" + ); + assert_eq!( + report.rows[3].action, + Some(RowAction::Create), + "no existing match must be Create" + ); + } + + /// S5's own gate: apply acts on the matcher's classification instead of + /// unconditionally creating — same v1/v2 setup as the golden pair, but + /// "v2" runs in apply mode and asserts the actual vault calls made. + #[tokio::test] + async fn apply_acts_on_classification_instead_of_always_creating() { + let (state, mock) = live_vault_state().await; + let job_store = state.job_store.clone(); + let app = build_router(state); + + let unchanged_gtin = nth_valid_gtin(11); + let edited_gtin = nth_valid_gtin(12); + let published_gtin = nth_valid_gtin(13); + let new_gtin = nth_valid_gtin(14); + + fn row(product_name: &str, gtin: &str, batch: &str) -> String { + format!("{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + } + + let mut v1 = String::from(BATTERY_CSV_HEADER); + v1.push('\n'); + v1.push_str(&row("Steady Battery", &unchanged_gtin, "BATCH-1")); + v1.push('\n'); + v1.push_str(&row("Original Name", &edited_gtin, "BATCH-2")); + v1.push('\n'); + v1.push_str(&row("Published Original", &published_gtin, "BATCH-3")); + v1.push('\n'); + + let body = multipart_body("X", "v1.csv", &v1, None); + let resp = app + .clone() + .oneshot(import_request("battery", body)) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + assert_eq!(mock.create_hits.load(Ordering::SeqCst), 3); + + { + let mut passports = mock.passports.lock().unwrap(); + let published = passports + .iter_mut() + .find(|p| p["sectorData"]["gtin"].as_str() == Some(published_gtin.as_str())) + .expect("BATCH-3 passport must exist after v1 import"); + published["status"] = serde_json::json!("active"); + } + + let mut v2 = String::from(BATTERY_CSV_HEADER); + v2.push('\n'); + v2.push_str(&row("Steady Battery", &unchanged_gtin, "BATCH-1")); // identical + v2.push('\n'); + v2.push_str(&row("Edited Name", &edited_gtin, "BATCH-2")); // draft, changed + v2.push('\n'); + v2.push_str(&row("Published Edited", &published_gtin, "BATCH-3")); // published, changed + v2.push('\n'); + v2.push_str(&row("Brand New Battery", &new_gtin, "BATCH-4")); // no match + v2.push('\n'); + + // Apply mode this time (mode omitted — apply is the default). + let body = multipart_body("X", "v2.csv", &v2, None); + let resp = app + .clone() + .oneshot(import_request("battery", body)) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + + let json = response_json(resp).await; + assert_eq!(json["totalRows"], 4); + assert_eq!(json["errorCount"], 0); + assert_eq!( + json["successCount"], 4, + "no row errored — created/updated/unchanged/conflict all count as success" + ); + assert_eq!( + json["created"].as_array().unwrap().len(), + 1, + "only the brand-new row should be created" + ); + assert_eq!( + json["updated"].as_array().unwrap().len(), + 1, + "only the edited-draft row should be updated" + ); + + assert_eq!( + mock.create_hits.load(Ordering::SeqCst), + 4, + "v1's 3 creates plus exactly 1 new create from v2 — not 4 more" + ); + assert_eq!( + mock.update_hits.load(Ordering::SeqCst), + 1, + "exactly one PUT — the edited draft row — unchanged and conflict rows must never call update" + ); + + // ── S6: re-submit the identical v2 sheet a third time ──────────────── + // Proves idempotence through a real state transition, not just against + // a hand-set fixture: BATCH-2 and BATCH-4 were just mutated by the call + // above, so this checks the matcher correctly treats its own prior + // output as the new baseline, not that "unchanged rows stay unchanged" + // (already covered by the golden pair and by BATCH-1 above). + let body = multipart_body("X", "v2.csv", &v2, None); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + + let json = response_json(resp).await; + assert_eq!( + json["created"].as_array().unwrap().len(), + 0, + "re-applying the same sheet must create nothing new" + ); + assert_eq!( + json["updated"].as_array().unwrap().len(), + 0, + "re-applying the same sheet must update nothing — BATCH-2 is now identical to its own draft" + ); + assert_eq!( + mock.create_hits.load(Ordering::SeqCst), + 4, + "zero additional creates on re-apply" + ); + assert_eq!( + mock.update_hits.load(Ordering::SeqCst), + 1, + "zero additional updates on re-apply" + ); + + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + let report = job_store + .get(job_id) + .await + .expect("third-call job must be retrievable") + .report + .expect("third-call report must be persisted"); + assert_eq!( + report.rows[0].action, + Some(RowAction::Unchanged), + "BATCH-1 was already unchanged and stays that way" + ); + assert_eq!( + report.rows[1].action, + Some(RowAction::Unchanged), + "BATCH-2's prior update is now the baseline — re-submitting it must read as Unchanged, not UpdateDraft again" + ); + assert_eq!( + report.rows[2].action, + Some(RowAction::ConflictPublished), + "BATCH-3 never resolves itself — the published passport was never mutated, so this conflict recurs on every resubmission" + ); + assert_eq!( + report.rows[3].action, + Some(RowAction::Unchanged), + "BATCH-4's prior create is now the baseline — re-submitting it must read as Unchanged, not Create again" + ); + } + + /// S7's own gate: the XLSX parser must feed the exact same downstream + /// pipeline as CSV. Same v1/v2 golden pair as S3's own gate, but "v2" is + /// uploaded as a hand-built XLSX workbook (`build_xlsx_from_csv`) instead + /// of CSV text, asserting the matcher classifies every row identically. + #[tokio::test] + async fn xlsx_upload_classifies_identically_to_the_csv_golden_pair() { + let (state, mock) = live_vault_state().await; + let job_store = state.job_store.clone(); + let app = build_router(state); + + let unchanged_gtin = nth_valid_gtin(21); + let edited_gtin = nth_valid_gtin(22); + let published_gtin = nth_valid_gtin(23); + let new_gtin = nth_valid_gtin(24); + + fn row(product_name: &str, gtin: &str, batch: &str) -> String { + format!("{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + } + + let mut v1 = String::from(BATTERY_CSV_HEADER); + v1.push('\n'); + v1.push_str(&row("Steady Battery", &unchanged_gtin, "BATCH-1")); + v1.push('\n'); + v1.push_str(&row("Original Name", &edited_gtin, "BATCH-2")); + v1.push('\n'); + v1.push_str(&row("Published Original", &published_gtin, "BATCH-3")); + v1.push('\n'); + + let body = multipart_body("X", "v1.csv", &v1, None); + let resp = app + .clone() + .oneshot(import_request("battery", body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + axum::http::StatusCode::OK, + "v1 import must succeed" + ); + assert_eq!(mock.create_hits.load(Ordering::SeqCst), 3); + + // Simulate that BATCH-3 was published since the v1 import. + { + let mut passports = mock.passports.lock().unwrap(); + let published = passports + .iter_mut() + .find(|p| p["sectorData"]["gtin"].as_str() == Some(published_gtin.as_str())) + .expect("BATCH-3 passport must exist after v1 import"); + published["status"] = serde_json::json!("active"); + } + + let mut v2 = String::from(BATTERY_CSV_HEADER); + v2.push('\n'); + v2.push_str(&row("Steady Battery", &unchanged_gtin, "BATCH-1")); // identical + v2.push('\n'); + v2.push_str(&row("Edited Name", &edited_gtin, "BATCH-2")); // draft, changed + v2.push('\n'); + v2.push_str(&row("Published Edited", &published_gtin, "BATCH-3")); // published, changed + v2.push('\n'); + v2.push_str(&row("Brand New Battery", &new_gtin, "BATCH-4")); // no match + v2.push('\n'); + + // Same v2 row content as the CSV golden pair, byte-sourced from the + // same string, but encoded as a real XLSX workbook — proves the + // parser front-end doesn't change the matcher's behaviour, not just + // that XLSX "parses" in isolation (that's xlsx_parser's own tests). + let xlsx_bytes = build_xlsx_from_csv(&v2); + let body = multipart_body_bytes( + "X", + "v2.xlsx", + &xlsx_bytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Some("dry_run"), + ); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + assert_eq!( + mock.create_hits.load(Ordering::SeqCst), + 3, + "dry run must not create anything, XLSX or not — v1's 3 creates must be the only ones" + ); + + let json = response_json(resp).await; + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + let job = job_store + .get(job_id) + .await + .expect("xlsx job must be retrievable"); + let report = job.report.expect("xlsx report must be persisted"); + assert_eq!(report.rows.len(), 4, "one report row per xlsx row"); + + assert_eq!( + report.rows[0].action, + Some(RowAction::Unchanged), + "identical content against a draft match must be Unchanged, same as CSV" + ); + assert_eq!( + report.rows[1].action, + Some(RowAction::UpdateDraft), + "changed content against a draft match must be UpdateDraft, same as CSV" + ); + assert_eq!( + report.rows[2].action, + Some(RowAction::ConflictPublished), + "changed content against a published match must be ConflictPublished, same as CSV" + ); + assert_eq!( + report.rows[3].action, + Some(RowAction::Create), + "no existing match must be Create, same as CSV" + ); + } + + #[tokio::test] + async fn sync_apply_persists_a_retrievable_report() { + let (state, _mock) = live_vault_state().await; + let job_store = state.job_store.clone(); + let app = build_router(state); + + let mut csv = String::from(BATTERY_CSV_HEADER); + csv.push('\n'); + csv.push_str(&battery_csv_row(VALID_GTIN)); + csv.push('\n'); + // GTIN too short — fails the row-level checksum/length check. + csv.push_str("EV Battery Bad,1234,BATCH-2,Acme Energy,DE,LFP,48.0,100.0,3000,85.4\n"); + + let body = multipart_body("X", "battery.csv", &csv, None); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + let json = response_json(resp).await; + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + + let job = job_store + .get(job_id) + .await + .expect("job must be retrievable"); + assert!(matches!(job.status, JobStatus::Completed)); + let report = job.report.expect("report must be persisted"); + assert!(matches!(report.mode, ImportMode::Apply)); + assert_eq!(report.rows.len(), 2, "one report row per input row"); + assert!(report.rows[0].valid); + assert!(!report.rows[1].valid); + assert_eq!(report.rows[1].findings[0].field, "gtin"); + } + + #[tokio::test] + async fn dry_run_persists_a_retrievable_report() { + let (state, _mock) = live_vault_state().await; + let job_store = state.job_store.clone(); + let app = build_router(state); + + let body = multipart_body("X", "battery.csv", &battery_csv(2), Some("dry_run")); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + let json = response_json(resp).await; + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + + let job = job_store + .get(job_id) + .await + .expect("job must be retrievable"); + let report = job.report.expect("report must be persisted"); + assert!(matches!(report.mode, ImportMode::DryRun)); + assert_eq!(report.rows.len(), 2); + assert!(report.rows.iter().all(|r| r.valid)); + } + + #[tokio::test] + async fn dry_run_surfaces_lint_findings_alongside_validation() { + let (state, mock) = live_vault_state().await; + let job_store = state.job_store.clone(); + let app = build_router(state); + + // repairScore >= 8 with neither disassemblyInstructions nor + // sparePartsAvailable=true triggers textile.repair_score_high_without_support + // (dpp-rules::lint::textile) — and the CSV path never sets either of + // those two fields, so this is a deterministic, always-firing trigger. + let header = "productName,gtin,batchId,manufacturerName,manufacturerCountry,fibreComposition,countryOfManufacturing,careInstructions,chemicalComplianceStandard,recycledContentPct,repairScore,carbonFootprintKgCo2e"; + let row = format!( + "Organic Cotton Tee,{VALID_GTIN},BATCH-T-1,EcoWear,BD,\"[{{\"\"fibre\"\":\"\"cotton\"\",\"\"pct\"\":100}}]\",BD,30C wash,OEKO-TEX 100,,9.0," + ); + let csv = format!("{header}\n{row}\n"); + + let body = multipart_body("X", "textile.csv", &csv, Some("dry_run")); + let resp = app.oneshot(import_request("textile", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + let json = response_json(resp).await; + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + + let job = job_store + .get(job_id) + .await + .expect("job must be retrievable"); + let report = job.report.expect("report must be persisted"); + assert_eq!(report.rows.len(), 1); + assert!( + report.rows[0].valid, + "a lint finding is advisory — it must not block the row" + ); + let lint = report.rows[0] + .findings + .iter() + .find(|f| matches!(f.kind, FindingKind::Lint)) + .expect("lint finding must surface in the dry-run report"); + assert_eq!(lint.field, "repairScore"); + assert_eq!( + mock.create_hits.load(Ordering::SeqCst), + 0, + "dry run must never call the vault" + ); + } + + #[tokio::test] + async fn unknown_sector_is_rejected_before_any_vault_call() { + // Intentionally a dead vault URL: the sector check must reject this + // before auth or parsing ever contact it. + let state = AppState { + vault_client: Arc::new(VaultHttpClient::new("http://127.0.0.1:1")), + job_store: Arc::new(InMemoryJobStore::new()), + batch_concurrency: 1, + }; + let app = build_router(state); + + let body = multipart_body("X", "x.csv", "a,b\n1,2\n", None); + let resp = app + .oneshot(import_request("not-a-real-sector", body)) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn missing_file_field_is_rejected() { + let (state, _mock) = live_vault_state().await; + let app = build_router(state); + + let body = + "--X\r\nContent-Disposition: form-data; name=\"dry_run\"\r\n\r\ntrue\r\n--X--\r\n" + .to_owned() + .into_bytes(); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn invalid_row_reports_error_and_lets_valid_rows_through() { + let (state, mock) = live_vault_state().await; + let app = build_router(state); + + let mut csv = String::from(BATTERY_CSV_HEADER); + csv.push('\n'); + csv.push_str(&battery_csv_row(VALID_GTIN)); + csv.push('\n'); + // GTIN too short — fails the row-level checksum/length check. + csv.push_str("EV Battery Bad,1234,BATCH-2,Acme Energy,DE,LFP,48.0,100.0,3000,85.4\n"); + + let body = multipart_body("X", "battery.csv", &csv, None); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + + let json = response_json(resp).await; + assert_eq!(json["successCount"], 1); + assert_eq!(json["errorCount"], 1); + assert_eq!(json["errors"][0]["field"], "gtin"); + assert_eq!(mock.create_hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn async_path_processes_the_job_in_the_background() { + let (state, mock) = live_vault_state().await; + let app = build_router(state.clone()); + + // SYNC_THRESHOLD is 100 — 101 valid rows forces the async path. + let body = multipart_body("X", "battery.csv", &battery_csv(101), None); + let resp = app.oneshot(import_request("battery", body)).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED); + + let json = response_json(resp).await; + assert_eq!(json["status"], "queued"); + assert_eq!(json["totalRows"], 101); + let job_id = uuid::Uuid::parse_str(json["jobId"].as_str().unwrap()).unwrap(); + + let job = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(job) = state.job_store.get(job_id).await + && matches!(job.status, JobStatus::Completed) + { + return job; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("import job must complete within 10s"); + + let result = job.result.expect("completed job must carry a result"); + assert_eq!(result.created.len(), 101); + assert_eq!(mock.create_hits.load(Ordering::SeqCst), 101); + } } diff --git a/crates/dpp-integrator/src/handlers/job_status.rs b/crates/dpp-integrator/src/handlers/job_status.rs index 5c81056..eb71a23 100644 --- a/crates/dpp-integrator/src/handlers/job_status.rs +++ b/crates/dpp-integrator/src/handlers/job_status.rs @@ -55,6 +55,7 @@ pub async fn get_job_status( JobStatus::Failed(reason) => ("failed", serde_json::json!({"reason": reason})), }; + let report_json = serde_json::to_value(&job.report).unwrap_or(serde_json::Value::Null); ( StatusCode::OK, Json(serde_json::json!({ @@ -64,7 +65,8 @@ pub async fn get_job_status( "processed": job.processed, "total": job.total_rows }, - "result": result_json + "result": result_json, + "report": report_json })), ) .into_response() diff --git a/crates/dpp-integrator/src/infra/job_store.rs b/crates/dpp-integrator/src/infra/job_store.rs index 5809bed..e0b81be 100644 --- a/crates/dpp-integrator/src/infra/job_store.rs +++ b/crates/dpp-integrator/src/infra/job_store.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::domain::batch_runner::BatchResult; +use crate::domain::import_report::ImportReport; // ─── Job model ──────────────────────────────────────────────────────────────── @@ -30,6 +31,15 @@ pub struct ImportJob { pub processed: usize, /// Final batch result — populated when status transitions to Completed. pub result: Option, + /// The row-addressed findings report — set for every job, dry-run or + /// apply, via [`JobStore::record_report`]. Independent of `result`: + /// `result` is apply-mode's created/errors bookkeeping, `report` is the + /// per-row validation outcome both modes share. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub report: Option, + /// The dry-run job this job re-runs as an apply, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_job_id: Option, pub created_at: DateTime, } @@ -42,6 +52,8 @@ impl ImportJob { total_rows, processed: 0, result: None, + report: None, + parent_job_id: None, created_at: Utc::now(), } } @@ -66,6 +78,11 @@ pub trait JobStore: Send + Sync { async fn set_status(&self, id: Uuid, status: JobStatus) -> anyhow::Result<()>; /// Mark the job `Completed` and store the final batch result. async fn complete(&self, id: Uuid, result: BatchResult) -> anyhow::Result<()>; + /// Attach the row-addressed findings report to a job. Independent of + /// `complete`/`result` — set for dry-run jobs (which never call + /// `complete`, since there is no `BatchResult` without a vault call) and + /// apply jobs alike, as soon as validation finishes. + async fn record_report(&self, id: Uuid, report: ImportReport) -> anyhow::Result<()>; /// Delete completed/failed jobs older than `max_age`. async fn cleanup(&self, max_age: chrono::Duration); } @@ -141,6 +158,18 @@ impl JobStore for InMemoryJobStore { Ok(()) } + async fn record_report(&self, id: Uuid, report: ImportReport) -> anyhow::Result<()> { + if let Some(job) = self + .jobs + .write() + .expect("job store write lock poisoned") + .get_mut(&id) + { + job.report = Some(report); + } + Ok(()) + } + async fn cleanup(&self, max_age: chrono::Duration) { let cutoff = Utc::now() - max_age; self.jobs @@ -152,3 +181,143 @@ impl JobStore for InMemoryJobStore { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::batch_runner::{CreatedItem, RowError}; + + fn sample_result() -> BatchResult { + BatchResult { + created: vec![CreatedItem { + row: 1, + passport_id: "pp-1".into(), + }], + updated: vec![], + errors: vec![RowError { + row: 2, + field: "gtin".into(), + message: "invalid".into(), + }], + } + } + + #[tokio::test] + async fn insert_then_get_round_trips() { + let store = InMemoryJobStore::new(); + let id = Uuid::now_v7(); + store.insert(ImportJob::new(id, 42)).await.unwrap(); + + let job = store.get(id).await.expect("job should exist"); + assert_eq!(job.id, id); + assert_eq!(job.total_rows, 42); + assert_eq!(job.processed, 0); + assert!(matches!(job.status, JobStatus::Queued)); + assert!(job.result.is_none()); + } + + #[tokio::test] + async fn get_unknown_id_returns_none() { + let store = InMemoryJobStore::new(); + assert!(store.get(Uuid::now_v7()).await.is_none()); + } + + #[tokio::test] + async fn set_status_transitions_existing_job() { + let store = InMemoryJobStore::new(); + let id = Uuid::now_v7(); + store.insert(ImportJob::new(id, 10)).await.unwrap(); + + store.set_status(id, JobStatus::Processing).await.unwrap(); + let job = store.get(id).await.unwrap(); + assert!(matches!(job.status, JobStatus::Processing)); + } + + #[tokio::test] + async fn set_status_on_unknown_id_is_a_silent_noop() { + let store = InMemoryJobStore::new(); + // Must not panic or error — the caller can't distinguish "unknown id" + // from "already cleaned up" and shouldn't need to. + store + .set_status(Uuid::now_v7(), JobStatus::Processing) + .await + .unwrap(); + } + + #[tokio::test] + async fn complete_records_result_and_marks_processed() { + let store = InMemoryJobStore::new(); + let id = Uuid::now_v7(); + store.insert(ImportJob::new(id, 5)).await.unwrap(); + + store.complete(id, sample_result()).await.unwrap(); + + let job = store.get(id).await.unwrap(); + assert!(matches!(job.status, JobStatus::Completed)); + assert_eq!(job.processed, job.total_rows); + let result = job.result.expect("result should be recorded"); + assert_eq!(result.created.len(), 1); + assert_eq!(result.errors.len(), 1); + } + + #[tokio::test] + async fn cleanup_removes_old_terminal_jobs_but_keeps_recent_and_active_ones() { + let store = InMemoryJobStore::new(); + + let old_completed = Uuid::now_v7(); + store + .insert(ImportJob::new(old_completed, 1)) + .await + .unwrap(); + store + .complete(old_completed, sample_result()) + .await + .unwrap(); + // Backdate creation so it's older than the cleanup cutoff. + { + let mut jobs = store.jobs.write().unwrap(); + jobs.get_mut(&old_completed).unwrap().created_at = + Utc::now() - chrono::Duration::days(2); + } + + let recent_completed = Uuid::now_v7(); + store + .insert(ImportJob::new(recent_completed, 1)) + .await + .unwrap(); + store + .complete(recent_completed, sample_result()) + .await + .unwrap(); + + let old_but_active = Uuid::now_v7(); + store + .insert(ImportJob::new(old_but_active, 1)) + .await + .unwrap(); + store + .set_status(old_but_active, JobStatus::Processing) + .await + .unwrap(); + { + let mut jobs = store.jobs.write().unwrap(); + jobs.get_mut(&old_but_active).unwrap().created_at = + Utc::now() - chrono::Duration::days(2); + } + + store.cleanup(chrono::Duration::hours(1)).await; + + assert!( + store.get(old_completed).await.is_none(), + "old + terminal must be swept" + ); + assert!( + store.get(recent_completed).await.is_some(), + "recent must survive" + ); + assert!( + store.get(old_but_active).await.is_some(), + "old but non-terminal (still processing) must survive" + ); + } +} diff --git a/crates/dpp-integrator/src/infra/vault_client.rs b/crates/dpp-integrator/src/infra/vault_client.rs index f9a4101..2465ca0 100644 --- a/crates/dpp-integrator/src/infra/vault_client.rs +++ b/crates/dpp-integrator/src/infra/vault_client.rs @@ -2,6 +2,8 @@ use std::fmt; +use dpp_domain::domain::product_identity::ProductIdentity; + use crate::domain::request::CreatePassportRequest; /// HTTP client for `dpp-vault`. @@ -93,6 +95,101 @@ impl VaultHttpClient { s => Err(VaultClientError::Unexpected(s.as_u16())), } } + + /// PUT a merge-patch to an existing (draft) passport — the delta + /// matcher's `update_draft` action. Same status-code mapping as + /// `create_passport`; the vault's `PUT` returns `200`, not `201`, but + /// both are covered by `is_success()`. + pub async fn update_passport( + &self, + id: &str, + req: &CreatePassportRequest, + auth_token: &str, + ) -> Result { + let url = format!("{}/api/v1/dpp/{id}", self.base_url); + + let resp = self + .client + .put(&url) + .header("Authorization", format!("Bearer {auth_token}")) + .json(req) + .send() + .await + .map_err(|e| VaultClientError::Network(e.to_string()))?; + + let status = resp.status(); + + match status { + s if s.is_success() => { + let body = resp + .json::() + .await + .map_err(|e| VaultClientError::Parse(e.to_string()))?; + Ok(body) + } + s if s == reqwest::StatusCode::UNAUTHORIZED => Err(VaultClientError::Unauthorised), + s if s == reqwest::StatusCode::FORBIDDEN => Err(VaultClientError::Unauthorised), + s if s == reqwest::StatusCode::UNPROCESSABLE_ENTITY => { + let body = resp + .json::() + .await + .unwrap_or(serde_json::json!({"detail": "validation failed"})); + let msg = body + .get("detail") + .or_else(|| body.get("title")) + .and_then(|v| v.as_str()) + .unwrap_or("validation failed") + .to_owned(); + Err(VaultClientError::Validation(msg)) + } + s if s == reqwest::StatusCode::TOO_MANY_REQUESTS => Err(VaultClientError::RateLimit), + s if s.is_server_error() => Err(VaultClientError::ServerError(s.as_u16())), + s => Err(VaultClientError::Unexpected(s.as_u16())), + } + } + + /// Look up a passport by exact compound identity (sector, GTIN, batch). + /// `Ok(None)` means no passport matches — not an error. + pub async fn find_by_identity( + &self, + identity: &ProductIdentity, + auth_token: &str, + ) -> Result, VaultClientError> { + let sector = serde_json::to_value(&identity.sector) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_default(); + let mut params = vec![("sector", sector), ("gtin", identity.gtin.clone())]; + if let Some(ref batch_id) = identity.batch_id { + params.push(("batchId", batch_id.clone())); + } + + let url = format!("{}/api/v1/dpp/by-identity", self.base_url); + let resp = self + .client + .get(&url) + .query(¶ms) + .bearer_auth(auth_token) + .send() + .await + .map_err(|e| VaultClientError::Network(e.to_string()))?; + + match resp.status() { + s if s.is_success() => { + let body = resp + .json::() + .await + .map_err(|e| VaultClientError::Parse(e.to_string()))?; + Ok(Some(body)) + } + reqwest::StatusCode::NOT_FOUND => Ok(None), + s if s == reqwest::StatusCode::UNAUTHORIZED || s == reqwest::StatusCode::FORBIDDEN => { + Err(VaultClientError::Unauthorised) + } + s if s.is_server_error() => Err(VaultClientError::ServerError(s.as_u16())), + s => Err(VaultClientError::Unexpected(s.as_u16())), + } + } } // ─── Error type ─────────────────────────────────────────────────────────────── @@ -122,3 +219,194 @@ impl fmt::Display for VaultClientError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + }; + use dpp_domain::domain::passport::ManufacturerInfo; + use std::sync::Arc; + + struct MockResponse { + status: StatusCode, + body: serde_json::Value, + } + + async fn respond(State(mock): State>) -> Response { + (mock.status, Json(mock.body.clone())).into_response() + } + + /// Spawns a mock vault server that answers `GET /api/v1/dpps` and + /// `POST /api/v1/dpp` with the same canned `(status, body)` — neither + /// `verify_token` nor `create_passport` retries, so one canned response + /// per test is enough. + async fn spawn_mock(status: StatusCode, body: serde_json::Value) -> String { + let mock = Arc::new(MockResponse { status, body }); + let app = Router::new() + .route("/api/v1/dpps", get(respond)) + .route("/api/v1/dpp", post(respond)) + .with_state(mock); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") + } + + fn sample_request() -> CreatePassportRequest { + CreatePassportRequest { + product_name: "Test Widget".into(), + sector: None, + manufacturer: ManufacturerInfo { + name: "Acme".into(), + address: "1 Main St".into(), + did_web_url: None, + }, + materials: None, + co2e_per_unit: None, + repairability_score: None, + sector_data: None, + batch_id: None, + schema_version: None, + } + } + + #[tokio::test] + async fn verify_token_empty_token_is_false_without_a_network_call() { + // base_url is never contacted — the empty-token check short-circuits first. + let client = VaultHttpClient::new("http://127.0.0.1:1"); + assert!(!client.verify_token("").await); + } + + #[tokio::test] + async fn verify_token_true_on_2xx() { + let base_url = spawn_mock(StatusCode::OK, serde_json::json!({"items": []})).await; + let client = VaultHttpClient::new(&base_url); + assert!(client.verify_token("some-token").await); + } + + #[tokio::test] + async fn verify_token_false_on_401() { + let base_url = spawn_mock(StatusCode::UNAUTHORIZED, serde_json::json!({})).await; + let client = VaultHttpClient::new(&base_url); + assert!(!client.verify_token("bad-token").await); + } + + #[tokio::test] + async fn verify_token_false_when_vault_unreachable() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); // nothing listens now + let client = VaultHttpClient::new(&format!("http://{addr}")); + assert!(!client.verify_token("some-token").await); + } + + #[tokio::test] + async fn create_passport_success_returns_body() { + let base_url = spawn_mock(StatusCode::CREATED, serde_json::json!({"id": "pp-123"})).await; + let client = VaultHttpClient::new(&base_url); + let body = client + .create_passport(&sample_request(), "tok") + .await + .expect("should succeed"); + assert_eq!(body["id"], "pp-123"); + } + + #[tokio::test] + async fn create_passport_401_is_unauthorised() { + let base_url = spawn_mock(StatusCode::UNAUTHORIZED, serde_json::json!({})).await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + assert!(matches!(err, VaultClientError::Unauthorised)); + } + + #[tokio::test] + async fn create_passport_403_is_unauthorised() { + let base_url = spawn_mock(StatusCode::FORBIDDEN, serde_json::json!({})).await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + assert!(matches!(err, VaultClientError::Unauthorised)); + } + + #[tokio::test] + async fn create_passport_422_extracts_detail() { + let base_url = spawn_mock( + StatusCode::UNPROCESSABLE_ENTITY, + serde_json::json!({"detail": "bad gtin"}), + ) + .await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + match err { + VaultClientError::Validation(msg) => assert_eq!(msg, "bad gtin"), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn create_passport_422_falls_back_to_title_when_no_detail() { + let base_url = spawn_mock( + StatusCode::UNPROCESSABLE_ENTITY, + serde_json::json!({"title": "Unprocessable Entity"}), + ) + .await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + match err { + VaultClientError::Validation(msg) => assert_eq!(msg, "Unprocessable Entity"), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn create_passport_429_is_rate_limit() { + let base_url = spawn_mock(StatusCode::TOO_MANY_REQUESTS, serde_json::json!({})).await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + assert!(matches!(err, VaultClientError::RateLimit)); + } + + #[tokio::test] + async fn create_passport_5xx_is_server_error() { + let base_url = spawn_mock(StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({})).await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + assert!(matches!(err, VaultClientError::ServerError(500))); + } + + #[tokio::test] + async fn create_passport_unrecognised_status_is_unexpected() { + let base_url = spawn_mock(StatusCode::IM_A_TEAPOT, serde_json::json!({})).await; + let client = VaultHttpClient::new(&base_url); + let err = client + .create_passport(&sample_request(), "tok") + .await + .unwrap_err(); + assert!(matches!(err, VaultClientError::Unexpected(418))); + } +} diff --git a/crates/dpp-node/src/boot/db.rs b/crates/dpp-node/src/boot/db.rs index 1bd2acc..c4d01ab 100644 --- a/crates/dpp-node/src/boot/db.rs +++ b/crates/dpp-node/src/boot/db.rs @@ -7,16 +7,16 @@ use anyhow::Context; use async_trait::async_trait; use dpp_dal::pg::{ - PgApiKeyRepo, PgAuditRepo, PgDal, PgOperatorConfigRepo, PgPassportRepo, PgRegistryIdentityRepo, - PgRegistrySyncRepo, PgTransferRepo, + PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo, + PgRegistryIdentityRepo, PgRegistrySyncRepo, PgTransferRepo, }; use dpp_domain::{DppError, ports::passport_repo::PassportRepository}; use dpp_integrator::infra::job_store::JobStore; use dpp_node::{config::NodeConfig, infra::pg_job_store::PgJobStore}; use dpp_types::{ - api_key::ApiKeyRepository, audit::AuditRepository, operator::OperatorConfigRepository, - registry_identity::RegistryIdentityRepository, registry_sync::RegistrySyncOutbox, - transfer::TransferStore, + api_key::ApiKeyRepository, audit::AuditRepository, evidence::EvidenceDossierRepository, + operator::OperatorConfigRepository, registry_identity::RegistryIdentityRepository, + registry_sync::RegistrySyncOutbox, transfer::TransferStore, }; use dpp_vault::state::DbPing; @@ -28,6 +28,7 @@ pub struct DbComponents { pub registry_repo: Arc, pub registry_outbox: Arc, pub transfer_store: Arc, + pub evidence_store: Arc, pub job_store: Arc, pub db_ping: Arc, } @@ -89,6 +90,7 @@ pub async fn init_db(cfg: &NodeConfig) -> anyhow::Result { registry_repo: Arc::new(PgRegistryIdentityRepo::new(dal.clone())), registry_outbox: Arc::new(PgRegistrySyncRepo::new(dal.clone())), transfer_store: Arc::new(PgTransferRepo::new(dal.clone())), + evidence_store: Arc::new(PgEvidenceDossierRepo::new(dal.clone())), job_store: Arc::new(PgJobStore::new(dal.clone())), db_ping: Arc::new(PgPing(dal)), }) diff --git a/crates/dpp-node/src/infra/pg_job_store.rs b/crates/dpp-node/src/infra/pg_job_store.rs index 8979381..5cfe61c 100644 --- a/crates/dpp-node/src/infra/pg_job_store.rs +++ b/crates/dpp-node/src/infra/pg_job_store.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use dpp_dal::pg::{PgDal, sqlx}; use dpp_integrator::{ domain::batch_runner::BatchResult, + domain::import_report::ImportReport, infra::job_store::{ImportJob, JobStatus, JobStore}, }; use sqlx::Row; @@ -48,12 +49,17 @@ impl PgJobStore { let result: Option = row .get::, _>("result") .and_then(|v| serde_json::from_value(v).ok()); + let report: Option = row + .get::, _>("report") + .and_then(|v| serde_json::from_value(v).ok()); Some(ImportJob { id: row.get("id"), status, total_rows: row.get::("total_rows").max(0) as usize, processed: row.get::("processed").max(0) as usize, result, + report, + parent_job_id: row.get::, _>("parent_job_id"), created_at: row.get::, _>("created_at"), }) } @@ -125,6 +131,18 @@ impl JobStore for PgJobStore { Ok(()) } + async fn record_report(&self, id: Uuid, report: ImportReport) -> anyhow::Result<()> { + let report_json = serde_json::to_value(&report)?; + let mut tx = self.dal.begin().await.map_err(|e| anyhow::anyhow!("{e}"))?; + sqlx::query("UPDATE odal.import_job SET report = $2 WHERE id = $1") + .bind(id) + .bind(report_json) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + async fn cleanup(&self, max_age: chrono::Duration) { let cutoff = Utc::now() - max_age; let Ok(mut tx) = self.dal.begin().await else { diff --git a/crates/dpp-node/src/infra/registry/mapping.rs b/crates/dpp-node/src/infra/registry/mapping.rs index 4f073ca..86cff6b 100644 --- a/crates/dpp-node/src/infra/registry/mapping.rs +++ b/crates/dpp-node/src/infra/registry/mapping.rs @@ -142,8 +142,12 @@ impl RegistrySyncPort for EuRegistrySync { operator_id: OperatorIdentifier { scheme: "did".into(), value: request.operator_identifier.clone(), + // Wire the operator country that the request already carries + // (sourced from OperatorConfig) instead of dropping it. The + // operator legal `name` is not yet threaded through the port — + // see the payload-validation note below. name: String::new(), - country: String::new(), + country: request.country_code.clone(), did: Some(request.operator_identifier.clone()), }, sector: request.product_category.clone(), diff --git a/crates/dpp-node/src/infra/registry/tests.rs b/crates/dpp-node/src/infra/registry/tests.rs index b0ea2b5..abc8bcd 100644 --- a/crates/dpp-node/src/infra/registry/tests.rs +++ b/crates/dpp-node/src/infra/registry/tests.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; use chrono::Utc; @@ -5,7 +7,7 @@ use dpp_domain::domain::passport::PassportId; use dpp_registry::{EuRegistryResponse, registry::RegistryStatusCode}; use uuid::Uuid; -use dpp_domain::ports::registry_sync::{RegistrationRequest, RegistryStatus}; +use dpp_domain::ports::registry_sync::{RegistrationRequest, RegistryStatus, RegistrySyncPort}; use super::client::EuRegistrySync; use super::config::EuRegistrySyncConfig; @@ -144,3 +146,311 @@ fn cached_token_expiry_check() { }; assert!(stale.is_expired()); } + +// --------------------------------------------------------------------------- +// HTTP-layer tests: `register`/`check_status`/`notify_transfer` against a mock +// EU registry (real axum server on a random local port). These exercise the +// retry-classification logic (Retryable/Unreachable/Fatal) that the pure +// mapping tests above can't reach. +// --------------------------------------------------------------------------- + +mod mock_server { + use std::collections::VecDeque; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + }; + use serde_json::Value; + use tokio::sync::Mutex; + + /// Shared state for a mock EU registry: canned response queues per route, + /// plus a hit counter on `/registrations` for retry-count assertions. + #[derive(Default)] + pub(super) struct MockState { + pub(super) register_queue: Mutex>, + pub(super) register_hits: AtomicUsize, + pub(super) status_queue: Mutex>, + pub(super) transfer_queue: Mutex>, + } + + async fn pop_or_500(queue: &Mutex>) -> Response { + let (status, body) = queue.lock().await.pop_front().unwrap_or(( + StatusCode::INTERNAL_SERVER_ERROR, + serde_json::json!({"error": "no mock response queued"}), + )); + (status, Json(body)).into_response() + } + + async fn token_handler() -> Response { + ( + StatusCode::OK, + Json(serde_json::json!({ + "access_token": "mock-token", + "expires_in": 3600, + "token_type": "Bearer", + })), + ) + .into_response() + } + + async fn register_handler(State(state): State>) -> Response { + state + .register_hits + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + pop_or_500(&state.register_queue).await + } + + async fn status_handler( + State(state): State>, + Path(_id): Path, + ) -> Response { + pop_or_500(&state.status_queue).await + } + + async fn transfer_handler( + State(state): State>, + Path(_id): Path, + ) -> Response { + pop_or_500(&state.transfer_queue).await + } + + /// Spawns a mock EU registry on a random local port and returns its base URL. + pub(super) async fn spawn(state: Arc) -> String { + let app = Router::new() + .route("/token", post(token_handler)) + .route("/registrations", post(register_handler)) + .route("/registrations/{id}/status", get(status_handler)) + .route("/registrations/{id}/transfer", post(transfer_handler)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") + } +} + +use mock_server::MockState; + +fn mock_config(base_url: &str) -> EuRegistrySyncConfig { + EuRegistrySyncConfig { + endpoint: dpp_registry::RegistryEndpoint { + authority: dpp_registry::RegistryAuthority::EuSandbox, + base_url: base_url.to_string(), + api_version: "1.0".into(), + mtls_required: false, + token_endpoint: Some(format!("{base_url}/token")), + }, + client_id: "test-client".into(), + client_secret: "test-secret".into(), + max_retries: 3, + retry_base_delay: Duration::from_millis(1), + request_timeout: Duration::from_secs(5), + } +} + +fn registered_response(registry_id: &str) -> serde_json::Value { + serde_json::json!({ + "registryId": registry_id, + "passportId": Uuid::now_v7(), + "status": "registered", + "updatedAt": Utc::now().to_rfc3339(), + }) +} + +#[tokio::test] +async fn register_succeeds_and_maps_response() { + let state = Arc::new(MockState::default()); + state + .register_queue + .lock() + .await + .push_back((axum::http::StatusCode::OK, registered_response("EU-REG-1"))); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let record = sync + .register(request_with_facility(None)) + .await + .expect("register should succeed"); + + assert_eq!(record.status, RegistryStatus::Registered); + assert_eq!(record.identifiers.registry_id, "EU-REG-1"); + assert_eq!(state.register_hits.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn register_fatal_4xx_does_not_retry() { + let state = Arc::new(MockState::default()); + state.register_queue.lock().await.push_back(( + axum::http::StatusCode::BAD_REQUEST, + serde_json::json!({"error": "invalid payload"}), + )); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let err = sync + .register(request_with_facility(None)) + .await + .expect_err("4xx should surface as an error"); + + assert_eq!( + state.register_hits.load(Ordering::SeqCst), + 1, + "4xx must not be retried" + ); + assert!( + err.to_string().contains("registration rejected 400"), + "got: {err}" + ); +} + +#[tokio::test] +async fn register_retries_on_5xx_then_exhausts() { + let state = Arc::new(MockState::default()); + { + let mut q = state.register_queue.lock().await; + for _ in 0..3 { + q.push_back(( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + serde_json::json!({"error": "boom"}), + )); + } + } + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let err = sync + .register(request_with_facility(None)) + .await + .expect_err("persistent 5xx should exhaust retries"); + + assert_eq!( + state.register_hits.load(Ordering::SeqCst), + 3, + "must retry exactly max_retries times" + ); + assert!( + err.to_string().contains("failed after 3 attempts"), + "got: {err}" + ); +} + +#[tokio::test] +async fn register_retries_on_429_then_succeeds() { + let state = Arc::new(MockState::default()); + { + let mut q = state.register_queue.lock().await; + q.push_back(( + axum::http::StatusCode::TOO_MANY_REQUESTS, + serde_json::json!({}), + )); + q.push_back((axum::http::StatusCode::OK, registered_response("EU-REG-2"))); + } + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let record = sync + .register(request_with_facility(None)) + .await + .expect("should succeed after one retry"); + + assert_eq!(record.status, RegistryStatus::Registered); + assert_eq!(state.register_hits.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn register_unreachable_registration_endpoint_is_not_retried() { + // Token endpoint is live (so token acquisition succeeds)... + let state = Arc::new(MockState::default()); + let base_url_alive = mock_server::spawn(state.clone()).await; + + // ...but the registration endpoint itself points at a dead port, so the + // *second* request (not token acquisition) is what hits Unreachable. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let dead_addr = listener.local_addr().unwrap(); + drop(listener); + + let mut config = mock_config(&base_url_alive); + config.endpoint.base_url = format!("http://{dead_addr}"); + let sync = EuRegistrySync::new(config).unwrap(); + + let err = sync + .register(request_with_facility(None)) + .await + .expect_err("unreachable registry should error"); + + assert!(err.to_string().contains("unreachable"), "got: {err}"); +} + +#[tokio::test] +async fn check_status_success() { + let state = Arc::new(MockState::default()); + state.status_queue.lock().await.push_back(( + axum::http::StatusCode::OK, + serde_json::json!({ + "registryId": "EU-REG-3", + "status": "pending", + "updatedAt": Utc::now().to_rfc3339(), + }), + )); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let record = sync + .check_status(PassportId::new()) + .await + .expect("check_status should succeed"); + + assert_eq!(record.status, RegistryStatus::Pending); + assert_eq!(record.identifiers.registry_id, "EU-REG-3"); +} + +#[tokio::test] +async fn check_status_404_is_fatal_not_found() { + let state = Arc::new(MockState::default()); + state + .status_queue + .lock() + .await + .push_back((axum::http::StatusCode::NOT_FOUND, serde_json::json!({}))); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let err = sync + .check_status(PassportId::new()) + .await + .expect_err("404 should surface as not-found"); + + assert!( + err.to_string().contains("not found in EU registry"), + "got: {err}" + ); +} + +#[tokio::test] +async fn notify_transfer_success() { + let state = Arc::new(MockState::default()); + state + .transfer_queue + .lock() + .await + .push_back((axum::http::StatusCode::OK, registered_response("EU-REG-4"))); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let record = sync + .notify_transfer(PassportId::new(), "did:web:new-operator.example".into()) + .await + .expect("notify_transfer should succeed"); + + assert_eq!(record.status, RegistryStatus::Registered); +} diff --git a/crates/dpp-node/src/infra/registry/token.rs b/crates/dpp-node/src/infra/registry/token.rs index 84a9fd3..86a3140 100644 --- a/crates/dpp-node/src/infra/registry/token.rs +++ b/crates/dpp-node/src/infra/registry/token.rs @@ -20,7 +20,34 @@ pub(super) struct CachedToken { impl CachedToken { pub(super) fn is_expired(&self) -> bool { - // Refresh 30 seconds before actual expiry to avoid edge-case failures. - Instant::now() >= self.expires_at - Duration::from_secs(30) + // Refresh 30 seconds before actual expiry. Compare additively + // (`now + 30s >= expires_at`) rather than subtracting from an `Instant`, + // which panics on underflow when the token expires in under ~30s (a fresh + // restart racing a short-lived token refresh). + Instant::now() + Duration::from_secs(30) >= self.expires_at + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn near_immediate_expiry_reports_expired_without_panicking() { + // Expiry within the 30s refresh window must not underflow-panic. + let t = CachedToken { + access_token: "x".into(), + expires_at: Instant::now(), + }; + assert!(t.is_expired()); + } + + #[test] + fn far_future_token_is_not_expired() { + let t = CachedToken { + access_token: "x".into(), + expires_at: Instant::now() + Duration::from_secs(3600), + }; + assert!(!t.is_expired()); } } diff --git a/crates/dpp-node/src/infra/ruleset.rs b/crates/dpp-node/src/infra/ruleset.rs index cd1a5d0..47e7ec2 100644 --- a/crates/dpp-node/src/infra/ruleset.rs +++ b/crates/dpp-node/src/infra/ruleset.rs @@ -55,7 +55,7 @@ pub fn sign_bundle( effective_date, act_citations, schema_versions, - content_sha256: content_hash(&content), + content_sha256: content_hash(&content)?, }; let manifest_value = serde_json::to_value(&manifest)?; let manifest_jws = jws::sign(store, key_id, &manifest_value)?; @@ -91,7 +91,8 @@ impl ActiveRuleset { effective_date: Utc::now(), act_citations: vec![], schema_versions: BTreeMap::new(), - content_sha256: content_hash(&content), + content_sha256: content_hash(&content) + .expect("JCS canonicalisation of an empty object is infallible"), }; Self { current: RwLock::new(Arc::new(VerifiedRuleset { manifest, content })), diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index f7b7b26..501d501 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -194,14 +194,23 @@ async fn main() -> anyhow::Result<()> { tracing::info!(version = %active_ruleset.version(), "active ruleset"); // ── Vault service state ──────────────────────────────────────────────────── - let operator_country = db + let operator_country = match db .operator_repo .get(dpp_types::STANDALONE_OPERATOR_ID) .await - .ok() - .flatten() - .map(|c| c.country) - .unwrap_or_default(); + { + Ok(cfg) => cfg.map(|c| c.country).unwrap_or_default(), + Err(e) => { + // Don't silently bake an empty country into every registry payload + // for the life of the process — a transient DB hiccup at boot must + // be visible, like every other fallible boot step in this file. + tracing::warn!( + error = %e, + "could not read operator config at boot — operator country left empty this run" + ); + String::new() + } + }; let compliance: Arc = plugin_host.clone(); // Clone the registry-sync port for the outbox drain task before it is moved @@ -225,7 +234,8 @@ async fn main() -> anyhow::Result<()> { ) .with_registry_reader(db.operator_repo.clone()) .with_registry_outbox(db.registry_outbox.clone()) - .with_transfer_store(db.transfer_store.clone()), + .with_transfer_store(db.transfer_store.clone()) + .with_evidence_store(db.evidence_store.clone()), ); let operator_service = Arc::new(OperatorService::new(db.operator_repo.clone())); let api_key_service = Arc::new(ApiKeyService::new(db.api_key_repo.clone())); diff --git a/crates/dpp-node/tests/import_job_store.rs b/crates/dpp-node/tests/import_job_store.rs index 254d372..29918c7 100644 --- a/crates/dpp-node/tests/import_job_store.rs +++ b/crates/dpp-node/tests/import_job_store.rs @@ -19,6 +19,7 @@ use uuid::Uuid; use dpp_dal::pg::{PgDal, sqlx}; use dpp_integrator::{ domain::batch_runner::{BatchResult, CreatedItem, RowError}, + domain::import_report::{FindingKind, ImportMode, ImportReport, ReportRow, RowFinding}, infra::job_store::{ImportJob, JobStatus, JobStore}, }; use dpp_node::infra::pg_job_store::PgJobStore; @@ -95,6 +96,7 @@ async fn job_lifecycle_persists_and_is_retrievable() { row: 1, passport_id: Uuid::now_v7().to_string(), }], + updated: vec![], errors: vec![RowError { row: 2, field: "gtin".into(), @@ -116,3 +118,64 @@ async fn job_lifecycle_persists_and_is_retrievable() { assert!(store.get(Uuid::now_v7()).await.is_none()); } + +// A dry-run job never calls `complete` (there is no `BatchResult` without a +// vault call) — `record_report` is its only persistence path, and must +// round-trip through real Postgres independent of `result`. +#[tokio::test(flavor = "multi_thread")] +async fn record_report_persists_and_survives_sql_round_trip() { + let (dal, _container) = start_pg().await; + let store = PgJobStore::new(dal); + + let id = Uuid::now_v7(); + store.insert(ImportJob::new(id, 2)).await.expect("insert"); + + let report = ImportReport { + mode: ImportMode::DryRun, + total_rows: 2, + rows: vec![ + ReportRow { + row: 1, + valid: true, + action: Some(dpp_integrator::domain::matcher::RowAction::Create), + existing_passport_id: None, + findings: vec![], + }, + ReportRow { + row: 2, + valid: false, + action: None, + existing_passport_id: None, + findings: vec![RowFinding { + kind: FindingKind::Validation, + field: "gtin".into(), + message: "O'Brien said \"invalid\"".into(), + severity: None, + }], + }, + ], + }; + store + .record_report(id, report) + .await + .expect("record_report must persist"); + + let job = store.get(id).await.expect("job retrievable"); + assert!( + job.result.is_none(), + "record_report must not touch the unrelated `result` column" + ); + assert!( + matches!(job.status, JobStatus::Queued), + "record_report must not change status" + ); + let report = job.report.expect("report populated"); + assert!(matches!(report.mode, ImportMode::DryRun)); + assert_eq!(report.rows.len(), 2); + assert!(report.rows[0].valid); + assert!(!report.rows[1].valid); + assert_eq!( + report.rows[1].findings[0].message, "O'Brien said \"invalid\"", + "finding message must survive SQL round-trip intact" + ); +} diff --git a/crates/dpp-node/tests/registry_outbox.rs b/crates/dpp-node/tests/registry_outbox.rs index 98e2e57..77bc51f 100644 --- a/crates/dpp-node/tests/registry_outbox.rs +++ b/crates/dpp-node/tests/registry_outbox.rs @@ -99,6 +99,7 @@ fn draft_passport() -> Passport { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, diff --git a/crates/dpp-node/tests/s3_archive.rs b/crates/dpp-node/tests/s3_archive.rs index 109ec57..6449598 100644 --- a/crates/dpp-node/tests/s3_archive.rs +++ b/crates/dpp-node/tests/s3_archive.rs @@ -68,6 +68,7 @@ fn make_passport() -> Passport { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Published, qr_code_url: None, diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index 7f056a0..9d04e94 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -24,8 +24,8 @@ use base64::Engine as _; use dpp_crypto::identity::LocalIdentityService; use dpp_crypto::keystore::KeyStore; use dpp_dal::pg::{ - PgApiKeyRepo, PgAuditRepo, PgDal, PgOperatorConfigRepo, PgPassportRepo, PgRegistryIdentityRepo, - PgTransferRepo, sqlx, + PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo, + PgRegistryIdentityRepo, PgTransferRepo, sqlx, }; use dpp_domain::{ DppError, GhostArchive, GhostRegistrySync, @@ -184,6 +184,7 @@ async fn start_node_with_dal(dal: PgDal) -> String { String::new(), ) .with_transfer_store(Arc::new(PgTransferRepo::new(dal.clone()))) + .with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))) .with_registry_reader(operator_repo.clone()), ); let operator_service = Arc::new(OperatorService::new(operator_repo)); diff --git a/crates/dpp-plugin-host/src/host.rs b/crates/dpp-plugin-host/src/host.rs index 7cc69e4..86a5d82 100644 --- a/crates/dpp-plugin-host/src/host.rs +++ b/crates/dpp-plugin-host/src/host.rs @@ -70,7 +70,7 @@ impl WasmPluginHost { key, ); - let payload = plugin + let mut payload = plugin .invoke_generate_passport(&input) .map_err(|e| ComplianceError { kind: ComplianceErrorKind::Internal, @@ -93,6 +93,17 @@ impl WasmPluginHost { }); } + // Host-side backstop mirroring compute()'s determination gate: a + // provisional (not-in-force) sector can never surface a binding + // compliance claim, even if the plugin ignores the advisory __isInForce + // flag and injects one into its generated output. + if !catalog().is_in_force(key) + && let Some(obj) = payload.as_object_mut() + { + obj.remove("complianceStatus"); + obj.remove("complianceResult"); + } + Ok(payload) } } @@ -136,11 +147,12 @@ impl PluginHost for WasmPluginHost { Ok(r) => r, Err(e) => { let msg = e.to_string(); - // Fuel exhaustion is a distinct sandbox event — track it separately - // so the alert rule "any increment = sandbox limit actually firing" - // is unambiguous. - if msg.to_lowercase().contains("fuel") || msg.to_lowercase().contains("out of fuel") - { + // Classify sandbox events from HOST-controlled error prefixes, + // never from plugin-controlled text. A fuel trap is remapped to a + // "fuel exhausted" prefix and the memory cap bail to "memory cap + // exceeded"; a plugin's own error surfaces as "plugin reported an + // error: …", so it cannot spoof either metric/alert. + if msg.starts_with("fuel exhausted") { metrics::counter!( "plugin_fuel_exhausted_total", "sector" => key.to_owned() @@ -152,9 +164,7 @@ impl PluginHost for WasmPluginHost { "Wasm plugin exhausted fuel budget" ); } - // Memory cap is signalled by ResourceLimiter::memory_growing returning Err - // with a message containing "memory cap" (see runtime.rs). - if msg.contains("memory cap") { + if msg.starts_with("memory cap exceeded") { metrics::counter!( "plugin_mem_capped_total", "sector" => key.to_owned() diff --git a/crates/dpp-plugin-host/src/loader/plugin.rs b/crates/dpp-plugin-host/src/loader/plugin.rs index d7f3465..978da42 100644 --- a/crates/dpp-plugin-host/src/loader/plugin.rs +++ b/crates/dpp-plugin-host/src/loader/plugin.rs @@ -18,6 +18,26 @@ use crate::runtime::{DEFAULT_FUEL, DEFAULT_MEMORY_CAP_BYTES, HostState, build_st /// Maximum bytes accepted from any plugin ABI output (4 MiB). const MAX_ABI_OUTPUT_BYTES: usize = 4 * 1024 * 1024; +/// Whether unsigned plugin loading is explicitly opted into, from the +/// `DPP_ALLOW_UNSIGNED_PLUGINS` value. Pure (takes the value) so it is testable +/// without mutating the process-global environment. +fn unsigned_allowed(env_value: Option<&str>) -> bool { + matches!(env_value, Some(v) if v.eq_ignore_ascii_case("true")) +} + +/// Map a wasmtime call error, giving a fuel-exhaustion trap a distinct, +/// host-controlled `"fuel exhausted"` prefix. Sandbox metrics are then classified +/// from this prefix (and the host's `"memory cap exceeded"` bail), never by +/// string-matching plugin-controlled error text — a plugin's structured error +/// surfaces as `"plugin reported an error: …"` and so cannot spoof either signal. +fn map_call_error(e: wasmtime::Error) -> anyhow::Error { + if e.downcast_ref::() == Some(&wasmtime::Trap::OutOfFuel) { + anyhow::anyhow!("fuel exhausted: plugin exceeded its fuel budget") + } else { + anyhow::anyhow!("{e}") + } +} + /// A compiled, in-memory sector plugin ready to be instantiated per-request. pub struct LoadedPlugin { engine: Engine, @@ -57,9 +77,23 @@ impl LoadedPlugin { return Err(e); } } else { + // No trusted key configured. Unsigned loading is a development-only + // convenience and must be explicitly opted into — otherwise a + // misconfigured production deploy would silently run unverified + // plugins. Fail closed unless `DPP_ALLOW_UNSIGNED_PLUGINS=true`. + let allow_unsigned = + unsigned_allowed(std::env::var("DPP_ALLOW_UNSIGNED_PLUGINS").ok().as_deref()); + if !allow_unsigned { + return Err(anyhow::anyhow!( + "refusing to load unsigned plugin {}: no trusted key is configured and \ + DPP_ALLOW_UNSIGNED_PLUGINS is not set (production must provide a key)", + path.display() + )); + } tracing::warn!( path = %path.display(), - "loading Wasm plugin WITHOUT signature verification — not safe for production" + "loading Wasm plugin WITHOUT signature verification \ + (DPP_ALLOW_UNSIGNED_PLUGINS=true) — not safe for production" ); } @@ -277,7 +311,7 @@ fn call_generate_passport( let packed = generate .call(&mut *store, (input_ptr, input_len)) - .map_err(w)?; + .map_err(map_call_error)?; let out_ptr = (packed >> 32) as usize; let out_len = (packed & 0xFFFF_FFFF) as usize; @@ -335,7 +369,7 @@ fn call_calculate( // Returns a packed (ptr << 32 | len) u64 let packed = calculate .call(&mut *store, (input_ptr, input_len)) - .map_err(w)?; + .map_err(map_call_error)?; let out_ptr = (packed >> 32) as usize; let out_len = (packed & 0xFFFF_FFFF) as usize; @@ -359,3 +393,258 @@ fn call_calculate( Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::build_engine; + use dpp_domain::ports::compliance::ComplianceStatus; + use dpp_plugin_traits::PluginCapability; + use ed25519_dalek::SigningKey; + use tempfile::TempDir; + + const DESCRIBE_JSON: &str = r#"{"abiVersion":{"major":1,"minor":0},"supportedSchemas":[],"capabilities":["compute_metrics"],"maxFuel":500000,"maxMemoryBytes":1048576}"#; + const CALC_OK_JSON: &str = + r#"{"ok":{"complianceStatus":"COMPLIANT","metrics":{"co2e_score":1.5}}}"#; + const CALC_ERR_JSON: &str = r#"{"error":{"Internal":"boom"}}"#; + const GEN_OK_JSON: &str = r#"{"ok":{"productName":"Test Widget"}}"#; + + fn wat_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") + } + + fn pack(offset: u32, len: u32) -> i64 { + (((offset as u64) << 32) | (len as u64)) as i64 + } + + /// Builds a fake plugin `.wasm` with `alloc`/`dealloc`/`memory`/`describe` always + /// present, and `calculate_metrics`/`generate_passport` present only when their + /// JSON is `Some`. Each export returns a fixed packed `(ptr<<32|len)` pointing at + /// a canned JSON blob embedded via a WAT `data` segment — no real allocation + /// logic is needed since these fixtures ignore their input entirely. + fn build_plugin_wasm( + describe_json: &str, + calculate_json: Option<&str>, + calculate_len_override: Option, + generate_json: Option<&str>, + ) -> Vec { + let alloc_offset: u32 = 1024; + let describe_offset: u32 = 4096; + let calc_offset: u32 = 8192; + let gen_offset: u32 = 12288; + + let mut data_segments = format!( + "(data (i32.const {describe_offset}) \"{}\")\n", + wat_escape(describe_json) + ); + let describe_packed = pack(describe_offset, describe_json.len() as u32); + + let mut funcs = String::new(); + + if let Some(cj) = calculate_json { + data_segments += &format!("(data (i32.const {calc_offset}) \"{}\")\n", wat_escape(cj)); + let len = calculate_len_override.unwrap_or(cj.len() as u32); + let packed = pack(calc_offset, len); + funcs += &format!( + "(func (export \"calculate_metrics\") (param $ptr i32) (param $len i32) (result i64)\n i64.const {packed})\n" + ); + } + + if let Some(gj) = generate_json { + data_segments += &format!("(data (i32.const {gen_offset}) \"{}\")\n", wat_escape(gj)); + let packed = pack(gen_offset, gj.len() as u32); + funcs += &format!( + "(func (export \"generate_passport\") (param $ptr i32) (param $len i32) (result i64)\n i64.const {packed})\n" + ); + } + + let wat = format!( + r#"(module + (memory (export "memory") 2) + {data_segments} + (func (export "alloc") (param $len i32) (result i32) + i32.const {alloc_offset}) + (func (export "dealloc") (param $ptr i32) (param $len i32)) + (func (export "describe") (result i64) + i64.const {describe_packed}) + {funcs} +)"# + ); + + wat::parse_str(&wat).expect("generated WAT must parse") + } + + fn write_plugin_file(dir: &TempDir, wasm: &[u8]) -> std::path::PathBuf { + let path = dir.path().join("plugin.wasm"); + std::fs::write(&path, wasm).unwrap(); + path + } + + fn full_plugin_wasm() -> Vec { + build_plugin_wasm(DESCRIBE_JSON, Some(CALC_OK_JSON), None, Some(GEN_OK_JSON)) + } + + /// Load a plugin without a trusted key — the development path. Opts into + /// unsigned loading explicitly, mirroring what a real dev/CI deploy must do. + fn load_unsigned(engine: &Engine, path: &Path, sector: &str) -> Result { + // Safe: every caller sets the same value, so concurrent sets are benign. + unsafe { std::env::set_var("DPP_ALLOW_UNSIGNED_PLUGINS", "true") }; + LoadedPlugin::from_file(engine, path, sector, None) + } + + #[test] + fn unsigned_allowed_only_when_explicitly_opted_in() { + // The refusal decision is a pure function of the env value, so it can be + // asserted deterministically without racing the process-global env. + assert!(super::unsigned_allowed(Some("true"))); + assert!(super::unsigned_allowed(Some("TRUE"))); + assert!(!super::unsigned_allowed(Some("false"))); + assert!(!super::unsigned_allowed(Some("1"))); + assert!(!super::unsigned_allowed(Some(""))); + assert!(!super::unsigned_allowed(None)); + } + + #[test] + fn from_file_refuses_when_signature_verification_fails() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + // Any bytes work — verification happens before the file is read as wasm, + // and there's no `.sig` file at all. + let path = write_plugin_file(&dir, b"not real wasm bytes"); + let trusted_key = SigningKey::from_bytes(&[9; 32]).verifying_key(); + + let err = match LoadedPlugin::from_file(&engine, &path, "battery", Some(&trusted_key)) { + Ok(_) => panic!("missing signature must refuse to load"), + Err(e) => e, + }; + assert!(err.to_string().contains("signature file not found")); + } + + #[test] + fn from_file_without_trusted_key_falls_back_to_host_defaults_when_describe_missing() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + // A module with zero exports: instantiates fine, but `call_describe` fails + // (missing `dealloc`/`describe`), so `from_file` must fall back rather than + // propagate that error. + let wasm = wat::parse_str("(module)").unwrap(); + let path = write_plugin_file(&dir, &wasm); + + let plugin = load_unsigned(&engine, &path, "battery") + .expect("dev-mode load without a key must succeed even without describe()"); + assert!(plugin.capabilities.supported_schemas.is_empty()); + assert!(plugin.capabilities.capabilities.is_empty()); + assert_eq!(plugin.capabilities.max_fuel, None); + } + + #[test] + fn from_file_caches_declared_capabilities_from_describe() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + let wasm = build_plugin_wasm(DESCRIBE_JSON, Some(CALC_OK_JSON), None, None); + let path = write_plugin_file(&dir, &wasm); + + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + assert_eq!(plugin.capabilities.abi_version.major, 1); + assert_eq!( + plugin.capabilities.capabilities, + vec![PluginCapability::ComputeMetrics] + ); + assert_eq!(plugin.capabilities.max_fuel, Some(500_000)); + assert_eq!(plugin.capabilities.max_memory_bytes, Some(1_048_576)); + } + + #[test] + fn invoke_calculate_success_maps_plugin_result() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + let path = write_plugin_file(&dir, &full_plugin_wasm()); + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + let result = plugin + .invoke_calculate(&serde_json::json!({"gtin": "irrelevant — fixture ignores input"})) + .expect("calculate should succeed"); + + assert_eq!(result.compliance_status, ComplianceStatus::Compliant); + assert_eq!(result.co2e_score, Some(1.5)); + } + + #[test] + fn invoke_generate_passport_success_returns_raw_payload() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + let path = write_plugin_file(&dir, &full_plugin_wasm()); + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + let payload = plugin + .invoke_generate_passport(&serde_json::json!({})) + .expect("generate_passport should succeed"); + + assert_eq!(payload["productName"], "Test Widget"); + } + + #[test] + fn invoke_calculate_surfaces_plugin_reported_error() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + let wasm = build_plugin_wasm(DESCRIBE_JSON, Some(CALC_ERR_JSON), None, None); + let path = write_plugin_file(&dir, &wasm); + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + let err = plugin + .invoke_calculate(&serde_json::json!({})) + .expect_err("plugin-reported error must surface as Err"); + assert!(err.to_string().contains("plugin reported an error")); + } + + #[test] + fn invoke_calculate_rejects_invalid_json_output() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + let wasm = build_plugin_wasm(DESCRIBE_JSON, Some("not json at all"), None, None); + let path = write_plugin_file(&dir, &wasm); + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + let err = plugin + .invoke_calculate(&serde_json::json!({})) + .expect_err("non-JSON output must be rejected"); + assert!(err.to_string().contains("invalid JSON")); + } + + #[test] + fn invoke_calculate_rejects_output_exceeding_size_cap() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + // Claims an output length far beyond MAX_ABI_OUTPUT_BYTES; the size check + // must reject this before ever attempting to read that much memory. + let oversized_len = (MAX_ABI_OUTPUT_BYTES + 1) as u32; + let wasm = build_plugin_wasm(DESCRIBE_JSON, Some(CALC_OK_JSON), Some(oversized_len), None); + let path = write_plugin_file(&dir, &wasm); + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + let err = plugin + .invoke_calculate(&serde_json::json!({})) + .expect_err("oversized output must be rejected"); + assert!(err.to_string().contains("output too large")); + } + + #[test] + fn invoke_calculate_errors_cleanly_when_export_missing() { + let engine = build_engine().unwrap(); + let dir = TempDir::new().unwrap(); + // describe() succeeds (so from_file loads fine) but calculate_metrics was + // never exported — a partial/malformed plugin must fail predictably, not panic. + let wasm = build_plugin_wasm(DESCRIBE_JSON, None, None, Some(GEN_OK_JSON)); + let path = write_plugin_file(&dir, &wasm); + let plugin = load_unsigned(&engine, &path, "battery").unwrap(); + + let err = plugin + .invoke_calculate(&serde_json::json!({})) + .expect_err("missing export must error, not panic"); + assert!( + err.to_string() + .contains("missing 'calculate_metrics' export") + ); + } +} diff --git a/crates/dpp-plugin-host/src/loader/signing.rs b/crates/dpp-plugin-host/src/loader/signing.rs index c2fd8f2..eb126f8 100644 --- a/crates/dpp-plugin-host/src/loader/signing.rs +++ b/crates/dpp-plugin-host/src/loader/signing.rs @@ -68,3 +68,153 @@ pub(crate) fn verify_plugin_signature(wasm_path: &Path, trusted_key: &VerifyingK ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + use tempfile::TempDir; + + fn keypair(seed: u8) -> (SigningKey, VerifyingKey) { + let signing_key = SigningKey::from_bytes(&[seed; 32]); + let verifying_key = signing_key.verifying_key(); + (signing_key, verifying_key) + } + + /// Writes `wasm_bytes` to `{dir}/plugin.wasm` and a correctly signed + /// `plugin.wasm.sig` next to it, returning the wasm path. + fn write_signed_plugin( + dir: &TempDir, + wasm_bytes: &[u8], + signing_key: &SigningKey, + ) -> std::path::PathBuf { + let wasm_path = dir.path().join("plugin.wasm"); + std::fs::write(&wasm_path, wasm_bytes).unwrap(); + let digest = Sha256::digest(wasm_bytes); + let signature = signing_key.sign(&digest); + std::fs::write(wasm_path.with_extension("wasm.sig"), signature.to_bytes()).unwrap(); + wasm_path + } + + #[test] + fn valid_raw_signature_verifies() { + let (signing_key, verifying_key) = keypair(1); + let dir = TempDir::new().unwrap(); + let wasm_path = write_signed_plugin(&dir, b"fake wasm bytes", &signing_key); + + verify_plugin_signature(&wasm_path, &verifying_key).expect("signature should verify"); + } + + #[test] + fn valid_base64_signature_verifies() { + let (signing_key, verifying_key) = keypair(2); + let dir = TempDir::new().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + let wasm_bytes = b"fake wasm bytes"; + std::fs::write(&wasm_path, wasm_bytes).unwrap(); + let digest = Sha256::digest(wasm_bytes); + let signature = signing_key.sign(&digest); + let encoded = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()); + std::fs::write(wasm_path.with_extension("wasm.sig"), encoded).unwrap(); + + verify_plugin_signature(&wasm_path, &verifying_key) + .expect("base64 signature should verify"); + } + + #[test] + fn base64_signature_with_trailing_newline_verifies() { + let (signing_key, verifying_key) = keypair(3); + let dir = TempDir::new().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + let wasm_bytes = b"fake wasm bytes"; + std::fs::write(&wasm_path, wasm_bytes).unwrap(); + let digest = Sha256::digest(wasm_bytes); + let signature = signing_key.sign(&digest); + let mut encoded = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()); + encoded.push('\n'); + std::fs::write(wasm_path.with_extension("wasm.sig"), encoded).unwrap(); + + verify_plugin_signature(&wasm_path, &verifying_key) + .expect("base64 signature with trailing newline should verify"); + } + + #[test] + fn missing_signature_file_is_rejected() { + let (_signing_key, verifying_key) = keypair(4); + let dir = TempDir::new().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + std::fs::write(&wasm_path, b"fake wasm bytes").unwrap(); + + let err = verify_plugin_signature(&wasm_path, &verifying_key).unwrap_err(); + assert!(err.to_string().contains("signature file not found")); + } + + #[test] + fn tampered_wasm_bytes_fail_verification() { + let (signing_key, verifying_key) = keypair(5); + let dir = TempDir::new().unwrap(); + let wasm_path = write_signed_plugin(&dir, b"original bytes", &signing_key); + // Tamper with the wasm file after signing — digest no longer matches. + std::fs::write(&wasm_path, b"tampered bytes!!").unwrap(); + + let err = verify_plugin_signature(&wasm_path, &verifying_key).unwrap_err(); + assert!(err.to_string().contains("signature verification failed")); + } + + #[test] + fn signature_from_wrong_key_is_rejected() { + let (signing_key, _matching_key) = keypair(6); + let (_other_signing_key, wrong_verifying_key) = keypair(7); + let dir = TempDir::new().unwrap(); + let wasm_path = write_signed_plugin(&dir, b"fake wasm bytes", &signing_key); + + let err = verify_plugin_signature(&wasm_path, &wrong_verifying_key).unwrap_err(); + assert!(err.to_string().contains("signature verification failed")); + } + + #[test] + fn malformed_signature_file_is_rejected() { + let (_signing_key, verifying_key) = keypair(8); + let dir = TempDir::new().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + std::fs::write(&wasm_path, b"fake wasm bytes").unwrap(); + // Not 64 bytes, and not valid base64 (contains spaces and '!'). + std::fs::write( + wasm_path.with_extension("wasm.sig"), + b"this is not base64 and not 64 bytes!", + ) + .unwrap(); + + let err = verify_plugin_signature(&wasm_path, &verifying_key).unwrap_err(); + assert!( + err.to_string() + .contains("neither raw 64 bytes nor valid base64") + ); + } + + #[test] + fn base64_decoding_to_wrong_length_is_rejected() { + let (_signing_key, verifying_key) = keypair(9); + let dir = TempDir::new().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + std::fs::write(&wasm_path, b"fake wasm bytes").unwrap(); + // Valid base64, but decodes to far fewer than 64 bytes. + let encoded = base64::engine::general_purpose::STANDARD.encode(b"too short"); + std::fs::write(wasm_path.with_extension("wasm.sig"), encoded).unwrap(); + + let err = verify_plugin_signature(&wasm_path, &verifying_key).unwrap_err(); + assert!(err.to_string().contains("invalid Ed25519 signature format")); + } + + #[test] + fn missing_wasm_file_is_rejected() { + let (signing_key, verifying_key) = keypair(10); + let dir = TempDir::new().unwrap(); + // Sign a wasm file, then delete it — only the .sig remains. + let wasm_path = write_signed_plugin(&dir, b"fake wasm bytes", &signing_key); + std::fs::remove_file(&wasm_path).unwrap(); + + let err = verify_plugin_signature(&wasm_path, &verifying_key).unwrap_err(); + assert!(err.to_string().contains("failed to read wasm file")); + } +} diff --git a/crates/dpp-plugin-host/src/runtime.rs b/crates/dpp-plugin-host/src/runtime.rs index e8097ab..52a310f 100644 --- a/crates/dpp-plugin-host/src/runtime.rs +++ b/crates/dpp-plugin-host/src/runtime.rs @@ -63,13 +63,23 @@ impl ResourceLimiter for HostState { fn table_growing( &mut self, _current: usize, - _desired: usize, + desired: usize, _maximum: Option, ) -> Result { - Ok(true) + // Meter table growth like linear memory. Otherwise a single `table.grow` + // with a huge element count forces a large host-side allocation that + // fuel counts as only one instruction — a resource-limit bypass distinct + // from the (capped) linear-memory path. Ok(false) makes `table.grow` + // return -1 (the Wasm denial signal). Sector plugins use tiny indirect + // tables, so this ceiling is generous. + Ok(desired <= MAX_TABLE_ELEMENTS) } } +/// Maximum number of elements a plugin's tables may grow to. A funcref/externref +/// element is a few host bytes, so this caps table growth well under a MiB. +const MAX_TABLE_ELEMENTS: usize = 100_000; + /// Create a sandboxed `Store` with WASI disabled for filesystem and network. /// /// `fuel` and `memory_cap` override the defaults; the host always clamps them diff --git a/crates/dpp-plugin-host/src/tests.rs b/crates/dpp-plugin-host/src/tests.rs index 94d0af1..dff8ee6 100644 --- a/crates/dpp-plugin-host/src/tests.rs +++ b/crates/dpp-plugin-host/src/tests.rs @@ -101,6 +101,7 @@ fn empty_host_reports_no_sector_plugin() { fn empty_host_compliance_returns_passthrough() { let host = WasmPluginHost::new(); let data = SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![FibreEntry { fibre: "Cotton".into(), pct: 100.0, @@ -220,6 +221,7 @@ fn enrich_input_non_object_passes_through() { fn generate_passport_payload_no_plugin_returns_unknown_sector() { let host = WasmPluginHost::new(); let data = SectorData::Textile(TextileData { + gtin: "09506000134352".into(), fibre_composition: vec![FibreEntry { fibre: "Cotton".into(), pct: 100.0, diff --git a/crates/dpp-resolver/src/domain/id.rs b/crates/dpp-resolver/src/domain/id.rs index f0d4e33..c0b3412 100644 --- a/crates/dpp-resolver/src/domain/id.rs +++ b/crates/dpp-resolver/src/domain/id.rs @@ -10,6 +10,17 @@ pub fn is_valid_dpp_id(id: &str) -> bool { uuid::Uuid::parse_str(id).is_ok() } +/// Whether `gtin` is a syntactically valid GTIN for resolution: 8–14 ASCII +/// digits and nothing else. +/// +/// Validated at the resolver edge for the same reason as [`is_valid_dpp_id`]: +/// a value like `../admin` (from a percent-encoded `/01/{gtin}` path segment, +/// which Axum decodes after routing) must never reach the server-to-server +/// vault URL, closing a path-traversal / SSRF surface. +pub fn is_valid_gtin(gtin: &str) -> bool { + (8..=14).contains(>in.len()) && gtin.bytes().all(|b| b.is_ascii_digit()) +} + #[cfg(test)] mod tests { use super::is_valid_dpp_id; @@ -34,4 +45,22 @@ mod tests { assert!(!is_valid_dpp_id(bad), "should reject: {bad}"); } } + + #[test] + fn gtin_accepts_digits_rejects_traversal() { + use super::is_valid_gtin; + assert!(is_valid_gtin("09506000134352")); // GTIN-14 + assert!(is_valid_gtin("12345678")); // GTIN-8 + for bad in [ + "../admin", + "%2E%2E", + "0950/6000", + "abc", + "", + "1234567", + "123456789012345", + ] { + assert!(!is_valid_gtin(bad), "should reject: {bad}"); + } + } } diff --git a/crates/dpp-resolver/src/domain/mod.rs b/crates/dpp-resolver/src/domain/mod.rs index 2196c11..ea3c9b6 100644 --- a/crates/dpp-resolver/src/domain/mod.rs +++ b/crates/dpp-resolver/src/domain/mod.rs @@ -5,4 +5,4 @@ mod id; -pub use id::is_valid_dpp_id; +pub use id::{is_valid_dpp_id, is_valid_gtin}; diff --git a/crates/dpp-resolver/src/handlers/resolve_by_gtin.rs b/crates/dpp-resolver/src/handlers/resolve_by_gtin.rs index e88394f..1c7f8d2 100644 --- a/crates/dpp-resolver/src/handlers/resolve_by_gtin.rs +++ b/crates/dpp-resolver/src/handlers/resolve_by_gtin.rs @@ -11,6 +11,11 @@ use serde_json::Value; use crate::state::AppState; +/// The resolver's own public base URL (its GS1 Digital Link host). Hardcoded +/// rather than derived from mutable passport data, matching the QR-code handler +/// — this is what closes the open-redirect surface. +const RESOLVER_BASE_URL: &str = "https://id.odal-node.io"; + /// Query parameters for the GS1 Digital Link resolver endpoint (`/01/{gtin}`). #[derive(Deserialize)] pub struct ByGtinQuery { @@ -33,6 +38,17 @@ pub async fn resolve_by_gtin_handler( Query(query): Query, headers: HeaderMap, ) -> impl IntoResponse { + // Validate the GTIN at the edge before it reaches the server-to-server vault + // URL — a percent-decoded `../admin` must not path-traverse/SSRF the vault. + if !crate::domain::is_valid_gtin(>in) { + return ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "application/json")], + r#"{"error":"NOT_FOUND","message":"No published DPP for this GTIN"}"#.to_owned(), + ) + .into_response(); + } + let passport = match fetch_by_gtin(&state, >in).await { Ok(v) => v, Err(status) => { @@ -52,13 +68,12 @@ pub async fn resolve_by_gtin_handler( } }; - // Derive the resolver's public base URL from qrCodeUrl stored in the passport. - // Battery GS1 DL URL: https://id.odal-node.io/01/{gtin}/21/{uuid} - let base_url = passport - .get("qrCodeUrl") - .and_then(Value::as_str) - .and_then(resolver_base) - .unwrap_or_else(|| "https://id.odal-node.io".to_owned()); + // The resolver's own trusted base URL. It is NOT derived from the passport's + // `qrCodeUrl`: that field is mutable and content-binding-exempt (tamperable), + // so trusting it would let an altered value 307-redirect the scan to an + // attacker host — the exact open redirect the QR-code handler hardcodes + // against. + let base_url = RESOLVER_BASE_URL; // Linkset request: ?linkType=linkset OR Accept: application/linkset+json let wants_linkset = query.link_type.as_deref() == Some("linkset") @@ -69,7 +84,7 @@ pub async fn resolve_by_gtin_handler( .unwrap_or(false); if wants_linkset { - let body = serde_json::to_string(&build_linkset(&base_url, >in, &passport_id)) + let body = serde_json::to_string(&build_linkset(base_url, >in, &passport_id)) .unwrap_or_default(); return ( StatusCode::OK, @@ -115,16 +130,6 @@ fn redirect(location: &str) -> axum::response::Response { .into_response() } -/// Extract `https://host` from a GS1 DL URL like -/// `https://id.odal-node.io/01/{gtin}/21/{uuid}`. -fn resolver_base(qr_code_url: &str) -> Option { - let without_scheme = qr_code_url - .strip_prefix("https://") - .or_else(|| qr_code_url.strip_prefix("http://"))?; - let host = without_scheme.split('/').next()?; - Some(format!("https://{host}")) -} - /// Build a GS1 linkset per RFC 9264 / GS1 Digital Link standard. fn build_linkset(base_url: &str, gtin: &str, passport_id: &str) -> serde_json::Value { let anchor = format!("{base_url}/01/{gtin}"); @@ -171,15 +176,6 @@ async fn fetch_by_gtin(state: &AppState, gtin: &str) -> Result String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "alloyGrade": "6061-T6", + "productionRoute": "Primary", + "countryOfProduction": "DE", + "co2ePerTonneKg": 8500.5, + "recycledContentPct": 22.3, + }}); + let html = build_aluminium_section(&p); + assert!(html.contains("6061-T6")); + assert!(html.contains("Primary")); + assert!(html.contains("DE")); + assert!(html.contains("8500.50 kg CO\u{2082}e / t")); + assert!(html.contains("22.3%")); + } + + #[test] + fn missing_fields_fall_back_to_dashes() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_aluminium_section(&p); + assert!(html.contains("Aluminium Product Information")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_aluminium_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/construction.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/construction.rs index 8f8b2bf..571ffa7 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/construction.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/construction.rs @@ -39,3 +39,45 @@ pub(super) fn build_construction_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "productFamily": "Insulation Board", + "countryOfManufacture": "FR", + "functionalUnit": "m2", + "co2ePerFunctionalUnitKg": 3.25, + "ceMarking": true, + }}); + let html = build_construction_section(&p); + assert!(html.contains("Insulation Board")); + assert!(html.contains("FR")); + assert!(html.contains("3.25 kg CO\u{2082}e / m2")); + assert!(html.contains(">Yes<")); + } + + #[test] + fn ce_marking_false_renders_no() { + let p = serde_json::json!({"sectorData": {"ceMarking": false}}); + let html = build_construction_section(&p); + assert!(html.contains(">No<")); + } + + #[test] + fn missing_fields_fall_back_to_dashes_and_default_unit() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_construction_section(&p); + assert!(html.contains("Construction Product Information")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_construction_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/detergent.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/detergent.rs index 862db9e..0b648b3 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/detergent.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/detergent.rs @@ -43,3 +43,46 @@ pub(super) fn build_detergent_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "productType": "Laundry Detergent", + "format": "Liquid", + "countryOfManufacture": "NL", + "biodegradable": true, + "surfactants": ["anionic", "nonionic"], + }}); + let html = build_detergent_section(&p); + assert!(html.contains("Laundry Detergent")); + assert!(html.contains("Liquid")); + assert!(html.contains("NL")); + assert!(html.contains("All surfactants biodegradable")); + assert!(html.contains(">2<")); + } + + #[test] + fn not_biodegradable_renders_the_negative_message() { + let p = serde_json::json!({"sectorData": {"biodegradable": false}}); + let html = build_detergent_section(&p); + assert!(html.contains("Not fully biodegradable")); + } + + #[test] + fn missing_fields_fall_back_to_dashes() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_detergent_section(&p); + assert!(html.contains("Detergent Information")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_detergent_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/electronics.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/electronics.rs index d72ad07..8fb8c04 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/electronics.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/electronics.rs @@ -41,3 +41,39 @@ pub(super) fn build_electronics_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "productCategory": "Smartphone", + "energyEfficiencyClass": "A", + "co2ePerUnitKg": 42.1, + "repairabilityScore": 7.5, + "expectedLifetimeYears": 5, + }}); + let html = build_electronics_section(&p); + assert!(html.contains("Smartphone")); + assert!(html.contains(">A<")); + assert!(html.contains("42.10 kg CO\u{2082}e")); + assert!(html.contains("7.5 / 10")); + assert!(html.contains("5 years")); + } + + #[test] + fn missing_co2e_reports_not_disclosed() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_electronics_section(&p); + assert!(html.contains("Not disclosed")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_electronics_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/furniture.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/furniture.rs index 65a620c..bc629ca 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/furniture.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/furniture.rs @@ -40,3 +40,42 @@ pub(super) fn build_furniture_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "productType": "Office Chair", + "primaryMaterial": "Steel & Fabric", + "countryOfManufacture": "SE", + "co2ePerUnitKg": 18.4, + "repairabilityScore": 6.0, + }}); + let html = build_furniture_section(&p); + assert!(html.contains("Office Chair")); + assert!( + html.contains("Steel & Fabric"), + "esc() must escape '&'; got: {html}" + ); + assert!(html.contains("SE")); + assert!(html.contains("18.40 kg CO\u{2082}e")); + assert!(html.contains("6.0 / 10")); + } + + #[test] + fn missing_co2e_reports_not_disclosed() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_furniture_section(&p); + assert!(html.contains("Not disclosed")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_furniture_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/steel.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/steel.rs index dc851dd..1c6936a 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/steel.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/steel.rs @@ -40,3 +40,39 @@ pub(super) fn build_steel_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "productionRoute": "EAF", + "productCategory": "Flat Steel", + "countryOfProduction": "IT", + "co2ePerTonneSteel": 0.850, + "recycledScrapContentPct": 65.0, + }}); + let html = build_steel_section(&p); + assert!(html.contains("EAF")); + assert!(html.contains("Flat Steel")); + assert!(html.contains("IT")); + assert!(html.contains("0.850 t CO\u{2082}e / t steel")); + assert!(html.contains("65.0%")); + } + + #[test] + fn missing_fields_fall_back_to_dashes() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_steel_section(&p); + assert!(html.contains("Steel Product Information")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_steel_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/toy.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/toy.rs index 3ab1da3..af222c2 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/toy.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/toy.rs @@ -31,3 +31,44 @@ pub(super) fn build_toy_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "ageGroup": "3+", + "primaryMaterial": "ABS Plastic", + "countryOfManufacture": "CN", + "ceMarking": true, + }}); + let html = build_toy_section(&p); + assert!(html.contains("3+")); + assert!(html.contains("ABS Plastic")); + assert!(html.contains("CN")); + assert!(html.contains(">Yes<")); + } + + #[test] + fn ce_marking_false_renders_no() { + let p = serde_json::json!({"sectorData": {"ceMarking": false}}); + let html = build_toy_section(&p); + assert!(html.contains(">No<")); + } + + #[test] + fn missing_fields_fall_back_to_dashes() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_toy_section(&p); + assert!(html.contains("Toy Safety Information")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_toy_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/handlers/resolve_html/sections/tyre.rs b/crates/dpp-resolver/src/handlers/resolve_html/sections/tyre.rs index cc7b23e..887f260 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/sections/tyre.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/sections/tyre.rs @@ -36,3 +36,39 @@ pub(super) fn build_tyre_section(p: &serde_json::Value) -> String { "# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_data_populates_all_fields() { + let p = serde_json::json!({"sectorData": { + "tyreClass": "C1", + "fuelEfficiencyClass": "B", + "wetGripClass": "A", + "externalRollingNoiseDb": 68.0, + "noisePerformanceClass": "1", + }}); + let html = build_tyre_section(&p); + assert!(html.contains(">C1<")); + assert!(html.contains(">B<")); + assert!(html.contains(">A<")); + assert!(html.contains("68 dB")); + assert!(html.contains(">1<")); + } + + #[test] + fn missing_fields_fall_back_to_dashes() { + let p = serde_json::json!({"sectorData": {}}); + let html = build_tyre_section(&p); + assert!(html.contains("Tyre Labelling Information")); + assert!(html.contains(">-<")); + } + + #[test] + fn absent_sector_data_returns_empty_string() { + let p = serde_json::json!({}); + assert_eq!(build_tyre_section(&p), ""); + } +} diff --git a/crates/dpp-resolver/src/infra/did.rs b/crates/dpp-resolver/src/infra/did.rs index 98eb402..c4a3de5 100644 --- a/crates/dpp-resolver/src/infra/did.rs +++ b/crates/dpp-resolver/src/infra/did.rs @@ -157,9 +157,10 @@ mod tests { // ── DAL D2: MUTABLE_FIELDS parity guard ───────────────────────────────── /// `MUTABLE_FIELDS` must equal the DB retention trigger's `mutable_keys` - /// array (`0004_passport.sql`, amended by `0011_public_jws_mutable.sql`): - /// the fields a retention-locked passport may still change. Machine-checks - /// the DAL D2 invariant so the two cannot silently diverge. + /// array (`0004_passport.sql`, amended by `0011_public_jws_mutable.sql` + /// and `0018_lint_result_mutable.sql`): the fields a retention-locked + /// passport may still change. Machine-checks the DAL D2 invariant so the + /// two cannot silently diverge. #[test] fn mutable_fields_matches_db_trigger_mutable_keys() { let expected: &[&str] = &[ @@ -170,6 +171,7 @@ mod tests { "publishedAt", "retentionLocked", "updatedAt", + "lintResult", ]; let mut actual = MUTABLE_FIELDS.to_vec(); let mut want = expected.to_vec(); diff --git a/crates/dpp-seal/src/adapter.rs b/crates/dpp-seal/src/adapter.rs index ec43761..2797359 100644 --- a/crates/dpp-seal/src/adapter.rs +++ b/crates/dpp-seal/src/adapter.rs @@ -48,6 +48,42 @@ impl SealPort for QtspSealAdapter { } fn capabilities(&self) -> SealCapabilities { - GhostSeal.capabilities() + if self.qtsp_url.is_none() { + // Ghost-backed placeholder path — report exactly what `GhostSeal` + // actually does (its seal()/verify() succeed with synthetic values). + GhostSeal.capabilities() + } else { + // Configured, but the real CSC flow isn't implemented — seal() and + // verify() both return errors. Report no capability rather than + // advertising JAdES sealing the adapter cannot deliver, so a caller + // that checks capabilities() first isn't contradicted by seal(). + SealCapabilities { + supported_formats: Vec::new(), + supported_modes: Vec::new(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unconfigured_reports_ghost_capabilities() { + // The placeholder path genuinely produces (synthetic) seals. + let caps = QtspSealAdapter::new(None).capabilities(); + assert!(!caps.supported_formats.is_empty()); + } + + #[test] + fn configured_but_unimplemented_reports_no_capability() { + // capabilities() must not contradict seal()/verify(), which error here. + let caps = QtspSealAdapter::new(Some("https://qtsp.example".into())).capabilities(); + assert!( + caps.supported_formats.is_empty(), + "must not advertise sealing it cannot deliver" + ); + assert!(caps.supported_modes.is_empty()); } } diff --git a/crates/dpp-types/Cargo.toml b/crates/dpp-types/Cargo.toml index 92dc579..74d3548 100644 --- a/crates/dpp-types/Cargo.toml +++ b/crates/dpp-types/Cargo.toml @@ -13,11 +13,13 @@ name = "dpp_types" path = "src/lib.rs" [dependencies] -dpp-domain = { workspace = true } -dpp-evidence = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -async-trait = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } +dpp-domain = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_jcs = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } diff --git a/crates/dpp-types/src/api_key.rs b/crates/dpp-types/src/api_key.rs index 9501d25..9879949 100644 --- a/crates/dpp-types/src/api_key.rs +++ b/crates/dpp-types/src/api_key.rs @@ -17,8 +17,11 @@ use uuid::Uuid; /// NOT key management or operator-config mutation. /// - `Admin` — everything, including `/api-keys` and operator-config `PATCH`. /// -/// The default is `Admin` for backward compatibility (pre-scope keys and the -/// bootstrap key remain fully capable). Issue `Write`/`Read` keys to integrations. +/// The `#[default]` is `Admin`: creating a key without specifying a scope (the +/// bootstrap key, or an omitted `scope` in a create request) is fully capable. +/// A NULL/empty `scopes` *column*, by contrast, fails closed to `Read` (see +/// [`ApiKeyScope::from_scopes`]) — only an explicit `admin` grants admin. +/// Issue `Write`/`Read` keys to integrations. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ApiKeyScope { @@ -54,20 +57,20 @@ impl ApiKeyScope { /// Collapse the DB `scopes TEXT[]` array into the effective (highest) scope. /// - /// A NULL/empty array — i.e. a key written before scopes were enforced — - /// maps to `Admin`, preserving the pre-scope "full access" behaviour. The - /// service only ever writes a single-element array, but reading the highest - /// privilege present keeps this robust to manual edits. + /// **Fails closed:** an empty/NULL array or any unrecognized scope string + /// maps to the least-privilege `Read`, never `Admin`. The service always + /// writes an explicit single-element scope, so a NULL/empty column only + /// arises from a legacy pre-scope row or a manual edit — neither of which + /// should silently grant admin. Reading the highest recognized privilege + /// present keeps this robust to manual multi-element edits. #[must_use] pub fn from_scopes(scopes: &[String]) -> Self { - if scopes.is_empty() || scopes.iter().any(|s| s == "admin") { + if scopes.iter().any(|s| s == "admin") { ApiKeyScope::Admin } else if scopes.iter().any(|s| s == "write") { ApiKeyScope::Write - } else if scopes.iter().any(|s| s == "read") { - ApiKeyScope::Read } else { - ApiKeyScope::Admin + ApiKeyScope::Read } } } @@ -154,3 +157,51 @@ pub trait ApiKeyRepository: Send + Sync { /// Revoke a key by id. Returns `true` if the key existed and was revoked. async fn revoke(&self, id: Uuid) -> Result; } + +#[cfg(test)] +mod tests { + use super::*; + + fn scopes(vals: &[&str]) -> Vec { + vals.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn from_scopes_fails_closed_to_read() { + // Empty/NULL, unrecognized, and wrong-case values must all fail closed. + assert_eq!(ApiKeyScope::from_scopes(&[]), ApiKeyScope::Read); + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["superuser"])), + ApiKeyScope::Read + ); + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["ADMIN"])), // not the literal "admin" + ApiKeyScope::Read + ); + } + + #[test] + fn from_scopes_recognizes_known_values_highest_wins() { + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["read"])), + ApiKeyScope::Read + ); + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["write"])), + ApiKeyScope::Write + ); + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["admin"])), + ApiKeyScope::Admin + ); + // Highest recognized privilege present wins. + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["read", "admin"])), + ApiKeyScope::Admin + ); + assert_eq!( + ApiKeyScope::from_scopes(&scopes(&["read", "write"])), + ApiKeyScope::Write + ); + } +} diff --git a/crates/dpp-types/src/audit.rs b/crates/dpp-types/src/audit.rs index f283297..dfb8b9e 100644 --- a/crates/dpp-types/src/audit.rs +++ b/crates/dpp-types/src/audit.rs @@ -1,19 +1,162 @@ -//! Audit trail persistence port. -//! -//! The wire type and hash-chain algorithm were promoted to `dpp-core`'s -//! `dpp-evidence` crate (2026-07-08) — the shape is third-party-verifiable, -//! which makes it part of the proof-bound standard rather than engine -//! plumbing (this module's doc used to flag exactly this as a -//! "core-candidate"). This module now owns only what's legitimately -//! engine-side: persistence. +//! Audit trail wire type, hash-chain verification, and persistence port. //! //! Entries are append-only; the DB trigger raises `ODAL_AUDIT` on any UPDATE //! or DELETE attempt, making the trail tamper-evident at the DB layer. use async_trait::async_trait; +use chrono::{DateTime, Utc}; use dpp_domain::DppError; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// The `prev_hash` of the first (genesis) entry in a passport's chain. +pub const GENESIS_PREV_HASH: &str = ""; + +/// A single immutable audit record for a passport state change. +/// +/// `#[serde(deny_unknown_fields)]` — an unknown field here must fail loudly, +/// not silently vanish from an otherwise-valid content hash. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditEntry { + /// Unique identifier for this audit record. + pub id: Uuid, + /// The passport this entry is for (stringified UUID for forward-compat). + pub passport_id: String, + /// Who triggered this change, stamped from `AuthContext` at the call site. + pub actor: String, + /// Machine-readable action code, e.g. `"create"`, `"publish"`, `"archive"`. + pub action: String, + /// Passport status before the transition, if applicable. + pub previous_status: Option, + /// Passport status after the transition, if applicable. + pub new_status: Option, + /// Optional structured metadata (e.g. field diffs, a stamped EOL event, + /// or — for a `"published"` entry — the exact payloads that were signed; + /// see `dpp-vault`'s `publish.rs`/`evidence.rs`). + pub metadata: Option, + /// Wall-clock timestamp of the operation (UUIDv7 source; sub-millisecond ordered). + pub timestamp: DateTime, + /// Hash-chain link to the previous entry in this passport's chain. + /// `""`/`None` for the genesis entry. Set by the storage layer on append. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prev_hash: Option, + /// SHA-256 (hex) over the JCS-canonicalised content of this entry folded + /// with `prev_hash` — the chain link the next entry points back to. Set + /// by the storage layer on append; `None` on an in-memory entry not yet + /// persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_hash: Option, +} + +impl AuditEntry { + /// Construct an audit entry from an action and its actor. + /// + /// `id` is a new UUIDv7 so entries are time-ordered within a passport. + /// Takes a plain actor string — `AuthContext`-aware call sites pass + /// `&auth.user_id` (single-tenant: no operator scope to include, + /// DECISION-0002). + pub fn new( + passport_id: &str, + action: &str, + actor: impl Into, + previous_status: Option<&str>, + new_status: Option<&str>, + ) -> Self { + Self { + id: Uuid::now_v7(), + passport_id: passport_id.to_owned(), + actor: actor.into(), + action: action.to_owned(), + previous_status: previous_status.map(|s| s.to_owned()), + new_status: new_status.map(|s| s.to_owned()), + metadata: None, + timestamp: Utc::now(), + prev_hash: None, + entry_hash: None, + } + } + + /// Attach structured metadata to this entry (builder-style). + pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } -pub use dpp_evidence::audit::{AuditChainBreak, AuditEntry, GENESIS_PREV_HASH, verify_audit_chain}; + /// The chain hash for this entry given its predecessor's hash: SHA-256 (hex) + /// over the JCS-canonicalised content **and** `prev_hash`. Excludes the + /// `prev_hash`/`entry_hash` columns themselves (prev is folded in as + /// `prevHash`). Deterministic — the same content + prev always hashes equal. + #[must_use] + pub fn chain_hash(&self, prev_hash: &str) -> String { + let canonical = serde_json::json!({ + "id": self.id, + "passportId": self.passport_id, + "actor": self.actor, + "action": self.action, + "previousStatus": self.previous_status, + "newStatus": self.new_status, + "metadata": self.metadata, + "timestamp": self.timestamp, + "prevHash": prev_hash, + }); + let bytes = serde_jcs::to_vec(&canonical) + .expect("JCS canonicalisation of audit content is infallible"); + hex::encode(Sha256::digest(&bytes)) + } +} + +/// The first broken link found while verifying a passport's audit chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditChainBreak { + /// 0-based position of the offending entry in the ordered chain. + pub index: usize, + /// The passport whose chain broke. + pub passport_id: String, + /// Human-readable reason (prev-link mismatch vs. content tamper). + pub reason: String, +} + +/// Verify a passport's audit entries form an intact hash chain. +/// +/// `entries` must be in chain order (ascending timestamp). Returns the first +/// break: either a `prev_hash` that doesn't point at the prior entry, or an +/// `entry_hash` that doesn't match the entry's recomputed content hash (a +/// tampered row). `Ok(())` means every link verifies. +/// +/// This detects any tamper that does not re-hash the *entire forward chain*; +/// pinning the head with a signed checkpoint is what makes a full re-hash +/// detectable by a third party without access to the issuing node. +/// +/// # Errors +/// [`AuditChainBreak`] at the first inconsistent entry. +pub fn verify_audit_chain(entries: &[AuditEntry]) -> Result<(), AuditChainBreak> { + let mut expected_prev = GENESIS_PREV_HASH.to_owned(); + for (index, entry) in entries.iter().enumerate() { + let stored_prev = entry.prev_hash.as_deref().unwrap_or(GENESIS_PREV_HASH); + if stored_prev != expected_prev { + return Err(AuditChainBreak { + index, + passport_id: entry.passport_id.clone(), + reason: format!( + "prev_hash link broken: stored {stored_prev:?}, expected {expected_prev:?}" + ), + }); + } + let recomputed = entry.chain_hash(&expected_prev); + let stored_hash = entry.entry_hash.as_deref().unwrap_or(""); + if stored_hash != recomputed { + return Err(AuditChainBreak { + index, + passport_id: entry.passport_id.clone(), + reason: "entry_hash mismatch — content tampered".to_owned(), + }); + } + expected_prev = recomputed; + } + Ok(()) +} /// Port trait for audit trail persistence. #[async_trait] @@ -23,3 +166,84 @@ pub trait AuditRepository: Send + Sync { /// Retrieve the full audit trail for a passport, ordered by timestamp ascending. async fn list_by_passport(&self, passport_id: &str) -> Result, DppError>; } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use uuid::Uuid; + + fn entry(action: &str) -> AuditEntry { + AuditEntry { + id: Uuid::now_v7(), + passport_id: "p1".into(), + actor: "actor".into(), + action: action.into(), + previous_status: None, + new_status: None, + metadata: None, + timestamp: Utc::now(), + prev_hash: None, + entry_hash: None, + } + } + + /// Link a slice into a chain exactly as the storage layer does on append. + fn chain(entries: &mut [AuditEntry]) { + let mut prev = GENESIS_PREV_HASH.to_owned(); + for e in entries.iter_mut() { + let h = e.chain_hash(&prev); + e.prev_hash = Some(prev.clone()); + e.entry_hash = Some(h.clone()); + prev = h; + } + } + + #[test] + fn chain_hash_is_deterministic_and_prev_sensitive() { + let e = entry("created"); + assert_eq!(e.chain_hash(""), e.chain_hash("")); + assert_ne!(e.chain_hash(""), e.chain_hash("deadbeef")); + } + + #[test] + fn new_builds_from_a_plain_actor_string() { + let e = AuditEntry::new("p1", "created", "user-123", None, Some("draft")); + assert_eq!(e.actor, "user-123"); + assert_eq!(e.action, "created"); + assert_eq!(e.new_status.as_deref(), Some("draft")); + } + + #[test] + fn intact_chain_verifies() { + let mut es = [entry("created"), entry("published"), entry("suspended")]; + chain(&mut es); + assert!(verify_audit_chain(&es).is_ok()); + } + + #[test] + fn tampered_content_breaks_at_exact_index() { + let mut es = [entry("created"), entry("published"), entry("archived")]; + chain(&mut es); + es[1].new_status = Some("suspended".into()); // flip content, keep stored hash + let brk = verify_audit_chain(&es).expect_err("tamper must be detected"); + assert_eq!(brk.index, 1); + assert!(brk.reason.contains("tampered")); + } + + #[test] + fn broken_prev_link_detected() { + let mut es = [entry("created"), entry("published")]; + chain(&mut es); + es[1].prev_hash = Some("0000".into()); + assert_eq!(verify_audit_chain(&es).expect_err("break").index, 1); + } + + #[test] + fn unknown_field_is_rejected_at_deserialize() { + let mut value = serde_json::to_value(entry("created")).unwrap(); + value["notARealField"] = serde_json::json!("sneaky"); + let result: Result = serde_json::from_value(value); + assert!(result.is_err(), "unknown field must fail to deserialize"); + } +} diff --git a/crates/dpp-types/src/evidence.rs b/crates/dpp-types/src/evidence.rs new file mode 100644 index 0000000..17278fc --- /dev/null +++ b/crates/dpp-types/src/evidence.rs @@ -0,0 +1,243 @@ +//! Evidence dossier wire format, verification report, and persistence port. +//! +//! A dossier ([`DossierV1`]) is a self-contained, signed snapshot of a +//! passport's full proof chain — JWS signatures, hash-chained audit trail, +//! transfer-chain signatures. The node generates and persists dossiers +//! (`POST /api/v1/dpp/{id}/evidence`) and verifies stored or uploaded ones +//! (`dpp-vault`'s `domain::verify`); this module owns only the data shapes +//! those flows share. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use dpp_domain::{ + DppError, + domain::{passport::PassportId, transfer::TransferChain}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::audit::AuditEntry; + +/// A JWS alongside the exact JSON payload it was signed over. +/// +/// The signer applies transforms before signing (e.g. the full-view payload +/// forces `status` to `"active"`; the public-view payload is a redacted +/// projection). Rather than have the verifier reimplement those transforms +/// to reconstruct what *should* have been signed, the dossier assembler — +/// which already has that exact value in hand — embeds it directly. +/// Verification then only has to confirm the signature covers *this* +/// payload, not derive the payload itself. +/// +/// `deny_unknown_fields`: an unrecognised member here fails deserialization +/// (exit 2 / malformed) rather than being silently dropped and still +/// verifying green. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SignedLayer { + pub payload: serde_json::Value, + pub jws: String, +} + +/// Signed description of a dossier — the manifest JWS payload. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DossierManifest { + /// Dossier wire format version, `"1"`. + pub format_version: String, + pub passport_id: String, + /// The DID that signed the manifest, `full_view`, and `public_view` — + /// the node operator's own identity. Transfer-chain signatures carry + /// their own signer DIDs on each record instead. + pub issuer_did: String, + pub created_at: DateTime, + pub node_version: String, + /// The `dpp-calc` ruleset version, when a determination ran. `None` for + /// passthrough-only passports. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ruleset_version: Option, + /// member name -> hex SHA-256 over the JCS-canonicalised member content. + /// Binds every dossier member into one atomic, tamper-evident unit — an + /// attacker cannot swap in a genuinely-signed-but-stale member (e.g. an + /// older audit trail that omits a later suspend event) without the + /// manifest's own signature catching the mismatch. + pub content_hashes: BTreeMap, +} + +/// A complete evidence dossier: a self-contained, signed snapshot of a +/// passport's full proof chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DossierV1 { + pub manifest: DossierManifest, + pub manifest_jws: String, + pub full_view: SignedLayer, + pub public_view: SignedLayer, + /// DID document snapshots, keyed by DID. Always contains at least + /// `manifest.issuer_did`; may contain other operators' DIDs when a + /// transfer chain is present. + pub did_documents: BTreeMap, + /// Ordered ascending by timestamp (chain order). + pub audit_entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transfer_chain: Option, + /// Present iff the passport was deactivated (End-of-Life declared). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eol_event: Option, + /// Always `None` in v1 — the signed-checkpoint layer is not yet built. + /// Present as a field (not omitted) so the format doesn't need a + /// breaking version bump when checkpoints ship. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, + /// Always empty in v1 — `dpp-calc` invocation is not yet wired end to + /// end (see the roadmap note on licensed factor data). Present as a + /// field for the same forward-compatibility reason as `checkpoint`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub calc_receipts: Vec, +} + +/// Canonical SHA-256 (hex) of a JSON value (RFC 8785 / JCS bytes). +/// +/// Exposed so the assembler builds the exact same hash a verifier will +/// later recompute and check against — one hash function, two call sites, +/// mirroring `dpp-rules::bundle::verify::content_hash`. +#[must_use] +pub fn content_hash(value: &serde_json::Value) -> String { + let bytes = serde_jcs::to_vec(value).expect("JCS canonicalisation is infallible"); + hex::encode(Sha256::digest(&bytes)) +} + +/// Compute the `content_hashes` map for a dossier's members, in the shape +/// [`DossierManifest::content_hashes`] expects. Both the assembler (to +/// build the manifest before signing) and the verifier (to recompute and +/// compare) call this on the same dossier shape, so the two can never drift. +#[must_use] +pub fn compute_content_hashes(dossier: &DossierV1) -> BTreeMap { + let mut hashes = BTreeMap::new(); + hashes.insert( + "fullView".to_string(), + content_hash(&dossier.full_view.payload), + ); + hashes.insert( + "publicView".to_string(), + content_hash(&dossier.public_view.payload), + ); + hashes.insert( + "auditEntries".to_string(), + content_hash( + &serde_json::to_value(&dossier.audit_entries).expect("audit entries serialise"), + ), + ); + if let Some(chain) = &dossier.transfer_chain { + hashes.insert( + "transferChain".to_string(), + content_hash(&serde_json::to_value(chain).expect("TransferChain serialises")), + ); + } + if let Some(eol) = &dossier.eol_event { + hashes.insert("eolEvent".to_string(), content_hash(eol)); + } + hashes +} + +/// Outcome of a single named check. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", content = "detail", rename_all = "camelCase")] +pub enum CheckStatus { + Pass, + Fail(String), + /// Not a failure — the layer is legitimately absent in v1 (checkpoint, + /// calc receipts) or not applicable to this passport (no transfer chain). + Absent(String), +} + +impl CheckStatus { + #[must_use] + pub fn is_failure(&self) -> bool { + matches!(self, CheckStatus::Fail(_)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckResult { + pub name: String, + #[serde(flatten)] + pub status: CheckStatus, +} + +impl CheckResult { + #[must_use] + fn is_failure(&self) -> bool { + self.status.is_failure() + } +} + +/// The full result of verifying a dossier. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerificationReport { + pub trust_anchor_note: String, + pub checks: Vec, +} + +impl VerificationReport { + /// `true` iff every check passed (informational `Absent` checks don't + /// count against this). + #[must_use] + pub fn all_verified(&self) -> bool { + !self.checks.iter().any(CheckResult::is_failure) + } + + /// Exit-code convention: `0` verified, `1` any tamper, `2` never + /// returned from here — malformed/incomplete input (including an + /// unrecognised field) is a hard parse error before a report can even be + /// built; see `dpp-vault`'s `verify_dossier_json`. + #[must_use] + pub fn exit_code(&self) -> i32 { + if self.all_verified() { 0 } else { 1 } + } +} + +/// A stored dossier snapshot: the dossier plus its persistence envelope. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceDossierRecord { + /// UUIDv7, minted by the service at generation time. + pub id: Uuid, + pub passport_id: PassportId, + /// Who requested generation, stamped from `AuthContext`. + pub actor: String, + pub created_at: DateTime, + /// Hex SHA-256 of the JCS-canonicalised stored dossier document. + pub doc_hash: String, + pub dossier: DossierV1, +} + +/// Listing projection of a stored dossier — everything but the document. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceDossierSummary { + pub id: Uuid, + pub passport_id: PassportId, + pub actor: String, + pub created_at: DateTime, + pub doc_hash: String, +} + +/// Port trait for evidence dossier persistence. +#[async_trait] +pub trait EvidenceDossierRepository: Send + Sync { + /// Persist a generated dossier snapshot. Append-only: the DB trigger + /// blocks any UPDATE or DELETE. + async fn insert(&self, record: &EvidenceDossierRecord) -> Result<(), DppError>; + /// Summaries for a passport, newest first. + async fn list_by_passport( + &self, + passport_id: PassportId, + ) -> Result, DppError>; + /// One stored dossier by id. + async fn get(&self, id: Uuid) -> Result, DppError>; +} diff --git a/crates/dpp-types/src/lib.rs b/crates/dpp-types/src/lib.rs index 44748fb..c0527bd 100644 --- a/crates/dpp-types/src/lib.rs +++ b/crates/dpp-types/src/lib.rs @@ -14,12 +14,12 @@ //! a chain-per-passport store). They live engine-side deliberately: the //! standard defines the *records*, not how a given deployment queues or //! stores them. See the doc comment on each port for the specific reasoning. -//! 3. **Standard-adjacent provenance** — `audit`: `AuditEntry` and its hash -//! chain were promoted to `dpp-core`'s `dpp-evidence` crate (2026-07-08), -//! since they're verified by third parties and are part of the -//! proof-bound standard rather than engine plumbing. This module -//! re-exports the type and keeps only the persistence port -//! (`AuditRepository`) — storage is still an engine deployment choice. +//! 3. **Provenance wire types** — `audit` and `evidence`: the audit-trail +//! entry with its hash chain, and the evidence-dossier format with its +//! verification report. These are defined here — they are the engine's +//! own proof surface — alongside their persistence ports +//! (`AuditRepository`, `EvidenceDossierRepository`); storage is an +//! engine deployment choice. //! //! New types should fit one of these three; if a fit isn't obvious, that's a //! sign the taxonomy needs revisiting rather than a place to force it. @@ -27,6 +27,7 @@ pub mod api_key; pub mod audit; pub mod auth; +pub mod evidence; pub mod operator; pub mod registry_identity; pub mod registry_sync; @@ -38,6 +39,11 @@ pub use audit::{ AuditChainBreak, AuditEntry, AuditRepository, GENESIS_PREV_HASH, verify_audit_chain, }; pub use auth::{AuthContext, AuthError, AuthProvider}; +pub use evidence::{ + CheckResult, CheckStatus, DossierManifest, DossierV1, EvidenceDossierRecord, + EvidenceDossierRepository, EvidenceDossierSummary, SignedLayer, VerificationReport, + compute_content_hashes, content_hash, +}; pub use operator::{ OperatorConfig, OperatorConfigRepository, STANDALONE_OPERATOR_ID, UpdateOperatorConfig, }; diff --git a/crates/dpp-types/src/operator.rs b/crates/dpp-types/src/operator.rs index 39f3dd8..a3ecd30 100644 --- a/crates/dpp-types/src/operator.rs +++ b/crates/dpp-types/src/operator.rs @@ -65,8 +65,12 @@ fn default_data_residency() -> String { "EU".to_owned() } +/// ESPR-driven minimum data-retention floor (days, ~10 years). A configured +/// retention shorter than this would violate the minimum-retention guarantee. +pub const MIN_RETENTION_DAYS: i64 = 3650; + fn default_retention_days() -> i64 { - 3650 + MIN_RETENTION_DAYS } impl OperatorConfig { @@ -146,6 +150,24 @@ pub struct UpdateOperatorConfig { } impl UpdateOperatorConfig { + /// Validate the patch's invariants before it is applied. + /// + /// # Errors + /// Returns a message if `retention_policy_days` is present and below + /// [`MIN_RETENTION_DAYS`] (the ESPR minimum-retention floor) — the config + /// must never silently drop below the documented minimum-retention guarantee. + pub fn validate(&self) -> Result<(), String> { + if let Some(days) = self.retention_policy_days + && days < MIN_RETENTION_DAYS + { + return Err(format!( + "retentionPolicyDays must be at least {MIN_RETENTION_DAYS} \ + (ESPR minimum retention); got {days}" + )); + } + Ok(()) + } + /// Apply all `Some` fields from `self` onto `cfg` in-place. pub fn apply(&self, cfg: &mut OperatorConfig) { if let Some(ref v) = self.legal_name { @@ -261,4 +283,45 @@ mod tests { assert!(!cfg.is_complete()); assert_eq!(cfg.missing_fields(), vec!["address"]); } + + fn patch_with_retention(days: Option) -> UpdateOperatorConfig { + UpdateOperatorConfig { + legal_name: None, + trade_name: None, + address: None, + country: None, + contact_email: None, + did_web_url: None, + product_categories: None, + brand_primary: None, + brand_secondary: None, + brand_logo_url: None, + custom_domain: None, + data_residency: None, + retention_policy_days: days, + feature_flags: None, + } + } + + #[test] + fn validate_rejects_retention_below_minimum() { + assert!(patch_with_retention(Some(-1)).validate().is_err()); + assert!(patch_with_retention(Some(0)).validate().is_err()); + assert!( + patch_with_retention(Some(MIN_RETENTION_DAYS - 1)) + .validate() + .is_err() + ); + } + + #[test] + fn validate_accepts_retention_at_or_above_minimum_and_absent() { + assert!( + patch_with_retention(Some(MIN_RETENTION_DAYS)) + .validate() + .is_ok() + ); + assert!(patch_with_retention(Some(5475)).validate().is_ok()); // ~15y + assert!(patch_with_retention(None).validate().is_ok()); + } } diff --git a/crates/dpp-vault/Cargo.toml b/crates/dpp-vault/Cargo.toml index 8054abc..79ca3bf 100644 --- a/crates/dpp-vault/Cargo.toml +++ b/crates/dpp-vault/Cargo.toml @@ -24,7 +24,6 @@ dpp-registry = { workspace = true } dpp-types = { workspace = true } dpp-dal = { path = "../dpp-dal" } dpp-common = { path = "../dpp-common" } -dpp-evidence = { workspace = true } axum = { workspace = true } tokio = { workspace = true } @@ -58,3 +57,4 @@ testcontainers = { workspace = true } testcontainers-modules = { workspace = true } tokio = { workspace = true } flate2 = { workspace = true } +ed25519-dalek = { workspace = true } diff --git a/crates/dpp-vault/src/domain/mod.rs b/crates/dpp-vault/src/domain/mod.rs index 429e009..c5bd4e6 100644 --- a/crates/dpp-vault/src/domain/mod.rs +++ b/crates/dpp-vault/src/domain/mod.rs @@ -4,3 +4,4 @@ pub mod api_key_service; pub mod operator_service; pub mod registry_identity_service; pub mod service; +pub mod verify; diff --git a/crates/dpp-vault/src/domain/service/create.rs b/crates/dpp-vault/src/domain/service/create.rs index 39d8126..c7d99ac 100644 --- a/crates/dpp-vault/src/domain/service/create.rs +++ b/crates/dpp-vault/src/domain/service/create.rs @@ -1,6 +1,7 @@ //! `create` and `update` — draft-passport writes, plus their private helpers -//! `apply_patch` (validates and applies an update patch) and `apply_compliance` -//! (backfills compliance-derived fields from the registered `ComplianceRegistry`). +//! `apply_patch` (validates and applies an update patch), `apply_compliance` +//! (backfills compliance-derived fields from the registered `ComplianceRegistry`), +//! and `apply_lint` (refreshes the non-binding plausibility findings). use chrono::Utc; use dpp_common::event; @@ -60,6 +61,7 @@ impl PassportService { } apply_compliance(&mut passport, &*self.compliance); + apply_lint(&mut passport); let created = self.repo.create(passport).await?; @@ -112,6 +114,7 @@ impl PassportService { let pre_compliance_co2e = passport.co2e_per_unit.clone(); let pre_compliance_repair = passport.repairability_score.clone(); apply_compliance(&mut passport, &*self.compliance); + apply_lint(&mut passport); // Start delta from the patch body (already camelCase DB field names). let mut delta = patch; @@ -127,6 +130,12 @@ impl PassportService { { m.insert("repairabilityScore".into(), serde_json::json!(v)); } + // Lint findings are cheap to recompute and always refreshed (unlike + // co2e/repairability above, which only backfill when the caller left + // them unset) — see PassportService::relint for the standalone re-check. + if let Some(ref lint) = passport.lint_result { + m.insert("lintResult".into(), serde_json::json!(lint)); + } } let updated = self.repo.patch_fields(id, delta).await?; @@ -179,6 +188,16 @@ fn apply_compliance(passport: &mut Passport, registry: &dyn ComplianceRegistry) } } +/// Backfill `lint_result` from the `dpp-rules` plausibility lint pack. +/// Unlike `apply_compliance`, always overwrites — the pack is cheap to +/// re-run and freshness (not preserving a caller-supplied value) is the +/// point. A no-op when the passport carries no sector data. +fn apply_lint(passport: &mut Passport) { + if let Some(sector_data) = passport.sector_data.as_ref() { + passport.lint_result = Some(dpp_domain::LintResult::compute(sector_data)); + } +} + fn apply_patch(passport: &mut Passport, patch: &serde_json::Value) -> Result<(), DppError> { let obj = match patch.as_object() { Some(o) => o, @@ -240,6 +259,7 @@ mod tests { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, diff --git a/crates/dpp-vault/src/domain/service/evidence.rs b/crates/dpp-vault/src/domain/service/evidence.rs index cdff8bf..162d093 100644 --- a/crates/dpp-vault/src/domain/service/evidence.rs +++ b/crates/dpp-vault/src/domain/service/evidence.rs @@ -1,24 +1,87 @@ -//! `export_evidence` — assemble a self-contained, signed evidence dossier -//! (N02) for fully offline verification. See `dpp_evidence::dossier` for the -//! wire format, which this crate depends on `dpp-evidence` (Apache-2.0 core) -//! purely to reuse — one definition, two producers/consumers. The audit -//! trail's own wire type (`dpp_types::audit::AuditEntry`) is itself a -//! re-export from `dpp-evidence`, so `list_by_passport`'s result is already -//! the exact type the dossier wants — no mapping step needed. +//! Evidence dossiers: assemble, generate + persist, list, fetch, and verify +//! a passport's self-contained signed proof snapshot. See `dpp_types::evidence` +//! for the wire format. The audit trail's own wire type +//! (`dpp_types::audit::AuditEntry`) is already the exact type the dossier +//! wants — no mapping step needed. use std::collections::BTreeMap; use dpp_domain::domain::{error::DppError, passport::PassportId, status::PassportStatus}; -use dpp_evidence::{DossierManifest, DossierV1, SignedLayer, compute_content_hashes}; +use dpp_types::evidence::{ + DossierManifest, DossierV1, EvidenceDossierRecord, EvidenceDossierSummary, SignedLayer, + VerificationReport, compute_content_hashes, content_hash, +}; +use uuid::Uuid; use super::PassportService; impl PassportService { + /// Generate an evidence dossier for a passport and persist it. Returns + /// the stored record (id, actor, doc hash, and the dossier itself). + #[tracing::instrument(skip(self, auth), fields(passport_id = %id))] + pub async fn generate_evidence( + &self, + id: PassportId, + auth: &dpp_types::auth::AuthContext, + ) -> Result { + let dossier = self.assemble_dossier(id).await?; + let store = self.evidence_store.as_ref().ok_or_else(|| { + DppError::Internal("evidence store not configured on this service".into()) + })?; + let doc_hash = content_hash( + &serde_json::to_value(&dossier).map_err(|e| DppError::Serialisation(e.to_string()))?, + ); + let record = EvidenceDossierRecord { + id: Uuid::now_v7(), + passport_id: id, + actor: auth.user_id.clone(), + created_at: dossier.manifest.created_at, + doc_hash, + dossier, + }; + store.insert(&record).await?; + Ok(record) + } + + /// List stored dossier summaries for a passport, newest first. + /// 404s if the passport itself doesn't exist. + pub async fn list_evidence( + &self, + id: PassportId, + ) -> Result, DppError> { + self.find_by_id(id).await?; + let store = self.evidence_store.as_ref().ok_or_else(|| { + DppError::Internal("evidence store not configured on this service".into()) + })?; + store.list_by_passport(id).await + } + + /// Fetch one stored dossier by id. + pub async fn get_evidence(&self, dossier_id: Uuid) -> Result { + let store = self.evidence_store.as_ref().ok_or_else(|| { + DppError::Internal("evidence store not configured on this service".into()) + })?; + store + .get(dossier_id) + .await? + .ok_or_else(|| DppError::NotFound(dossier_id.to_string())) + } + + /// Verify a stored dossier's signatures and hash chains. + pub async fn verify_evidence(&self, dossier_id: Uuid) -> Result { + let record = self.get_evidence(dossier_id).await?; + let bytes = serde_json::to_vec(&record.dossier) + .map_err(|e| DppError::Serialisation(e.to_string()))?; + crate::domain::verify::verify_dossier_json(&bytes).map_err(|e| { + DppError::Internal(format!("stored dossier {dossier_id} failed to parse: {e}")) + }) + } + /// Assemble the evidence dossier for a passport. Requires the passport to /// have been published at least once (a `Draft` has no signature to /// export yet). #[tracing::instrument(skip(self), fields(passport_id = %id))] - pub async fn export_evidence(&self, id: PassportId) -> Result { + async fn assemble_dossier(&self, id: PassportId) -> Result { let passport = self.find_by_id(id).await?; if matches!(passport.status, PassportStatus::Draft) { return Err(DppError::Validation( @@ -85,8 +148,8 @@ impl PassportService { // Best-effort: fetch each transfer counterparty's DID document over // HTTPS. An unresolvable remote DID is simply left out of the map — - // the offline verifier then reports that signature as unverifiable - // rather than silently skipping it (fail-closed, never false-green). + // the verifier then reports that signature as unverifiable rather + // than silently skipping it (fail-closed, never false-green). if let Some(chain) = &transfer_chain { for record in &chain.transfers { for did in [&record.from_operator.did, &record.to_operator.did] { @@ -153,7 +216,7 @@ impl PassportService { } async fn fetch_remote_did_document(did: &str) -> Result { - let url = dpp_evidence::did_web_url(did).map_err(DppError::Internal)?; + let url = crate::domain::verify::did_web_url(did).map_err(DppError::Internal)?; let resp = reqwest::get(&url) .await .map_err(|e| DppError::Internal(format!("{url}: {e}")))?; diff --git a/crates/dpp-vault/src/domain/service/lint.rs b/crates/dpp-vault/src/domain/service/lint.rs new file mode 100644 index 0000000..31ba38f --- /dev/null +++ b/crates/dpp-vault/src/domain/service/lint.rs @@ -0,0 +1,43 @@ +//! `relint` — on-demand plausibility-lint re-check (`POST /dpp/{dppId}/lint`). + +use dpp_domain::domain::{ + error::DppError, + passport::{Passport, PassportId}, +}; + +use super::PassportService; + +impl PassportService { + /// Re-run the plausibility lint pack against a passport's current sector + /// data and persist the refreshed result. A no-op (returns the passport + /// unchanged) when it carries no sector data. + /// + /// Unlike every other mutating method in this module, this does **not** + /// append an audit entry or emit an event: lint findings are advisory + /// only (never gate publish, never change status or the compliance + /// determination), so a re-check reads closer to a recompute-on-read + /// than a state transition worth auditing. + /// + /// Works regardless of passport status. A re-check on an already-published + /// passport does not retroactively affect its `jws_signature` — that JWS + /// is a frozen signature over whatever `lint_result` looked like at + /// publish time (see `publish`'s audit-entry snapshot, which evidence + /// export reads instead of the live row). + /// + /// # Errors + /// + /// Returns `DppError::NotFound` if the id is unknown. + #[tracing::instrument(skip(self), fields(passport_id = %id))] + pub async fn relint(&self, id: PassportId) -> Result { + let passport = self.find_by_id(id).await?; + + let Some(sector_data) = passport.sector_data.as_ref() else { + return Ok(passport); + }; + let lint_result = dpp_domain::LintResult::compute(sector_data); + + self.repo + .patch_fields(id, serde_json::json!({ "lintResult": lint_result })) + .await + } +} diff --git a/crates/dpp-vault/src/domain/service/mod.rs b/crates/dpp-vault/src/domain/service/mod.rs index a087f2d..cbf7839 100644 --- a/crates/dpp-vault/src/domain/service/mod.rs +++ b/crates/dpp-vault/src/domain/service/mod.rs @@ -13,7 +13,7 @@ //! - [`lifecycle`] — `suspend`, `archive` //! - [`eol`] — `declare_eol` //! - [`transfer`] — `initiate_transfer`, `accept_transfer` -//! - [`evidence`] — `export_evidence` (N02 offline-verifiable dossier) +//! - [`evidence`] — `generate_evidence`/`list_evidence`/`get_evidence`/`verify_evidence` //! - [`seal`] — reserved seat for the eIDAS seal step in `publish` (not wired yet) mod create; @@ -33,8 +33,8 @@ use dpp_domain::ports::{ passport_repo::PassportRepository, registry_sync::RegistrySyncPort, }; use dpp_types::{ - STANDALONE_OPERATOR_ID, audit::AuditRepository, operator::OperatorConfigRepository, - registry_sync::RegistrySyncOutbox, transfer::TransferStore, + STANDALONE_OPERATOR_ID, audit::AuditRepository, evidence::EvidenceDossierRepository, + operator::OperatorConfigRepository, registry_sync::RegistrySyncOutbox, transfer::TransferStore, }; /// Core domain service for the passport lifecycle. @@ -60,6 +60,9 @@ pub struct PassportService { /// Persistence for transfer-of-responsibility chains. `None` disables /// the transfer endpoints (test doubles without a transfer store). pub transfer_store: Option>, + /// Persistence for generated evidence dossiers. `None` disables the + /// evidence-generation endpoint (test doubles without an evidence store). + pub evidence_store: Option>, /// ISO 3166-1 alpha-2 country code of this operator, sourced from /// `OperatorConfig.country` at startup. Used in EU registry registration payloads. pub operator_country: String, @@ -93,6 +96,7 @@ impl PassportService { archive, registry_outbox: None, transfer_store: None, + evidence_store: None, operator_country, registry_reader: None, } @@ -106,6 +110,13 @@ impl PassportService { self } + /// Provide the evidence-dossier store, enabling dossier generation. + #[must_use] + pub fn with_evidence_store(mut self, store: Arc) -> Self { + self.evidence_store = Some(store); + self + } + /// Provide the transactional registry-sync outbox. When set, publish routes /// the passport write + registration enqueue through a single transaction /// (`commit_publish`) and the inline fire-after-commit register call is diff --git a/crates/dpp-vault/src/domain/service/publish.rs b/crates/dpp-vault/src/domain/service/publish.rs index eb5b5e3..6507e67 100644 --- a/crates/dpp-vault/src/domain/service/publish.rs +++ b/crates/dpp-vault/src/domain/service/publish.rs @@ -247,11 +247,11 @@ impl PassportService { // metadata on this publish's audit entry. `jws_signature` and // `public_jws_signature` are frozen at this moment and never re-signed // by later lifecycle transitions (suspend/archive/eol only touch - // `status`), so evidence export (N02) must recover *this* snapshot - // rather than reconstruct one from the passport's current — by then - // possibly mutated — row. A re-publish (Suspend -> Published) runs - // this same path again and appends a new "published" entry with a - // fresh snapshot; export always uses the most recent one. + // `status`), so evidence dossier generation must recover *this* + // snapshot rather than reconstruct one from the passport's current — + // by then possibly mutated — row. A re-publish (Suspend -> Published) + // runs this same path again and appends a new "published" entry with + // a fresh snapshot; generation always uses the most recent one. let entry = AuditEntry::new( &updated.id.to_string(), "published", @@ -373,6 +373,7 @@ mod tests { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, diff --git a/crates/dpp-vault/src/domain/service/query.rs b/crates/dpp-vault/src/domain/service/query.rs index d7a0d6a..1ff5853 100644 --- a/crates/dpp-vault/src/domain/service/query.rs +++ b/crates/dpp-vault/src/domain/service/query.rs @@ -3,6 +3,7 @@ use dpp_domain::domain::{ error::DppError, passport::{Passport, PassportId}, + product_identity::ProductIdentity, status::PassportStatus, }; use dpp_types::audit::AuditEntry; @@ -32,6 +33,15 @@ impl PassportService { self.repo.find_published_by_gtin(gtin).await } + /// Fetch a passport by exact compound identity (sector, GTIN, batch), + /// across `Draft` and `Published` — the import delta-matcher's lookup. + pub async fn find_by_identity( + &self, + identity: &ProductIdentity, + ) -> Result, DppError> { + self.repo.find_by_identity(identity).await + } + /// Fetch a passport in any status, including `Archived`. Returns `None` if unknown. pub async fn find_by_id_any_status( &self, diff --git a/crates/dpp-vault/src/domain/verify/did_web.rs b/crates/dpp-vault/src/domain/verify/did_web.rs new file mode 100644 index 0000000..f8e21ab --- /dev/null +++ b/crates/dpp-vault/src/domain/verify/did_web.rs @@ -0,0 +1,66 @@ +//! `did:web` DID-to-URL resolution (W3C did:web method spec), used by the +//! evidence exporter's transfer-chain counterparty lookup. Pure string +//! transform — no I/O here, callers do their own HTTP fetch. + +/// Resolve a `did:web` DID to the URL its document is published at. +/// +/// `did:web:example.com` -> `https://example.com/.well-known/did.json`. +/// `did:web:example.com:path:to:id` -> `https://example.com/path/to/id/did.json`. +/// A `:port` encoded as `%3A` in the host segment is decoded back to `:`. +/// +/// # Errors +/// A plain string reason if `did` is not a `did:web` DID at all. +pub fn did_web_url(did: &str) -> Result { + let rest = did + .strip_prefix("did:web:") + .ok_or_else(|| format!("not a did:web DID: {did}"))?; + let mut segments = rest.split(':'); + let host = segments + .next() + .ok_or_else(|| format!("empty did:web host in {did}"))?; + let host = host.replace("%3A", ":"); + let path_segments: Vec<&str> = segments.collect(); + if path_segments.is_empty() { + Ok(format!("https://{host}/.well-known/did.json")) + } else { + Ok(format!( + "https://{host}/{}/did.json", + path_segments.join("/") + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pathless_did_resolves_to_well_known() { + assert_eq!( + did_web_url("did:web:example.com").unwrap(), + "https://example.com/.well-known/did.json" + ); + } + + #[test] + fn path_did_resolves_to_path_plus_did_json() { + assert_eq!( + did_web_url("did:web:example.com:operators:acme").unwrap(), + "https://example.com/operators/acme/did.json" + ); + } + + #[test] + fn encoded_port_is_decoded() { + assert_eq!( + did_web_url("did:web:example.com%3A8443").unwrap(), + "https://example.com:8443/.well-known/did.json" + ); + } + + #[test] + fn non_did_web_is_an_error() { + assert!(did_web_url("did:key:z6Mk").is_err()); + assert!(did_web_url("https://example.com").is_err()); + } +} diff --git a/crates/dpp-vault/src/domain/verify/engine.rs b/crates/dpp-vault/src/domain/verify/engine.rs new file mode 100644 index 0000000..be54f3a --- /dev/null +++ b/crates/dpp-vault/src/domain/verify/engine.rs @@ -0,0 +1,608 @@ +//! The verification engine: runs every check independently against a +//! [`DossierV1`] and produces a [`VerificationReport`] — a single tamper +//! flips exactly one named check, never cascades into unrelated failures. + +use dpp_types::audit::verify_audit_chain; +use dpp_types::evidence::{ + CheckResult, CheckStatus, DossierV1, VerificationReport, compute_content_hashes, +}; + +use super::jws::{resolve_public_key, verify_jws_content}; +use super::transfer_chain::verify_transfer_chain; + +/// A dossier that could not even be parsed — distinct from a dossier that +/// parsed fine but failed one or more checks (that's a [`VerificationReport`] +/// with `all_verified() == false`, exit code 1). This is exit code 2: the +/// input is not a dossier this verifier understands at all, including an +/// unrecognised field (`deny_unknown_fields`) — which may mean the dossier +/// was produced by a newer format version than this verifier knows. +#[derive(Debug, thiserror::Error)] +pub enum DossierParseError { + #[error("not a valid dossier — malformed JSON or an unrecognised field: {0}")] + Json(#[from] serde_json::Error), +} + +/// Run every check against `dossier` and produce a full report. +/// +/// For typed callers that already hold a `DossierV1`. Byte-level consumers +/// (the verify endpoints) should use [`verify_dossier_json`] instead — it +/// additionally runs the `input_fidelity` check, which needs the original +/// raw bytes. +pub fn verify_dossier(dossier: &DossierV1) -> VerificationReport { + let mut checks = Vec::new(); + + let issuer_key = dossier + .did_documents + .get(&dossier.manifest.issuer_did) + .and_then(|doc| resolve_public_key(&dossier.manifest_jws, doc)); + + // 1. Manifest authenticity. + checks.push(CheckResult { + name: "manifest_signature".into(), + status: match &issuer_key { + None => CheckStatus::Fail(format!( + "no DID document available for issuer {}", + dossier.manifest.issuer_did + )), + Some(key) => { + let manifest_value = serde_json::to_value(&dossier.manifest) + .expect("DossierManifest serialises"); + match verify_jws_content(&dossier.manifest_jws, key, &manifest_value) { + Ok(true) => CheckStatus::Pass, + Ok(false) => CheckStatus::Fail( + "manifest signature invalid, or covers different content than this manifest".into(), + ), + Err(e) => CheckStatus::Fail(format!("malformed manifest signature: {e}")), + } + } + }, + }); + + // 2. Content integrity — every member hashes to what the signed + // manifest commits to. + checks.push(CheckResult { + name: "content_integrity".into(), + status: { + let recomputed = compute_content_hashes(dossier); + if recomputed == dossier.manifest.content_hashes { + CheckStatus::Pass + } else { + let mismatched: Vec<&String> = recomputed + .keys() + .filter(|k| recomputed.get(*k) != dossier.manifest.content_hashes.get(*k)) + .collect(); + CheckStatus::Fail(format!( + "content hash mismatch for: {}", + mismatched + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )) + } + }, + }); + + // 3. Full-view passport JWS. + checks.push(layer_check( + "full_view_signature", + &issuer_key, + &dossier.manifest.issuer_did, + &dossier.full_view.jws, + &dossier.full_view.payload, + )); + + // 4. Public-view passport JWS. + checks.push(layer_check( + "public_view_signature", + &issuer_key, + &dossier.manifest.issuer_did, + &dossier.public_view.jws, + &dossier.public_view.payload, + )); + + // 5. Audit chain. + checks.push(CheckResult { + name: "audit_chain".into(), + status: match verify_audit_chain(&dossier.audit_entries) { + Ok(()) => CheckStatus::Pass, + Err(brk) => CheckStatus::Fail(format!("broken at entry {}: {}", brk.index, brk.reason)), + }, + }); + + // 6. Transfer chain. + checks.push(CheckResult { + name: "transfer_chain".into(), + status: match &dossier.transfer_chain { + None => CheckStatus::Absent("no transfer chain on this passport".into()), + Some(chain) => match verify_transfer_chain(chain, &dossier.did_documents) { + Ok(()) => CheckStatus::Pass, + Err(brk) => { + CheckStatus::Fail(format!("broken at transfer {}: {:?}", brk.index, brk.issue)) + } + }, + }, + }); + + // 7. Checkpoint — always absent in v1, said honestly, never a failure. + checks.push(CheckResult { + name: "checkpoint".into(), + status: match &dossier.checkpoint { + None => CheckStatus::Absent( + "checkpoint layer not yet implemented — audit chain integrity is checked, \ + but nothing pins the chain head against third-party re-hash" + .into(), + ), + Some(_) => CheckStatus::Fail( + "checkpoint present but this verifier build does not yet check it".into(), + ), + }, + }); + + // 8. Calc receipts — always absent in v1, said honestly, never a failure. + checks.push(CheckResult { + name: "calc_receipts".into(), + status: if dossier.calc_receipts.is_empty() { + CheckStatus::Absent( + "no calculation receipts — dpp-calc invocation is not yet wired (licensed \ + factor data pending)" + .into(), + ) + } else { + CheckStatus::Fail( + "calc receipts present but this verifier build does not yet check them".into(), + ) + }, + }); + + VerificationReport { + trust_anchor_note: format!( + "trust anchored to the dossier's embedded DID-document snapshot dated {}", + dossier.manifest.created_at + ), + checks, + } +} + +/// Parse raw dossier bytes and verify them — the entry point the verify +/// endpoints use. +/// +/// Two distinct failure modes: +/// - Malformed JSON, a missing required field, or an unrecognised field +/// anywhere in a strict (`deny_unknown_fields`) type — returns `Err` +/// before any check runs (exit 2). An unrecognised field is treated the +/// same as malformed input on purpose: it may mean this dossier was +/// produced by a newer format version this verifier doesn't know about, +/// and silently ignoring unknown content is exactly what a verifier must +/// never do. +/// - A recognised, well-formed dossier that fails one or more checks — +/// returns `Ok(report)` with `report.all_verified() == false` (exit 1), +/// including a possible `input_fidelity` failure: an unknown field nested +/// inside a *tolerant* type (e.g. a `TransferRecord`, a core `dpp-domain` +/// type this module doesn't make strict) parses fine but is silently +/// dropped by serde — `deny_unknown_fields` on our own types can't catch +/// that, so `input_fidelity` recomputes the canonical bytes of what was +/// *actually parsed* and compares them against the canonical bytes of what +/// was *received*. Any content lost in between fails this check. +/// +/// # Errors +/// [`DossierParseError`] — see above. +pub fn verify_dossier_json(bytes: &[u8]) -> Result { + let raw: serde_json::Value = serde_json::from_slice(bytes)?; + let dossier: DossierV1 = serde_json::from_value(raw.clone())?; + + let mut report = verify_dossier(&dossier); + report.checks.push(input_fidelity_check(&raw, &dossier)); + Ok(report) +} + +/// `JCS(raw) == JCS(reserialize(parse(raw)))` — catches content silently +/// dropped anywhere in the tree, including inside tolerant nested types +/// `deny_unknown_fields` doesn't reach. See `verify_dossier_json`'s doc for +/// the full rationale. +fn input_fidelity_check(raw: &serde_json::Value, parsed: &DossierV1) -> CheckResult { + let status = (|| -> Result { + let raw_bytes = + dpp_crypto::jws::canonicalize(raw).map_err(|e| format!("canonicalising input: {e}"))?; + let reparsed_value = serde_json::to_value(parsed) + .map_err(|e| format!("reserialising parsed dossier: {e}"))?; + let reparsed_bytes = dpp_crypto::jws::canonicalize(&reparsed_value) + .map_err(|e| format!("canonicalising reserialised dossier: {e}"))?; + if reparsed_bytes == raw_bytes { + Ok(CheckStatus::Pass) + } else { + Ok(CheckStatus::Fail( + "dossier content changed after parsing — a field was likely dropped silently \ + (e.g. an unknown field nested inside a tolerant type such as a transfer record)" + .into(), + )) + } + })() + .unwrap_or_else(CheckStatus::Fail); + + CheckResult { + name: "input_fidelity".into(), + status, + } +} + +fn layer_check( + name: &'static str, + issuer_key: &Option, + issuer_did: &str, + jws: &str, + payload: &serde_json::Value, +) -> CheckResult { + let status = match issuer_key { + None => CheckStatus::Fail(format!("no DID document available for issuer {issuer_did}")), + Some(key) => match verify_jws_content(jws, key, payload) { + Ok(true) => CheckStatus::Pass, + Ok(false) => CheckStatus::Fail( + "signature invalid, or covers different content than this payload".into(), + ), + Err(e) => CheckStatus::Fail(format!("malformed signature: {e}")), + }, + }; + CheckResult { + name: name.into(), + status, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use chrono::Utc; + use dpp_crypto::jws::canonicalize; + use dpp_types::audit::AuditEntry; + use dpp_types::evidence::{DossierManifest, SignedLayer}; + use ed25519_dalek::{Signer, SigningKey}; + use std::collections::BTreeMap; + use uuid::Uuid; + + fn did_doc_for(signing_key: &SigningKey) -> serde_json::Value { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let x = b64.encode(signing_key.verifying_key().to_bytes()); + serde_json::json!({ + "verificationMethod": [{ + "id": "did:web:node.example#root", + "type": "JsonWebKey2020", + "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, + }], + "assertionMethod": ["did:web:node.example#root"], + }) + } + + fn sign(signing_key: &SigningKey, payload: &serde_json::Value) -> String { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA"})).unwrap()); + let body = b64.encode(canonicalize(payload).unwrap()); + let signing_input = format!("{header}.{body}"); + let sig = signing_key.sign(signing_input.as_bytes()); + format!("{signing_input}.{}", b64.encode(sig.to_bytes())) + } + + fn genesis_entry(action: &str) -> AuditEntry { + AuditEntry { + id: Uuid::now_v7(), + passport_id: "p1".into(), + actor: "actor".into(), + action: action.into(), + previous_status: None, + new_status: None, + metadata: None, + timestamp: Utc::now(), + prev_hash: None, + entry_hash: None, + } + } + + fn chain_entries(mut entries: Vec) -> Vec { + let mut prev = String::new(); + for e in &mut entries { + let h = e.chain_hash(&prev); + e.prev_hash = Some(prev.clone()); + e.entry_hash = Some(h.clone()); + prev = h; + } + entries + } + + fn valid_dossier(signing_key: &SigningKey) -> DossierV1 { + let full_payload = serde_json::json!({"passportId": "p1", "status": "active"}); + let public_payload = serde_json::json!({"passportId": "p1"}); + let audit_entries = + chain_entries(vec![genesis_entry("created"), genesis_entry("published")]); + + let mut dossier = DossierV1 { + manifest: DossierManifest { + format_version: "1".into(), + passport_id: "p1".into(), + issuer_did: "did:web:node.example".into(), + created_at: Utc::now(), + node_version: "test".into(), + ruleset_version: None, + content_hashes: BTreeMap::new(), + }, + manifest_jws: String::new(), + full_view: SignedLayer { + payload: full_payload.clone(), + jws: sign(signing_key, &full_payload), + }, + public_view: SignedLayer { + payload: public_payload.clone(), + jws: sign(signing_key, &public_payload), + }, + did_documents: BTreeMap::from([( + "did:web:node.example".to_string(), + did_doc_for(signing_key), + )]), + audit_entries, + transfer_chain: None, + eol_event: None, + checkpoint: None, + calc_receipts: Vec::new(), + }; + + dossier.manifest.content_hashes = compute_content_hashes(&dossier); + let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); + dossier.manifest_jws = sign(signing_key, &manifest_value); + dossier + } + + fn by_name<'a>(report: &'a VerificationReport, n: &str) -> &'a CheckStatus { + &report.checks.iter().find(|c| c.name == n).unwrap().status + } + + #[test] + fn clean_dossier_verifies_fully() { + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let dossier = valid_dossier(&signing_key); + let report = verify_dossier(&dossier); + assert!(report.all_verified(), "{report:?}"); + assert_eq!(report.exit_code(), 0); + } + + #[test] + fn tampered_full_view_payload_flips_only_that_check() { + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let mut dossier = valid_dossier(&signing_key); + dossier.full_view.payload["status"] = serde_json::json!("draft"); // tamper, jws not re-signed + let report = verify_dossier(&dossier); + + assert!(matches!( + by_name(&report, "full_view_signature"), + CheckStatus::Fail(_) + )); + assert!(matches!( + by_name(&report, "content_integrity"), + CheckStatus::Fail(_) + )); + // Unrelated checks stay green. + assert_eq!( + *by_name(&report, "public_view_signature"), + CheckStatus::Pass + ); + assert_eq!(*by_name(&report, "audit_chain"), CheckStatus::Pass); + } + + #[test] + fn tampered_jws_flips_only_that_signature_check() { + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let mut dossier = valid_dossier(&signing_key); + dossier.public_view.jws = format!("{}x", dossier.public_view.jws); + let report = verify_dossier(&dossier); + + assert!(matches!( + by_name(&report, "public_view_signature"), + CheckStatus::Fail(_) + )); + assert_eq!(*by_name(&report, "full_view_signature"), CheckStatus::Pass); + } + + #[test] + fn tampered_audit_row_flips_only_audit_chain() { + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let mut dossier = valid_dossier(&signing_key); + dossier.audit_entries[0].action = "tampered".into(); + // Re-derive content_hashes/manifest_jws to isolate the audit-chain + // check specifically (otherwise content_integrity also flips, which + // is correct but not what this test is isolating). + dossier.manifest.content_hashes = compute_content_hashes(&dossier); + let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); + dossier.manifest_jws = sign(&signing_key, &manifest_value); + + let report = verify_dossier(&dossier); + assert!(matches!( + by_name(&report, "audit_chain"), + CheckStatus::Fail(_) + )); + assert_eq!(*by_name(&report, "full_view_signature"), CheckStatus::Pass); + assert_eq!(*by_name(&report, "manifest_signature"), CheckStatus::Pass); + } + + #[test] + fn absent_checkpoint_and_receipts_are_informational_not_failures() { + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let dossier = valid_dossier(&signing_key); + let report = verify_dossier(&dossier); + assert!(matches!( + by_name(&report, "checkpoint"), + CheckStatus::Absent(_) + )); + assert!(matches!( + by_name(&report, "calc_receipts"), + CheckStatus::Absent(_) + )); + assert!(report.all_verified()); + } + + #[test] + fn tampered_transfer_signature_flips_only_transfer_chain() { + use dpp_domain::domain::passport::PassportId; + use dpp_domain::domain::transfer::{ + OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, + }; + + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let from_key = SigningKey::from_bytes(&[3u8; 32]); + let to_key = SigningKey::from_bytes(&[4u8; 32]); + let mut dossier = valid_dossier(&signing_key); + + let operator = |did: &str| ResponsibleOperator { + did: did.to_owned(), + name: "Acme".into(), + role: OperatorRole::Distributor, + eu_operator_id: None, + country: "DE".into(), + }; + let mut record = TransferRecord { + transfer_id: Uuid::now_v7(), + passport_id: PassportId::new(), + from_operator: operator("did:web:from.example"), + to_operator: operator("did:web:to.example"), + reason: TransferReason::Sale, + from_signature: None, + to_signature: None, + initiated_at: Utc::now(), + completed_at: None, + rejected_at: None, + cancelled_at: None, + notes: None, + }; + let payload = record.signing_payload(); + record.from_signature = Some(sign(&from_key, &payload)); + record.to_signature = Some(sign(&to_key, &payload)); + + dossier.transfer_chain = Some(TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }); + dossier + .did_documents + .insert("did:web:from.example".to_string(), did_doc_for(&from_key)); + dossier + .did_documents + .insert("did:web:to.example".to_string(), did_doc_for(&to_key)); + dossier.manifest.content_hashes = compute_content_hashes(&dossier); + let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); + dossier.manifest_jws = sign(&signing_key, &manifest_value); + + // Sanity: clean chain verifies before we tamper it. + let clean_report = verify_dossier(&dossier); + assert!(clean_report.all_verified(), "{clean_report:?}"); + + // Tamper the to_signature, then re-sign the manifest so only the + // transfer-chain check (not content_integrity) is isolated. + if let Some(chain) = &mut dossier.transfer_chain { + chain.transfers[0].to_signature = chain.transfers[0] + .to_signature + .clone() + .map(|s| format!("{s}x")); + } + dossier.manifest.content_hashes = compute_content_hashes(&dossier); + let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); + dossier.manifest_jws = sign(&signing_key, &manifest_value); + + let report = verify_dossier(&dossier); + assert!(matches!( + by_name(&report, "transfer_chain"), + CheckStatus::Fail(_) + )); + assert_eq!(*by_name(&report, "full_view_signature"), CheckStatus::Pass); + assert_eq!(*by_name(&report, "audit_chain"), CheckStatus::Pass); + } + + // ── verify_dossier_json / strictness / fidelity ──────────────────────── + + fn valid_dossier_bytes() -> Vec { + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let dossier = valid_dossier(&signing_key); + serde_json::to_vec(&dossier).unwrap() + } + + #[test] + fn clean_json_round_trips_through_verify_dossier_json() { + let bytes = valid_dossier_bytes(); + let report = verify_dossier_json(&bytes).expect("parses"); + assert!(report.all_verified(), "{report:?}"); + assert_eq!(*by_name(&report, "input_fidelity"), CheckStatus::Pass); + } + + #[test] + fn unknown_top_level_field_is_a_parse_error() { + let bytes = valid_dossier_bytes(); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + value["notARealField"] = serde_json::json!("sneaky"); + let bytes = serde_json::to_vec(&value).unwrap(); + + let err = verify_dossier_json(&bytes) + .expect_err("unknown field must be a hard parse error, not a report"); + assert!(matches!(err, DossierParseError::Json(_))); + } + + #[test] + fn unknown_field_nested_in_a_tolerant_type_fails_input_fidelity() { + // TransferRecord (a dpp-domain type) is not `deny_unknown_fields` — + // an unknown field inside it parses fine and is silently dropped by + // serde. `deny_unknown_fields` on our own types can't catch this; + // `input_fidelity` must. + use dpp_domain::domain::passport::PassportId; + use dpp_domain::domain::transfer::{ + OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, + }; + + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let mut dossier = valid_dossier(&signing_key); + let operator = |did: &str| ResponsibleOperator { + did: did.to_owned(), + name: "Acme".into(), + role: OperatorRole::Distributor, + eu_operator_id: None, + country: "DE".into(), + }; + let record = TransferRecord { + transfer_id: Uuid::now_v7(), + passport_id: PassportId::new(), + from_operator: operator("did:web:from.example"), + to_operator: operator("did:web:to.example"), + reason: TransferReason::Sale, + from_signature: None, + to_signature: None, + initiated_at: Utc::now(), + completed_at: None, + rejected_at: None, + cancelled_at: None, + notes: None, + }; + dossier.transfer_chain = Some(TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }); + dossier.manifest.content_hashes = compute_content_hashes(&dossier); + let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); + dossier.manifest_jws = sign(&signing_key, &manifest_value); + + let mut value = serde_json::to_value(&dossier).unwrap(); + value["transferChain"]["transfers"][0]["certificationStatus"] = + serde_json::json!("approved"); + let bytes = serde_json::to_vec(&value).unwrap(); + + let report = verify_dossier_json(&bytes) + .expect("TransferRecord tolerates unknown fields, so this must parse"); + assert!(matches!( + by_name(&report, "input_fidelity"), + CheckStatus::Fail(_) + )); + assert!(!report.all_verified()); + } + + #[test] + fn malformed_json_is_a_parse_error() { + let err = verify_dossier_json(b"not json").unwrap_err(); + assert!(matches!(err, DossierParseError::Json(_))); + } +} diff --git a/crates/dpp-vault/src/domain/verify/jws.rs b/crates/dpp-vault/src/domain/verify/jws.rs new file mode 100644 index 0000000..37851d4 --- /dev/null +++ b/crates/dpp-vault/src/domain/verify/jws.rs @@ -0,0 +1,149 @@ +//! Dossier-side JWS helpers on top of `dpp_crypto::jws`. +//! +//! `dpp-crypto` exposes the raw verifier and the DID-document key-extraction +//! functions; what it does not expose is payload decoding and content +//! binding, which dossier verification needs — those live here. + +use anyhow::anyhow; +use base64::Engine; +use dpp_crypto::jws::{ + canonicalize, extract_key_by_fingerprint, extract_kid_from_jws, extract_primary_public_key, + verify_jws, +}; + +/// Resolve the public key to verify `jws` against, from a DID document. +/// +/// If the JWS carries a `kid`, it must resolve to a key by fingerprint (this +/// includes rotation-archived keys). A present-but-unresolvable `kid` (revoked, +/// rotated out, or simply wrong) returns `None` so the caller surfaces an +/// accurate "kid does not resolve" diagnosis — it does **not** silently +/// substitute the primary key. The primary-key fallback is reserved for legacy +/// tokens signed before `kid` was added, i.e. tokens carrying no `kid` at all. +pub(crate) fn resolve_public_key(jws: &str, did_document: &serde_json::Value) -> Option { + match extract_kid_from_jws(jws) { + Some(kid) => extract_key_by_fingerprint(did_document, &kid), + None => extract_primary_public_key(did_document), + } +} + +/// Decode the payload segment of a compact JWS to raw bytes (post-base64, +/// pre-JSON-parse). +pub(crate) fn decode_payload_bytes(jws: &str) -> anyhow::Result> { + let payload_b64 = jws + .split('.') + .nth(1) + .ok_or_else(|| anyhow!("JWS has no payload segment"))?; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|e| anyhow!("payload base64: {e}")) +} + +/// Verify both that `jws` is validly signed under `public_key_b64` **and** +/// that its embedded payload is exactly the JCS-canonical bytes of +/// `expected`. +/// +/// The plain `verify_jws` only checks the signature is internally +/// consistent — it says nothing about *what* was signed. Every signer in +/// this system embeds `base64url(JCS(payload))` as the payload segment +/// (`dpp-crypto`'s `jws::signer`), so content-binding means recomputing +/// those same canonical bytes and comparing. Without this step a validly +/// signed JWS over the *wrong* content would incorrectly verify. +pub(crate) fn verify_jws_content( + jws: &str, + public_key_b64: &str, + expected: &serde_json::Value, +) -> anyhow::Result { + if !verify_jws(jws, public_key_b64)? { + return Ok(false); + } + let actual = decode_payload_bytes(jws)?; + let expected_bytes = canonicalize(expected)?; + Ok(actual == expected_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn sign(signing_key: &SigningKey, payload: &serde_json::Value) -> String { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA"})).unwrap()); + let body = b64.encode(canonicalize(payload).unwrap()); + let signing_input = format!("{header}.{body}"); + let sig = signing_key.sign(signing_input.as_bytes()); + format!("{signing_input}.{}", b64.encode(sig.to_bytes())) + } + + fn sign_with_kid(signing_key: &SigningKey, payload: &serde_json::Value, kid: &str) -> String { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = b64 + .encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA", "kid": kid})).unwrap()); + let body = b64.encode(canonicalize(payload).unwrap()); + let signing_input = format!("{header}.{body}"); + let sig = signing_key.sign(signing_input.as_bytes()); + format!("{signing_input}.{}", b64.encode(sig.to_bytes())) + } + + fn did_doc_for(signing_key: &SigningKey) -> serde_json::Value { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let x = b64.encode(signing_key.verifying_key().to_bytes()); + serde_json::json!({ + "verificationMethod": [{ + "id": "did:web:example.com#root", + "type": "JsonWebKey2020", + "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, + }], + "assertionMethod": ["did:web:example.com#root"], + }) + } + + #[test] + fn content_binding_rejects_signature_over_different_content() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let did_doc = did_doc_for(&signing_key); + let key = resolve_public_key("x.x.x", &did_doc).unwrap(); + + let signed = serde_json::json!({"a": 1}); + let jws = sign(&signing_key, &signed); + + assert!(verify_jws_content(&jws, &key, &signed).unwrap()); + let other = serde_json::json!({"a": 2}); + assert!( + !verify_jws_content(&jws, &key, &other).unwrap(), + "a valid signature over different content must not verify" + ); + } + + #[test] + fn resolve_falls_back_to_primary_key_without_kid() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let did_doc = did_doc_for(&signing_key); + let jws = sign(&signing_key, &serde_json::json!({"a": 1})); // header has no kid + let key = resolve_public_key(&jws, &did_doc); + assert!(key.is_some(), "must fall back to the primary key"); + } + + #[test] + fn resolve_returns_none_for_present_but_unresolvable_kid() { + // A kid that resolves to no key in the DID document (revoked/rotated + // out/wrong) must NOT silently substitute the primary key — return None + // so the caller reports the accurate "kid does not resolve" diagnosis. + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let did_doc = did_doc_for(&signing_key); + let jws = sign_with_kid( + &signing_key, + &serde_json::json!({"a": 1}), + "not-a-real-fingerprint", + ); + assert!( + resolve_public_key(&jws, &did_doc).is_none(), + "an unresolvable kid must not fall back to the primary key" + ); + } + + #[test] + fn decode_payload_bytes_rejects_segmentless_input() { + assert!(decode_payload_bytes("no-dots-here").is_err()); + } +} diff --git a/crates/dpp-vault/src/domain/verify/mod.rs b/crates/dpp-vault/src/domain/verify/mod.rs new file mode 100644 index 0000000..d8b0be7 --- /dev/null +++ b/crates/dpp-vault/src/domain/verify/mod.rs @@ -0,0 +1,12 @@ +//! Dossier verification: independent named checks over a stored or uploaded +//! evidence dossier — a single tamper flips exactly one check, never +//! cascades into unrelated failures. + +mod did_web; +mod engine; +mod jws; +mod transfer_chain; + +pub use did_web::did_web_url; +pub use engine::{DossierParseError, verify_dossier, verify_dossier_json}; +pub use transfer_chain::{TransferChainBreak, TransferSignatureIssue, verify_transfer_chain}; diff --git a/crates/dpp-vault/src/domain/verify/transfer_chain.rs b/crates/dpp-vault/src/domain/verify/transfer_chain.rs new file mode 100644 index 0000000..aae4eb9 --- /dev/null +++ b/crates/dpp-vault/src/domain/verify/transfer_chain.rs @@ -0,0 +1,330 @@ +//! Transfer-chain signature verification. +//! +//! Nothing else in the engine verifies a whole [`TransferChain`] standalone — +//! signature checking normally happens inline, one record at a time, as +//! part of accepting a transfer. This is the "verify the whole chain +//! after the fact" implementation; it reuses `TransferRecord::signing_payload` +//! (the exact bytes both operators sign) and the same JWS verification used +//! everywhere else, just applied per-record. + +use std::collections::BTreeMap; + +use dpp_domain::domain::transfer::TransferChain; + +use super::jws::{resolve_public_key, verify_jws_content}; + +/// Which signature(s) on a transfer record failed to verify. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransferSignatureIssue { + /// The `from_operator`'s signature is missing, or failed to verify, or + /// their DID document was not available to check against. + From(String), + /// The `to_operator`'s signature is missing, or failed to verify, or + /// their DID document was not available to check against. + To(String), +} + +/// The first broken record found while verifying a transfer chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferChainBreak { + /// 0-based position of the offending record in `chain.transfers`. + pub index: usize, + pub issue: TransferSignatureIssue, +} + +/// Verify every completed transfer record's signatures against the DID +/// documents available in `did_documents` (keyed by DID). +/// +/// A **completed** record (has `completed_at`, not rejected/cancelled) must +/// carry both operator signatures — an absent signature fails closed rather than +/// being treated as "nothing to verify", so a record a producing node marked +/// completed without signing can never pass with zero cryptographic checks. A +/// still-`Initiated` record's not-yet-present signature is skipped. A record +/// whose signer's DID document is missing from `did_documents` fails closed +/// (reported, not silently skipped) so a verifier never reports false-green on +/// an unresolvable cross-operator DID. +/// +/// # Errors +/// [`TransferChainBreak`] at the first record with a missing (on a completed +/// record), bad, or unverifiable signature. +pub fn verify_transfer_chain( + chain: &TransferChain, + did_documents: &BTreeMap, +) -> Result<(), TransferChainBreak> { + for (index, record) in chain.transfers.iter().enumerate() { + // A completed transfer must be fully signed by both parties. + let is_completed = record.completed_at.is_some() + && record.rejected_at.is_none() + && record.cancelled_at.is_none(); + + let payload = record.signing_payload(); + + match &record.from_signature { + Some(sig) => { + check_signature(&record.from_operator.did, sig, &payload, did_documents).map_err( + |reason| TransferChainBreak { + index, + issue: TransferSignatureIssue::From(reason), + }, + )?; + } + None if is_completed => { + return Err(TransferChainBreak { + index, + issue: TransferSignatureIssue::From( + "completed transfer is missing the from-operator signature".into(), + ), + }); + } + None => {} + } + + match &record.to_signature { + Some(sig) => { + check_signature(&record.to_operator.did, sig, &payload, did_documents).map_err( + |reason| TransferChainBreak { + index, + issue: TransferSignatureIssue::To(reason), + }, + )?; + } + None if is_completed => { + return Err(TransferChainBreak { + index, + issue: TransferSignatureIssue::To( + "completed transfer is missing the to-operator signature".into(), + ), + }); + } + None => {} + } + } + Ok(()) +} + +fn check_signature( + did: &str, + jws: &str, + payload: &serde_json::Value, + did_documents: &BTreeMap, +) -> Result<(), String> { + let did_doc = did_documents + .get(did) + .ok_or_else(|| format!("no DID document available for {did} — cannot verify"))?; + let key = resolve_public_key(jws, did_doc) + .ok_or_else(|| format!("no usable assertion key found in DID document for {did}"))?; + + match verify_jws_content(jws, &key, payload) { + Ok(true) => Ok(()), + Ok(false) => Err(format!( + "signature does not verify against {did}'s key, or covers different content than the transfer terms" + )), + Err(e) => Err(format!("malformed signature: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use chrono::Utc; + use dpp_crypto::jws::canonicalize; + use dpp_domain::domain::{ + passport::PassportId, + transfer::{OperatorRole, ResponsibleOperator, TransferReason, TransferRecord}, + }; + use ed25519_dalek::{Signer, SigningKey}; + use uuid::Uuid; + + fn operator(did: &str) -> ResponsibleOperator { + ResponsibleOperator { + did: did.to_owned(), + name: "Acme".into(), + role: OperatorRole::Distributor, + eu_operator_id: None, + country: "DE".into(), + } + } + + fn did_doc_for(signing_key: &SigningKey) -> serde_json::Value { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let x = b64.encode(signing_key.verifying_key().to_bytes()); + serde_json::json!({ + "verificationMethod": [{ + "id": "did:web:example.com#root", + "type": "JsonWebKey2020", + "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": x }, + }], + "assertionMethod": ["did:web:example.com#root"], + }) + } + + fn sign(signing_key: &SigningKey, payload: &serde_json::Value) -> String { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = b64.encode(serde_json::to_vec(&serde_json::json!({"alg": "EdDSA"})).unwrap()); + let body = b64.encode(canonicalize(payload).unwrap()); + let signing_input = format!("{header}.{body}"); + let sig = signing_key.sign(signing_input.as_bytes()); + format!("{signing_input}.{}", b64.encode(sig.to_bytes())) + } + + fn record_with_signatures( + from_key: &SigningKey, + to_key: &SigningKey, + from_did: &str, + to_did: &str, + ) -> TransferRecord { + let mut record = TransferRecord { + transfer_id: Uuid::now_v7(), + passport_id: PassportId::new(), + from_operator: operator(from_did), + to_operator: operator(to_did), + reason: TransferReason::Sale, + from_signature: None, + to_signature: None, + initiated_at: Utc::now(), + completed_at: None, + rejected_at: None, + cancelled_at: None, + notes: None, + }; + let payload = record.signing_payload(); + record.from_signature = Some(sign(from_key, &payload)); + record.to_signature = Some(sign(to_key, &payload)); + record + } + + #[test] + fn intact_chain_verifies() { + let from_key = SigningKey::from_bytes(&[1u8; 32]); + let to_key = SigningKey::from_bytes(&[2u8; 32]); + let record = record_with_signatures( + &from_key, + &to_key, + "did:web:from.example", + "did:web:to.example", + ); + let chain = TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }; + let mut docs = BTreeMap::new(); + docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); + docs.insert("did:web:to.example".to_string(), did_doc_for(&to_key)); + + assert!(verify_transfer_chain(&chain, &docs).is_ok()); + } + + #[test] + fn tampered_to_signature_is_detected() { + let from_key = SigningKey::from_bytes(&[1u8; 32]); + let to_key = SigningKey::from_bytes(&[2u8; 32]); + let mut record = record_with_signatures( + &from_key, + &to_key, + "did:web:from.example", + "did:web:to.example", + ); + record.to_signature = record.to_signature.map(|s| format!("{s}tampered")); + let chain = TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }; + let mut docs = BTreeMap::new(); + docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); + docs.insert("did:web:to.example".to_string(), did_doc_for(&to_key)); + + let brk = verify_transfer_chain(&chain, &docs).expect_err("must detect tamper"); + assert_eq!(brk.index, 0); + assert!(matches!(brk.issue, TransferSignatureIssue::To(_))); + } + + #[test] + fn missing_did_document_fails_closed() { + let from_key = SigningKey::from_bytes(&[1u8; 32]); + let to_key = SigningKey::from_bytes(&[2u8; 32]); + let record = record_with_signatures( + &from_key, + &to_key, + "did:web:from.example", + "did:web:to.example", + ); + let chain = TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }; + // Only the "from" DID document is available. + let mut docs = BTreeMap::new(); + docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); + + let brk = verify_transfer_chain(&chain, &docs).expect_err("must fail closed"); + assert!(matches!(brk.issue, TransferSignatureIssue::To(_))); + } + + #[test] + fn completed_record_without_signatures_fails_closed() { + // A record marked completed but carrying no signatures (a producing-node + // workflow bug) must fail closed, not pass with zero cryptographic checks. + let record = TransferRecord { + transfer_id: Uuid::now_v7(), + passport_id: PassportId::new(), + from_operator: operator("did:web:from.example"), + to_operator: operator("did:web:to.example"), + reason: TransferReason::Sale, + from_signature: None, + to_signature: None, + initiated_at: Utc::now(), + completed_at: Some(Utc::now()), + rejected_at: None, + cancelled_at: None, + notes: None, + }; + let chain = TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }; + // No DID docs needed — it must fail on the missing signature first. + let brk = verify_transfer_chain(&chain, &BTreeMap::new()) + .expect_err("a completed but unsigned record must fail closed"); + assert_eq!(brk.index, 0); + assert!(matches!(brk.issue, TransferSignatureIssue::From(_))); + } + + #[test] + fn initiated_record_pending_countersignature_is_skipped() { + // Still-Initiated (not completed): from signed, awaiting the to-operator. + // The absent to-signature is skipped, not treated as a failure. + let from_key = SigningKey::from_bytes(&[1u8; 32]); + let mut record = TransferRecord { + transfer_id: Uuid::now_v7(), + passport_id: PassportId::new(), + from_operator: operator("did:web:from.example"), + to_operator: operator("did:web:to.example"), + reason: TransferReason::Sale, + from_signature: None, + to_signature: None, + initiated_at: Utc::now(), + completed_at: None, + rejected_at: None, + cancelled_at: None, + notes: None, + }; + let payload = record.signing_payload(); + record.from_signature = Some(sign(&from_key, &payload)); + let chain = TransferChain { + passport_id: record.passport_id, + original_operator: operator("did:web:from.example"), + transfers: vec![record], + }; + let mut docs = BTreeMap::new(); + docs.insert("did:web:from.example".to_string(), did_doc_for(&from_key)); + assert!( + verify_transfer_chain(&chain, &docs).is_ok(), + "an initiated (uncompleted) record must not fail on its pending countersignature" + ); + } +} diff --git a/crates/dpp-vault/src/handlers/archive.rs b/crates/dpp-vault/src/handlers/archive.rs index 7042988..2cd4744 100644 --- a/crates/dpp-vault/src/handlers/archive.rs +++ b/crates/dpp-vault/src/handlers/archive.rs @@ -20,6 +20,13 @@ pub async fn archive_handler( Extension(auth): Extension, Path(dpp_id): Path, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Archiving a passport requires a write-scoped credential.", + ); + } let passport_id = match parse_passport_id(&dpp_id) { Ok(id) => id, Err(e) => return e, diff --git a/crates/dpp-vault/src/handlers/create.rs b/crates/dpp-vault/src/handlers/create.rs index 3771df9..9bf6f5e 100644 --- a/crates/dpp-vault/src/handlers/create.rs +++ b/crates/dpp-vault/src/handlers/create.rs @@ -50,6 +50,13 @@ pub async fn create_handler( Extension(auth): Extension, Json(body): Json, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Creating a passport requires a write-scoped credential.", + ); + } if body.product_name.trim().is_empty() { return api_error( StatusCode::UNPROCESSABLE_ENTITY, @@ -166,8 +173,9 @@ pub async fn create_handler( repairability_score: body .repairability_score .map(RepairabilityScore::from_scalar), - // Populated by the service's `apply_compliance` after creation. + // Populated by the service's `apply_compliance`/`apply_lint` after creation. compliance_result: None, + lint_result: None, sector_data: body.sector_data, status: PassportStatus::Draft, qr_code_url: None, diff --git a/crates/dpp-vault/src/handlers/eol.rs b/crates/dpp-vault/src/handlers/eol.rs index 8103761..3650e2e 100644 --- a/crates/dpp-vault/src/handlers/eol.rs +++ b/crates/dpp-vault/src/handlers/eol.rs @@ -40,6 +40,13 @@ pub async fn eol_handler( Path(dpp_id): Path, Json(body): Json, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Declaring a passport end-of-life requires a write-scoped credential.", + ); + } let passport_id = match parse_passport_id(&dpp_id) { Ok(id) => id, Err(e) => return e, diff --git a/crates/dpp-vault/src/handlers/evidence.rs b/crates/dpp-vault/src/handlers/evidence.rs index 85b385e..3b49da6 100644 --- a/crates/dpp-vault/src/handlers/evidence.rs +++ b/crates/dpp-vault/src/handlers/evidence.rs @@ -1,23 +1,29 @@ -//! `GET /api/v1/dpp/{dppId}/evidence` — self-contained, signed evidence -//! dossier for fully offline verification (N02). +//! Evidence dossier endpoints: generate, list, fetch, and verify. use axum::{ Json, + body::Bytes, extract::{Extension, Path, State}, http::StatusCode, response::IntoResponse, }; +use uuid::Uuid; -use crate::{middleware::auth::AuthContext, state::AppState}; +use crate::{domain::verify::verify_dossier_json, middleware::auth::AuthContext, state::AppState}; use super::error::{api_error, internal_error, parse_passport_id}; -/// `GET /api/v1/dpp/{dppId}/evidence` — assemble and return the evidence -/// dossier. Authenticated tier only (it contains full-view data); a public -/// redacted variant can follow later. -pub async fn evidence_handler( +#[allow(clippy::result_large_err)] +fn parse_dossier_id(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "BAD_REQUEST", "Invalid dossier id")) +} + +/// `POST /api/v1/dpp/{dppId}/evidence` — generate and store a new evidence +/// dossier for a passport. +pub async fn generate_evidence_handler( State(state): State, - Extension(_auth): Extension, + Extension(auth): Extension, Path(dpp_id): Path, ) -> impl IntoResponse { let passport_id = match parse_passport_id(&dpp_id) { @@ -25,8 +31,8 @@ pub async fn evidence_handler( Err(e) => return e, }; - match state.service.export_evidence(passport_id).await { - Ok(dossier) => (StatusCode::OK, Json(dossier)).into_response(), + match state.service.generate_evidence(passport_id, &auth).await { + Ok(record) => (StatusCode::CREATED, Json(record)).into_response(), Err(dpp_domain::DppError::NotFound(_)) => { api_error(StatusCode::NOT_FOUND, "NOT_FOUND", "DPP not found.") } @@ -36,3 +42,80 @@ pub async fn evidence_handler( Err(e) => internal_error(e), } } + +/// `GET /api/v1/dpp/{dppId}/evidence` — list stored dossier summaries for a +/// passport, newest first. +pub async fn list_evidence_handler( + State(state): State, + Extension(_auth): Extension, + Path(dpp_id): Path, +) -> impl IntoResponse { + let passport_id = match parse_passport_id(&dpp_id) { + Ok(id) => id, + Err(e) => return e, + }; + + match state.service.list_evidence(passport_id).await { + Ok(summaries) => (StatusCode::OK, Json(summaries)).into_response(), + Err(dpp_domain::DppError::NotFound(_)) => { + api_error(StatusCode::NOT_FOUND, "NOT_FOUND", "DPP not found.") + } + Err(e) => internal_error(e), + } +} + +/// `GET /api/v1/evidence/{id}` — fetch one stored dossier's document. +pub async fn get_evidence_handler( + State(state): State, + Extension(_auth): Extension, + Path(id): Path, +) -> impl IntoResponse { + let dossier_id = match parse_dossier_id(&id) { + Ok(id) => id, + Err(e) => return e, + }; + + match state.service.get_evidence(dossier_id).await { + Ok(record) => (StatusCode::OK, Json(record.dossier)).into_response(), + Err(dpp_domain::DppError::NotFound(_)) => { + api_error(StatusCode::NOT_FOUND, "NOT_FOUND", "Dossier not found.") + } + Err(e) => internal_error(e), + } +} + +/// `POST /api/v1/evidence/{id}/verify` — verify a stored dossier. +pub async fn verify_evidence_handler( + State(state): State, + Extension(_auth): Extension, + Path(id): Path, +) -> impl IntoResponse { + let dossier_id = match parse_dossier_id(&id) { + Ok(id) => id, + Err(e) => return e, + }; + + match state.service.verify_evidence(dossier_id).await { + Ok(report) => (StatusCode::OK, Json(report)).into_response(), + Err(dpp_domain::DppError::NotFound(_)) => { + api_error(StatusCode::NOT_FOUND, "NOT_FOUND", "Dossier not found.") + } + Err(e) => internal_error(e), + } +} + +/// `POST /api/v1/evidence/verify` — verify an uploaded dossier document. +pub async fn verify_document_handler( + State(_state): State, + Extension(_auth): Extension, + body: Bytes, +) -> impl IntoResponse { + match verify_dossier_json(&body) { + Ok(report) => (StatusCode::OK, Json(report)).into_response(), + Err(e) => api_error( + StatusCode::UNPROCESSABLE_ENTITY, + "INVALID_DOSSIER", + &e.to_string(), + ), + } +} diff --git a/crates/dpp-vault/src/handlers/find_by_identity.rs b/crates/dpp-vault/src/handlers/find_by_identity.rs new file mode 100644 index 0000000..8d6591f --- /dev/null +++ b/crates/dpp-vault/src/handlers/find_by_identity.rs @@ -0,0 +1,46 @@ +//! `GET /api/v1/dpp/by-identity` — exact compound identity lookup for the +//! import delta-matcher (sector, GTIN, batch), across `Draft` and `Published`. + +use axum::{ + Json, + extract::{Extension, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; + +use dpp_domain::domain::{product_identity::ProductIdentity, sector::Sector}; + +use crate::{middleware::auth::AuthContext, state::AppState}; + +use super::error::internal_error; + +/// Query parameters for the identity-lookup endpoint. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IdentityQuery { + pub sector: Sector, + pub gtin: String, + /// Omit to match only passports with no batch set. + pub batch_id: Option, +} + +/// `GET /api/v1/dpp/by-identity` — `404` if no passport matches. +pub async fn find_by_identity_handler( + State(state): State, + Extension(_auth): Extension, + Query(query): Query, +) -> impl IntoResponse { + let identity = ProductIdentity { + sector: query.sector, + gtin: query.gtin, + batch_id: query.batch_id, + }; + + match state.service.find_by_identity(&identity).await { + Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(), + Ok(None) => dpp_common::http_problem::not_found("No passport matches that identity.") + .into_response(), + Err(e) => internal_error(e), + } +} diff --git a/crates/dpp-vault/src/handlers/lint.rs b/crates/dpp-vault/src/handlers/lint.rs new file mode 100644 index 0000000..450c0b3 --- /dev/null +++ b/crates/dpp-vault/src/handlers/lint.rs @@ -0,0 +1,37 @@ +//! `POST /api/v1/dpp/{dppId}/lint` — on-demand plausibility-lint re-check (N10). + +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, +}; + +use crate::{middleware::auth::AuthContext, state::AppState}; + +use super::error::{api_error, internal_error, parse_passport_id}; + +/// `POST /api/v1/dpp/{dppId}/lint` — recompute and persist the plausibility +/// lint pack's findings against the passport's current sector data. +/// +/// Non-binding: findings never block publish and this endpoint never fails +/// on their account. Works regardless of passport status (Draft or +/// Published) — see [`dpp_vault::domain::service::PassportService::relint`]. +pub async fn lint_handler( + State(state): State, + Extension(_auth): Extension, + Path(dpp_id): Path, +) -> impl IntoResponse { + let passport_id = match parse_passport_id(&dpp_id) { + Ok(id) => id, + Err(e) => return e, + }; + + match state.service.relint(passport_id).await { + Ok(p) => (StatusCode::OK, Json(p)).into_response(), + Err(dpp_domain::DppError::NotFound(_)) => { + api_error(StatusCode::NOT_FOUND, "NOT_FOUND", "DPP not found.") + } + Err(e) => internal_error(e), + } +} diff --git a/crates/dpp-vault/src/handlers/operator.rs b/crates/dpp-vault/src/handlers/operator.rs index cff4de2..fb5cab1 100644 --- a/crates/dpp-vault/src/handlers/operator.rs +++ b/crates/dpp-vault/src/handlers/operator.rs @@ -40,6 +40,9 @@ pub async fn operator_patch_handler( "Updating operator config requires an admin-scoped credential.", ); } + if let Err(msg) = patch.validate() { + return api_error(StatusCode::BAD_REQUEST, "INVALID_CONFIG", &msg); + } match state .operator_service .update(STANDALONE_OPERATOR_ID, patch) diff --git a/crates/dpp-vault/src/handlers/publish.rs b/crates/dpp-vault/src/handlers/publish.rs index b441038..16ece15 100644 --- a/crates/dpp-vault/src/handlers/publish.rs +++ b/crates/dpp-vault/src/handlers/publish.rs @@ -21,6 +21,13 @@ pub async fn publish_handler( Extension(auth): Extension, Path(dpp_id): Path, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Publishing a passport requires a write-scoped credential.", + ); + } let passport_id = match parse_passport_id(&dpp_id) { Ok(id) => id, Err(e) => return e, diff --git a/crates/dpp-vault/src/handlers/suspend.rs b/crates/dpp-vault/src/handlers/suspend.rs index f0824e3..74df2bc 100644 --- a/crates/dpp-vault/src/handlers/suspend.rs +++ b/crates/dpp-vault/src/handlers/suspend.rs @@ -30,6 +30,13 @@ pub async fn suspend_handler( Path(dpp_id): Path, body: Option>, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Suspending a passport requires a write-scoped credential.", + ); + } let passport_id = match parse_passport_id(&dpp_id) { Ok(id) => id, Err(e) => return e, diff --git a/crates/dpp-vault/src/handlers/transfer.rs b/crates/dpp-vault/src/handlers/transfer.rs index 807008a..7ea38da 100644 --- a/crates/dpp-vault/src/handlers/transfer.rs +++ b/crates/dpp-vault/src/handlers/transfer.rs @@ -38,6 +38,13 @@ pub async fn transfer_initiate_handler( Path(dpp_id): Path, Json(body): Json, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Initiating a transfer requires a write-scoped credential.", + ); + } let id = match parse_passport_id(&dpp_id) { Ok(i) => i, Err(e) => return e, @@ -79,6 +86,13 @@ pub async fn transfer_accept_handler( Extension(auth): Extension, Path(dpp_id): Path, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Accepting a transfer requires a write-scoped credential.", + ); + } let id = match parse_passport_id(&dpp_id) { Ok(i) => i, Err(e) => return e, diff --git a/crates/dpp-vault/src/handlers/update.rs b/crates/dpp-vault/src/handlers/update.rs index bcae96e..1b5df57 100644 --- a/crates/dpp-vault/src/handlers/update.rs +++ b/crates/dpp-vault/src/handlers/update.rs @@ -20,6 +20,13 @@ pub async fn update_handler( Path(dpp_id): Path, Json(body): Json, ) -> impl IntoResponse { + if !auth.scope.can_write() { + return api_error( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "Updating a passport requires a write-scoped credential.", + ); + } let passport_id = match parse_passport_id(&dpp_id) { Ok(id) => id, Err(e) => return e, diff --git a/crates/dpp-vault/src/main.rs b/crates/dpp-vault/src/main.rs index 30fbca0..db9fbbd 100644 --- a/crates/dpp-vault/src/main.rs +++ b/crates/dpp-vault/src/main.rs @@ -7,7 +7,8 @@ use tracing_subscriber::{EnvFilter, fmt}; use dpp_common::{event::NoOpEventBus, event_codes}; use dpp_dal::pg::{ - PgApiKeyRepo, PgAuditRepo, PgDal, PgOperatorConfigRepo, PgPassportRepo, PgRegistryIdentityRepo, + PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo, + PgRegistryIdentityRepo, }; use dpp_domain::{ DppError, GhostArchive, GhostRegistrySync, @@ -62,6 +63,7 @@ async fn main() -> anyhow::Result<()> { let operator_repo = Arc::new(PgOperatorConfigRepo::new(dal.clone())); let api_key_repo = Arc::new(PgApiKeyRepo::new(dal.clone())); let registry_repo = Arc::new(PgRegistryIdentityRepo::new(dal.clone())); + let evidence_repo = Arc::new(PgEvidenceDossierRepo::new(dal.clone())); let identity = Arc::new(IdentityHttpClient::new(cfg.identity_service_url.clone())); let compliance = Arc::new(PassthroughRegistry::new()); @@ -117,7 +119,8 @@ async fn main() -> anyhow::Result<()> { Arc::new(GhostArchive), String::new(), ) - .with_registry_reader(operator_repo.clone()), + .with_registry_reader(operator_repo.clone()) + .with_evidence_store(evidence_repo), ); let operator_service = Arc::new(OperatorService::new(operator_repo)); let api_key_service = Arc::new(ApiKeyService::new(api_key_repo.clone())); diff --git a/crates/dpp-vault/src/router.rs b/crates/dpp-vault/src/router.rs index c6d386a..05bfa41 100644 --- a/crates/dpp-vault/src/router.rs +++ b/crates/dpp-vault/src/router.rs @@ -23,7 +23,10 @@ use crate::{ archive::archive_handler, create::create_handler, eol::eol_handler, - evidence::evidence_handler, + evidence::{ + generate_evidence_handler, get_evidence_handler, list_evidence_handler, + verify_document_handler, verify_evidence_handler, + }, health::{health_handler, ready_handler}, history::history_handler, info::info_handler, @@ -71,7 +74,13 @@ pub fn build(state: AppState) -> Router { post(transfer_accept_handler), ) .route("/dpp/{dppId}/history", get(history_handler)) - .route("/dpp/{dppId}/evidence", get(evidence_handler)) + .route( + "/dpp/{dppId}/evidence", + get(list_evidence_handler).post(generate_evidence_handler), + ) + .route("/evidence/verify", post(verify_document_handler)) + .route("/evidence/{id}", get(get_evidence_handler)) + .route("/evidence/{id}/verify", post(verify_evidence_handler)) // ── Node setup state ────────────────────────────────────────── .route("/node/state", get(node_state_handler)) // ── Operator config ─────────────────────────────────────────── diff --git a/crates/dpp-vault/tests/api_key_scope.rs b/crates/dpp-vault/tests/api_key_scope.rs index 745f9c9..3b68353 100644 --- a/crates/dpp-vault/tests/api_key_scope.rs +++ b/crates/dpp-vault/tests/api_key_scope.rs @@ -61,6 +61,44 @@ async fn write_scoped_credential_cannot_escalate() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn read_scoped_credential_cannot_mutate_passports() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + + // A least-privilege read-only key (documented for GET-only integrations). + let reader = TestClient::new(&vault_url, make_jwt_scoped("op", "read")); + let id = "00000000-0000-4000-8000-000000000000"; + + // Every passport-lifecycle mutation must reject a Read-scoped credential with + // 403 — the `can_write()` gate runs first, before any state change. Bodies are + // valid so each request reaches the handler rather than failing extraction. + let create = reader + .post_json( + "/api/v1/dpp", + json!({ "productName": "x", "manufacturer": { "name": "n", "address": "a" } }), + ) + .await; + assert_eq!(create.status(), 403, "create must require write scope"); + + let update = reader + .put_json(&format!("/api/v1/dpp/{id}"), json!({})) + .await; + assert_eq!(update.status(), 403, "update must require write scope"); + + // publish / suspend / archive / transfer-accept take no request body of their + // own; eol and transfer-initiate share the identical first-line gate. + for path in [ + format!("/api/v1/dpp/{id}/publish"), + format!("/api/v1/dpp/{id}/suspend"), + format!("/api/v1/dpp/{id}/archive"), + format!("/api/v1/dpp/{id}/transfer/accept"), + ] { + let r = reader.post_json(&path, json!({})).await; + assert_eq!(r.status(), 403, "{path} must require write scope"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn admin_can_mint_least_privilege_key_and_scope_round_trips() { let pg = start_postgres().await; diff --git a/crates/dpp-vault/tests/compliance_tests.rs b/crates/dpp-vault/tests/compliance_tests.rs index 81a773c..74fd41f 100644 --- a/crates/dpp-vault/tests/compliance_tests.rs +++ b/crates/dpp-vault/tests/compliance_tests.rs @@ -65,6 +65,7 @@ async fn test_passthrough_textile_stores_result() { "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [ {"fibre": "wool", "pct": 100.0} ], diff --git a/crates/dpp-vault/tests/evidence_api.rs b/crates/dpp-vault/tests/evidence_api.rs new file mode 100644 index 0000000..0f7cf92 --- /dev/null +++ b/crates/dpp-vault/tests/evidence_api.rs @@ -0,0 +1,185 @@ +//! Integration test: the evidence-dossier HTTP API (generate, list, fetch, +//! verify) against a real PostgreSQL container. +//! +//! `MockIdentity` (see `helpers::start_vault`) produces a non-cryptographic +//! fake JWS, so these tests assert the API's *shape* and status codes, not +//! `report.all_verified()` — the Tier-1 `evidence_dossier.rs` suite covers +//! genuine cryptographic verification with real Ed25519 signing. + +#![cfg(feature = "integration-tests")] + +mod helpers; + +use dpp_types::STANDALONE_OPERATOR_ID; +use helpers::{TestClient, make_jwt, seed_complete_operator, start_postgres, start_vault}; + +fn create_body() -> serde_json::Value { + serde_json::json!({ + "productName": "Evidence API Widget", + "productCategory": "BATTERY", + "manufacturer": {"name": "Evidence API Inc", "address": "Berlin, DE"}, + "materials": [{"name": "Nickel", "weightKg": 0.5}], + "schemaVersion": "1.0.0", + "sectorData": { + "sector": "battery", + "gtin": "09506000134352", + "batteryChemistry": "NiMH", + "nominalVoltageV": 12.0, + "nominalCapacityAh": 40.0, + "expectedLifetimeCycles": 1000, + "co2ePerUnitKg": 20.0 + } + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn generate_evidence_for_a_draft_passport_is_conflict() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + seed_complete_operator(&pg.dal).await; + let token = make_jwt(STANDALONE_OPERATOR_ID); + let client = TestClient::new(&vault_url, &token); + + let resp = client.post_json("/api/v1/dpp", create_body()).await; + assert_eq!(resp.status(), 201, "create should return 201"); + let passport: serde_json::Value = resp.json().await.unwrap(); + let id = passport["id"].as_str().unwrap().to_owned(); + + let resp = client + .post_json(&format!("/api/v1/dpp/{id}/evidence"), serde_json::json!({})) + .await; + assert_eq!( + resp.status(), + 409, + "generating evidence for a draft passport must be a conflict" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn generate_list_fetch_and_verify_round_trip() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + seed_complete_operator(&pg.dal).await; + let token = make_jwt(STANDALONE_OPERATOR_ID); + let client = TestClient::new(&vault_url, &token); + + let resp = client.post_json("/api/v1/dpp", create_body()).await; + assert_eq!(resp.status(), 201); + let passport: serde_json::Value = resp.json().await.unwrap(); + let id = passport["id"].as_str().unwrap().to_owned(); + + let resp = client + .post_json(&format!("/api/v1/dpp/{id}/publish"), serde_json::json!({})) + .await; + assert_eq!(resp.status(), 200, "publish should return 200"); + + // POST generate — 201, envelope carries id/passportId/actor/docHash/dossier. + let resp = client + .post_json(&format!("/api/v1/dpp/{id}/evidence"), serde_json::json!({})) + .await; + assert_eq!(resp.status(), 201, "generate evidence should return 201"); + let record: serde_json::Value = resp.json().await.unwrap(); + let dossier_id = record["id"].as_str().unwrap().to_owned(); + assert_eq!(record["passportId"].as_str().unwrap(), id); + assert!(record["docHash"].as_str().is_some()); + assert!(record["dossier"]["manifest"].is_object()); + + // GET list — one summary, no document body. + let resp = client.get(&format!("/api/v1/dpp/{id}/evidence")).await; + assert_eq!(resp.status(), 200, "list evidence should return 200"); + let summaries: Vec = resp.json().await.unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0]["id"].as_str().unwrap(), dossier_id); + assert!( + summaries[0].get("dossier").is_none(), + "summaries must not carry the full document" + ); + + // GET the stored document by dossier id. + let resp = client.get(&format!("/api/v1/evidence/{dossier_id}")).await; + assert_eq!(resp.status(), 200, "get evidence should return 200"); + let dossier: serde_json::Value = resp.json().await.unwrap(); + assert!(dossier["manifest"].is_object()); + assert_eq!(dossier["manifest"]["passportId"].as_str().unwrap(), id); + + // POST verify (stored) — 200 with a well-shaped report, regardless of + // whether MockIdentity's fake JWS passes every check. + let resp = client + .post_json( + &format!("/api/v1/evidence/{dossier_id}/verify"), + serde_json::json!({}), + ) + .await; + assert_eq!( + resp.status(), + 200, + "verify stored dossier should return 200" + ); + let report: serde_json::Value = resp.json().await.unwrap(); + assert!(report["trustAnchorNote"].as_str().is_some()); + let checks = report["checks"].as_array().expect("checks array"); + assert!(!checks.is_empty()); + assert!( + checks + .iter() + .any(|c| c["name"].as_str() == Some("audit_chain")), + "expected an audit_chain check in the report" + ); + + // POST verify (uploaded) with the same document — 200, same shape. + let resp = client.post_json("/api/v1/evidence/verify", dossier).await; + assert_eq!( + resp.status(), + 200, + "verify uploaded dossier should return 200" + ); + let uploaded_report: serde_json::Value = resp.json().await.unwrap(); + assert!(uploaded_report["checks"].as_array().is_some()); + + // POST verify (uploaded) with garbage — 422. + let resp = client + .post_json( + "/api/v1/evidence/verify", + serde_json::json!({"not": "a dossier"}), + ) + .await; + assert_eq!( + resp.status(), + 422, + "verifying a non-dossier document must be a hard error" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn evidence_endpoints_404_for_unknown_ids() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + let token = make_jwt(STANDALONE_OPERATOR_ID); + let client = TestClient::new(&vault_url, &token); + + let resp = client + .post_json( + "/api/v1/dpp/00000000-0000-0000-0000-0000000000ff/evidence", + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 404, "generate for unknown passport is 404"); + + let resp = client + .get("/api/v1/dpp/00000000-0000-0000-0000-0000000000ff/evidence") + .await; + assert_eq!(resp.status(), 404, "list for unknown passport is 404"); + + let resp = client + .get("/api/v1/evidence/00000000-0000-0000-0000-0000000000ff") + .await; + assert_eq!(resp.status(), 404, "get for unknown dossier is 404"); + + let resp = client + .post_json( + "/api/v1/evidence/00000000-0000-0000-0000-0000000000ff/verify", + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 404, "verify for unknown dossier is 404"); +} diff --git a/crates/dpp-vault/tests/evidence_export.rs b/crates/dpp-vault/tests/evidence_dossier.rs similarity index 68% rename from crates/dpp-vault/tests/evidence_export.rs rename to crates/dpp-vault/tests/evidence_dossier.rs index 184cba6..28b0720 100644 --- a/crates/dpp-vault/tests/evidence_export.rs +++ b/crates/dpp-vault/tests/evidence_dossier.rs @@ -1,5 +1,5 @@ -//! N02 round-trip: publish -> transfer -> declare EOL -> export the evidence -//! dossier -> verify it fully offline via `dpp_evidence::verify_dossier_json`. +//! Round-trip: publish -> transfer -> declare EOL -> generate + persist the +//! evidence dossier -> verify it via `PassportService::verify_evidence`. //! //! Uses real Ed25519 signing (`dpp_crypto::LocalIdentityService`, backed by a //! throwaway on-disk keystore) and small in-memory port implementations — @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use chrono::Utc; +use uuid::Uuid; use dpp_domain::{ DppError, GhostArchive, GhostRegistrySync, @@ -31,6 +32,9 @@ use dpp_types::{ api_key::ApiKeyScope, audit::{AuditEntry, AuditRepository, GENESIS_PREV_HASH}, auth::AuthContext, + evidence::{ + EvidenceDossierRecord, EvidenceDossierRepository, EvidenceDossierSummary, content_hash, + }, transfer::TransferStore, }; use dpp_vault::domain::service::PassportService; @@ -161,6 +165,63 @@ impl TransferStore for InMemoryTransferStore { } } +/// In-memory `EvidenceDossierRepository` — append-only in spirit (nothing +/// here exposes an update path), mirroring `PgEvidenceDossierRepo`'s shape. +#[derive(Default)] +struct InMemoryEvidenceRepo { + records: Mutex>, +} + +impl InMemoryEvidenceRepo { + /// Test-only hook: overwrite a stored record in place to simulate a + /// tampered row (the DB has no such path — this stands in for "what if + /// storage returned altered bytes"). + fn replace(&self, record: EvidenceDossierRecord) { + let mut records = self.records.lock().unwrap(); + if let Some(slot) = records.iter_mut().find(|r| r.id == record.id) { + *slot = record; + } + } +} + +#[async_trait] +impl EvidenceDossierRepository for InMemoryEvidenceRepo { + async fn insert(&self, record: &EvidenceDossierRecord) -> Result<(), DppError> { + self.records.lock().unwrap().push(record.clone()); + Ok(()) + } + async fn list_by_passport( + &self, + passport_id: PassportId, + ) -> Result, DppError> { + let mut summaries: Vec = self + .records + .lock() + .unwrap() + .iter() + .filter(|r| r.passport_id == passport_id) + .map(|r| EvidenceDossierSummary { + id: r.id, + passport_id: r.passport_id, + actor: r.actor.clone(), + created_at: r.created_at, + doc_hash: r.doc_hash.clone(), + }) + .collect(); + summaries.sort_by_key(|s| std::cmp::Reverse(s.created_at)); + Ok(summaries) + } + async fn get(&self, id: Uuid) -> Result, DppError> { + Ok(self + .records + .lock() + .unwrap() + .iter() + .find(|r| r.id == id) + .cloned()) + } +} + // --------------------------------------------------------------------------- // Harness // --------------------------------------------------------------------------- @@ -176,7 +237,7 @@ fn auth() -> AuthContext { /// Builds a `PassportService` wired with real Ed25519 signing and in-memory /// ports, plus the DID the identity's did:web document actually publishes as /// (pathless form — see `dpp_crypto::identity::did_builder`). -async fn build_service() -> (PassportService, String) { +async fn build_service() -> (PassportService, Arc, String) { let key_path = std::env::temp_dir().join(format!("evidence-test-{}.json", uuid::Uuid::new_v4())); let store = @@ -190,6 +251,8 @@ async fn build_service() -> (PassportService, String) { base_url, )); + let evidence_store = Arc::new(InMemoryEvidenceRepo::default()); + let service = PassportService::new( Arc::new(InMemoryPassportRepo::default()), identity, @@ -200,16 +263,17 @@ async fn build_service() -> (PassportService, String) { Arc::new(GhostArchive), "DE".to_owned(), ) - .with_transfer_store(Arc::new(InMemoryTransferStore::default())); + .with_transfer_store(Arc::new(InMemoryTransferStore::default())) + .with_evidence_store(evidence_store.clone()); - (service, issuer_did) + (service, evidence_store, issuer_did) } fn draft_passport() -> Passport { Passport { id: PassportId::new(), batch_id: None, - product_name: "Evidence Export Test Widget".into(), + product_name: "Evidence Dossier Test Widget".into(), sector: Sector::Textile, product_category: None, manufacturer: ManufacturerInfo { @@ -221,6 +285,7 @@ fn draft_passport() -> Passport { co2e_per_unit: None, repairability_score: None, compliance_result: None, + lint_result: None, sector_data: None, status: PassportStatus::Draft, qr_code_url: None, @@ -251,12 +316,28 @@ fn draft_passport() -> Passport { } // --------------------------------------------------------------------------- -// The test +// The tests // --------------------------------------------------------------------------- #[tokio::test] -async fn publish_transfer_eol_then_export_verifies_fully_offline() { - let (service, issuer_did) = build_service().await; +async fn generate_evidence_fails_for_a_draft_passport() { + let (service, _evidence, _issuer_did) = build_service().await; + let auth = auth(); + let created = service + .create(draft_passport(), &auth) + .await + .expect("create"); + + let err = service + .generate_evidence(created.id, &auth) + .await + .expect_err("draft passport has no signature to export"); + assert!(matches!(err, DppError::Validation(_))); +} + +#[tokio::test] +async fn publish_transfer_eol_then_generate_verifies_and_persists() { + let (service, evidence, issuer_did) = build_service().await; let auth = auth(); let created = service @@ -285,7 +366,7 @@ async fn publish_transfer_eol_then_export_verifies_fully_offline() { operator("From Operator"), operator("To Operator"), TransferReason::Sale, - Some("evidence export test".into()), + Some("evidence dossier test".into()), &auth, ) .await @@ -306,12 +387,21 @@ async fn publish_transfer_eol_then_export_verifies_fully_offline() { .await .expect("declare eol"); - // Export the dossier and verify it fully offline. - let dossier = service - .export_evidence(published.id) + // Generate the dossier and persist it. + let record = service + .generate_evidence(published.id, &auth) .await - .expect("export evidence"); + .expect("generate evidence"); + + assert_eq!(record.actor, "evidence-test"); + assert_eq!(record.passport_id, published.id); + let recomputed = content_hash(&serde_json::to_value(&record.dossier).unwrap()); + assert_eq!( + record.doc_hash, recomputed, + "stored doc_hash must match a fresh recomputation over the stored dossier" + ); + let dossier = &record.dossier; assert_eq!( dossier.transfer_chain.as_ref().map(|c| c.transfers.len()), Some(1) @@ -326,36 +416,28 @@ async fn publish_transfer_eol_then_export_verifies_fully_offline() { "checkpoint is always absent in v1" ); - let dossier_bytes = serde_json::to_vec(&dossier).unwrap(); - let report = - dpp_evidence::verify_dossier_json(&dossier_bytes, dpp_evidence::VerifyMode::Embedded) - .expect("a freshly exported dossier must at least parse"); + // Verify the stored dossier — must come back clean. + let report = service + .verify_evidence(record.id) + .await + .expect("verify freshly generated dossier"); assert!( report.all_verified(), "clean dossier must verify: {report:#?}" ); assert_eq!(report.exit_code(), 0); - // Determinism: exporting again (no new events in between) differs only - // in the manifest's own timestamp/signature, not in the underlying - // members. - let dossier2 = service - .export_evidence(published.id) + // Tamper: flip one byte in the stored record (standing in for storage + // returning altered bytes) and confirm verification names the break + // rather than reporting false-green. + let mut tampered = record.clone(); + tampered.dossier.audit_entries[0].action = "tampered".into(); + evidence.replace(tampered); + + let tampered_report = service + .verify_evidence(record.id) .await - .expect("export evidence again"); - assert_eq!(dossier.full_view.payload, dossier2.full_view.payload); - assert_eq!(dossier.audit_entries.len(), dossier2.audit_entries.len()); - - // Tamper: flip one byte in a stored audit row (round-tripped through - // JSON, exactly as a real consumer of the exported file would see it) - // and confirm the verifier names the break rather than reporting - // false-green. - let mut tampered = serde_json::to_value(&dossier).unwrap(); - tampered["auditEntries"][0]["action"] = serde_json::json!("tampered"); - let tampered_bytes = serde_json::to_vec(&tampered).unwrap(); - let tampered_report = - dpp_evidence::verify_dossier_json(&tampered_bytes, dpp_evidence::VerifyMode::Embedded) - .expect("a tampered-but-structurally-valid dossier must still parse"); + .expect("a tampered-but-structurally-valid dossier must still parse"); assert!( !tampered_report.all_verified(), "tampered audit row must be detected" @@ -367,6 +449,21 @@ async fn publish_transfer_eol_then_export_verifies_fully_offline() { .unwrap(); assert!(matches!( audit_check.status, - dpp_evidence::CheckStatus::Fail(_) + dpp_types::evidence::CheckStatus::Fail(_) )); + + // Generating a second dossier and listing must return both, newest first. + // A short sleep avoids a `created_at` tie with `record` under fast clocks. + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let record2 = service + .generate_evidence(published.id, &auth) + .await + .expect("generate a second dossier"); + let summaries = service + .list_evidence(published.id) + .await + .expect("list evidence"); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].id, record2.id, "newest dossier must be first"); + assert_eq!(summaries[1].id, record.id); } diff --git a/crates/dpp-vault/tests/helpers/mod.rs b/crates/dpp-vault/tests/helpers/mod.rs index 21b6155..c2b660f 100644 --- a/crates/dpp-vault/tests/helpers/mod.rs +++ b/crates/dpp-vault/tests/helpers/mod.rs @@ -18,8 +18,8 @@ use testcontainers::{ }; use dpp_dal::pg::{ - PgApiKeyRepo, PgAuditRepo, PgDal, PgOperatorConfigRepo, PgPassportRepo, PgRegistryIdentityRepo, - sqlx, + PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo, + PgRegistryIdentityRepo, sqlx, }; use dpp_domain::{ DppError, GhostArchive, GhostRegistrySync, @@ -279,7 +279,8 @@ async fn start_vault_with_identity(dal: PgDal, identity: Arc) Arc::new(GhostArchive), String::new(), ) - .with_registry_reader(operator_repo.clone()), + .with_registry_reader(operator_repo.clone()) + .with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))), ); let operator_service = Arc::new(OperatorService::new(operator_repo)); let api_key_service = Arc::new(ApiKeyService::new(api_key_repo)); diff --git a/crates/dpp-vault/tests/lint_findings.rs b/crates/dpp-vault/tests/lint_findings.rs new file mode 100644 index 0000000..5b14d1d --- /dev/null +++ b/crates/dpp-vault/tests/lint_findings.rs @@ -0,0 +1,140 @@ +//! Integration test for plausibility-lint findings: computed at create, +//! surfaced in the API response, never gate publish, and refreshable on +//! demand via `POST /dpp/{dppId}/lint` — even on an already-published DPP. + +#![cfg(feature = "integration-tests")] + +mod helpers; + +use helpers::{TestClient, make_jwt, seed_complete_operator, start_postgres, start_vault}; + +#[tokio::test(flavor = "multi_thread")] +async fn lint_findings_surface_and_never_block_publish() { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + seed_complete_operator(&pg.dal).await; + let token = make_jwt("00000000-0000-0000-0000-000000000006"); + let client = TestClient::new(&vault_url, &token); + + // ratedEnergyWh (500.0) is wildly inconsistent with nominalVoltageV * + // nominalCapacityAh (3.7 * 10.0 = 37.0) — deliberately triggers + // battery.energy_capacity_mismatch. + let body = serde_json::json!({ + "productName": "Lint Test Battery", + "productCategory": "BATTERY", + "manufacturer": {"name": "Lint Inc", "address": "Test City"}, + "materials": [{"name": "Lithium", "weightKg": 1.2}], + "schemaVersion": "2.0.0", + "sectorData": { + "sector": "battery", + "gtin": "09506000134352", + "batteryChemistry": "LFP", + "nominalVoltageV": 3.7, + "nominalCapacityAh": 10.0, + "expectedLifetimeCycles": 500, + "co2ePerUnitKg": 5.0, + "ratedEnergyWh": 500.0 + } + }); + + let resp = client.post_json("/api/v1/dpp", body).await; + assert_eq!(resp.status(), 201); + let passport: serde_json::Value = resp.json().await.unwrap(); + let id = passport["id"].as_str().unwrap(); + + // 1. Findings surface on create, tagged with the pack version. + assert_eq!(passport["lintResult"]["packVersion"], "1.0.0"); + let findings = passport["lintResult"]["findings"] + .as_array() + .expect("findings array"); + assert!( + findings + .iter() + .any(|f| f["code"] == "battery.energy_capacity_mismatch"), + "expected energy/capacity mismatch finding, got: {findings:?}" + ); + + // 2. GET reflects the same findings. + let resp = client.get(&format!("/api/v1/dpp/{id}")).await; + assert_eq!(resp.status(), 200); + let fetched: serde_json::Value = resp.json().await.unwrap(); + assert!( + fetched["lintResult"]["findings"] + .as_array() + .unwrap() + .iter() + .any(|f| f["code"] == "battery.energy_capacity_mismatch") + ); + + // 3. Publish succeeds despite the findings — the non-blocking assertion. + let resp = client + .post_json(&format!("/api/v1/dpp/{id}/publish"), serde_json::json!({})) + .await; + assert_eq!( + resp.status(), + 200, + "publish must succeed with only advisory lint findings present" + ); + let published: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(published["status"], "active"); + assert!( + published["lintResult"]["findings"] + .as_array() + .unwrap() + .iter() + .any(|f| f["code"] == "battery.energy_capacity_mismatch"), + "findings survive publish" + ); + + // 4. POST /dpp/{id}/lint re-checks even an already-published passport. + let resp = client + .post_json(&format!("/api/v1/dpp/{id}/lint"), serde_json::json!({})) + .await; + assert_eq!(resp.status(), 200); + let relinted: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(relinted["status"], "active"); + assert_eq!(relinted["lintResult"]["packVersion"], "1.0.0"); + assert!( + relinted["lintResult"]["findings"] + .as_array() + .unwrap() + .iter() + .any(|f| f["code"] == "battery.energy_capacity_mismatch") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn clean_sector_data_produces_no_findings() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + seed_complete_operator(&pg.dal).await; + let token = make_jwt("00000000-0000-0000-0000-000000000007"); + let client = TestClient::new(&vault_url, &token); + + let body = serde_json::json!({ + "productName": "Clean Battery", + "productCategory": "BATTERY", + "manufacturer": {"name": "Clean Inc", "address": "Test City"}, + "materials": [{"name": "Lithium", "weightKg": 1.2}], + "schemaVersion": "2.0.0", + "sectorData": { + "sector": "battery", + "gtin": "09506000134352", + "batteryChemistry": "LFP", + "nominalVoltageV": 3.7, + "nominalCapacityAh": 10.0, + "expectedLifetimeCycles": 500, + "co2ePerUnitKg": 5.0 + } + }); + + let resp = client.post_json("/api/v1/dpp", body).await; + assert_eq!(resp.status(), 201); + let passport: serde_json::Value = resp.json().await.unwrap(); + + assert_eq!(passport["lintResult"]["packVersion"], "1.0.0"); + // `findings` is omitted entirely (skip_serializing_if = "Vec::is_empty") + // when there are none — absent, not an empty array. + assert!(passport["lintResult"]["findings"].is_null()); +} diff --git a/crates/dpp-vault/tests/textile.rs b/crates/dpp-vault/tests/textile.rs index e859aca..f5ae61a 100644 --- a/crates/dpp-vault/tests/textile.rs +++ b/crates/dpp-vault/tests/textile.rs @@ -28,6 +28,7 @@ async fn test_textile_create_publish_resolve() { "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [ {"fibre": "cotton", "pct": 95.0}, {"fibre": "elastane", "pct": 5.0} diff --git a/crates/dpp-vault/tests/validation_failures.rs b/crates/dpp-vault/tests/validation_failures.rs index 4303962..a424a9f 100644 --- a/crates/dpp-vault/tests/validation_failures.rs +++ b/crates/dpp-vault/tests/validation_failures.rs @@ -62,6 +62,7 @@ async fn test_textile_fibre_sum_invalid() { "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [ {"fibre": "cotton", "pct": 50.0}, {"fibre": "polyester", "pct": 40.0} @@ -148,6 +149,7 @@ async fn test_textile_empty_care_instructions() { "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [ {"fibre": "cotton", "pct": 100.0} ], diff --git a/docs/README.md b/docs/README.md index 892cede..6e46e12 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,4 +24,4 @@ This folder documents **running the product**: services, database, operations, s ## What makes this node different (the 60-second version) -A **production node cannot lie about its trust level** — every trust-critical adapter reports `ghost`/`sandbox`/`live` in `/health`, and `NODE_PROFILE=production` refuses to boot on placeholders. **History is tamper-evident** — audit entries are hash-chained; edits are detected at the exact index. **Registration survives crashes** — EU-registry intent commits in the publish transaction to a durable outbox and drains with backoff. **Regulation arrives as signed data** — compliance rulesets are Ed25519-signed bundles, verified fail-closed, hot-swapped atomically. **Anyone can verify offline** — a signed evidence dossier exports in one call and verifies with zero network through the Apache-licensed `dpp-evidence` crate. Each of these is enforced by tests, not adjectives. +A **production node cannot lie about its trust level** — every trust-critical adapter reports `ghost`/`sandbox`/`live` in `/health`, and `NODE_PROFILE=production` refuses to boot on placeholders. **History is tamper-evident** — audit entries are hash-chained; edits are detected at the exact index. **Registration survives crashes** — EU-registry intent commits in the publish transaction to a durable outbox and drains with backoff. **Regulation arrives as signed data** — compliance rulesets are Ed25519-signed bundles, verified fail-closed, hot-swapped atomically. **Evidence is generated and verified, not just claimed** — a signed dossier is generated and stored in one call, then checked against its own signatures and hash chains via `odal verify` or the evidence API. Each of these is enforced by tests, not adjectives. diff --git a/docs/architecture/EVIDENCE-DOSSIER.md b/docs/architecture/EVIDENCE-DOSSIER.md new file mode 100644 index 0000000..a4a173c --- /dev/null +++ b/docs/architecture/EVIDENCE-DOSSIER.md @@ -0,0 +1,80 @@ +# Evidence Dossier — format v1 + +The evidence dossier is a self-contained, signed snapshot of a passport's full +proof chain. It is generated and persisted by `POST /dpp/{id}/evidence`, listed +and fetched via `GET /dpp/{id}/evidence` / `GET /evidence/{id}`, and verified — +either a stored dossier (`POST /evidence/{id}/verify`) or an uploaded document +(`POST /evidence/verify`) — by the checks in `dpp-vault`'s `domain::verify` +module. The `odal verify ` CLI command drives the same two +verify endpoints. + +`format_version` is currently `"1"`. This document describes that version. + +## Members + +| Field | Type | Meaning | +|---|---|---| +| `manifest` | `DossierManifest` | Signed metadata binding every other member into one atomic unit. | +| `manifestJws` | string | Compact EdDSA JWS over the JCS-canonical bytes of `manifest`. | +| `fullView` | `SignedLayer` | The full (non-redacted) passport payload as it was actually signed, plus that signature. | +| `publicView` | `SignedLayer` | The public (redacted) passport payload as it was actually signed, plus that signature. | +| `didDocuments` | map\ | Snapshot of every DID document needed to verify every signature in the dossier — the issuer's own, plus any transfer-chain counterparties'. | +| `auditEntries` | `AuditEntry[]` | The full hash-chained audit trail, oldest first. | +| `transferChain` | `TransferChain?` | Present iff the passport has ever changed responsible operator. | +| `eolEvent` | object? | Present iff the passport was declared end-of-life. | +| `checkpoint` | object? | **Always `null` in v1** — the signed-checkpoint layer is not yet built. | +| `calcReceipts` | array | **Always `[]` in v1** — `dpp-calc` invocation is not yet wired end to end (pending a licensed emission-factor data source). | + +### `DossierManifest` + +| Field | Meaning | +|---|---| +| `formatVersion` | `"1"`. | +| `passportId` | The passport this dossier is for. | +| `issuerDid` | The `did:web` DID that signed `manifest`, `fullView`, and `publicView` — the node operator's own identity. Transfer-chain signatures carry their own signer DIDs on each record instead. | +| `createdAt` | When this dossier was assembled. | +| `nodeVersion` | The engine version that produced it. | +| `rulesetVersion` | The `dpp-calc` ruleset version, when a determination ran. Omitted for passthrough-only passports. | +| `contentHashes` | map\ — see below. | + +### `SignedLayer` + +`{ payload, jws }` — the exact JSON value that was signed, alongside the JWS over it. The dossier embeds this pair directly rather than have a verifier reconstruct "what should have been signed": engine-side transforms (e.g. the full-view payload forces `status` to `"active"`; the public-view payload is redacted) are engine-internal, and reconstructing them independently is both extra surface for the two sides to disagree on and, in practice, was found to be a source of a real bug (see "Why `SignedLayer` embeds the payload" below). + +## Why the manifest hashes every member (`contentHashes`) + +Each member is independently verifiable on its own — its own JWS, or its own hash chain. That is not sufficient by itself: without a binding mechanism, an attacker could mix genuinely-signed-but-stale members from two different generations of the *same* passport — for example, pairing a current, validly-signed `fullView` with an older `auditEntries` array that omits a later suspension event. Each individual signature would still verify. + +`manifest.contentHashes` is a map from member name to the hex SHA-256 of that member's JCS-canonical bytes. Because the manifest itself is signed (`manifestJws`), tampering with any member's content — even by swapping in another genuinely-signed artifact — is caught: the recomputed hash won't match what the signed manifest commits to. + +## Why `SignedLayer` embeds the payload + +An earlier version of this format had the verifier reconstruct the full/public view payloads from the passport record itself. This was found to be unreliable: `jws_signature`/`public_jws_signature` are frozen at publish time, but a passport's `status` (and other fields) mutate afterward on suspend/archive/end-of-life — those transitions never re-sign. A verifier reconstructing "the current record" would produce a payload that no longer matches what was actually signed, and falsely report tamper on a perfectly legitimate, unmodified signature. Embedding the exact signed payload sidesteps this: verification only has to confirm the signature covers *this* payload, never derive what the payload should be. + +## Trust model + +Verification is an integrity check of a dossier — stored or uploaded — against its own signatures and hash chains. A green report proves: no member was altered since the dossier was generated; every signature covers exactly the payload it's paired with; the audit trail's hash chain is unbroken; every present transfer-chain signature is valid; the whole bundle is atomically bound (no mix-and-match). + +Trust is anchored to the DID documents embedded in the dossier at generation time — the report's `trustAnchorNote` states this and the snapshot date explicitly. This is honest, not exhaustive: it does not independently confirm those DID documents are still the operator's *real*, current keys, and running the check through this node's own API does not make the node a disinterested third party. + +What it does not prove, in any case: that the *issuing* operator didn't rewrite their own audit history before generating the dossier. An operator who regenerates their entire hash chain from genesis with altered content produces a dossier that is internally, perfectly self-consistent. Closing this requires an independent observation of the chain head at an earlier point in time — the signed-checkpoint layer (`checkpoint`, currently always `null`) exists for exactly this and is not yet built. Anyone relying on a dossier for a high-stakes claim should know the honest line: *tamper-evident against everyone except the issuer, until checkpoints ship.* + +## Strictness + +Every dossier-owned type (`DossierV1`, `DossierManifest`, `SignedLayer`, `AuditEntry`) rejects unknown fields at deserialization. An unrecognised field anywhere in one of these types is treated as malformed input (`odal verify` exit code 2 / HTTP 422 from the verify endpoints), not silently ignored — it may mean the dossier was produced by a newer format version this verifier doesn't understand, and a verifier must never silently pass over content it didn't check. + +This alone is not sufficient: `didDocuments`, `checkpoint`, and `calcReceipts` are untyped JSON by design (forward-compatible placeholders), and `transferChain` embeds `dpp-domain` types (`TransferChain`, `TransferRecord`, `ResponsibleOperator`) that are *not* strict, because they are core domain types shared with contexts where tolerance is correct. An unknown field nested inside one of those would parse successfully and be silently dropped by serde — and because content hashes are computed from the *parsed* structure, the dropped content would not be reflected in a hash mismatch either. + +`verify_dossier_json` closes this gap with a 9th check, **`input_fidelity`**: it compares the canonical (JCS) bytes of the raw input against the canonical bytes of the parsed dossier, re-serialized. Any content lost anywhere in the tree — not just in the strict types — fails this check. + +**Corollary rule: optional fields are omitted, never emitted as explicit `null`.** Every optional field in this format uses `skip_serializing_if`, so a value that's genuinely absent doesn't appear in the JSON at all. This is what makes the fidelity round-trip exact — an explicit `"field": null` versus an omitted field would otherwise be indistinguishable content that still produces different canonical bytes. + +## Versioning + +A breaking change to this format bumps `format_version`. A verifier that doesn't recognise a `format_version` (or, per the strictness rule above, encounters any field it doesn't understand) must refuse the dossier as malformed rather than attempt a best-effort partial verification. An honest refusal ("this dossier may require a newer verifier") is always the correct failure mode — never a false green. + +## See also + +- [`evidence-dossier-v1.schema.json`](evidence-dossier-v1.schema.json) — machine-readable JSON Schema for this format. +- `crates/dpp-vault/src/domain/verify/engine.rs` — the reference implementation of every check described above. +- `crates/dpp-types/src/evidence.rs` — the wire format and persistence types. diff --git a/docs/architecture/evidence-dossier-v1.schema.json b/docs/architecture/evidence-dossier-v1.schema.json new file mode 100644 index 0000000..ba11250 --- /dev/null +++ b/docs/architecture/evidence-dossier-v1.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.odal-node.io/evidence/dossier-v1.schema.json", + "title": "Odal Node Evidence Dossier v1", + "description": "A self-contained, signed snapshot of a passport's full proof chain — see EVIDENCE-DOSSIER.md for the full specification, trust model, and rationale.", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "manifestJws", "fullView", "publicView", "didDocuments", "auditEntries"], + "properties": { + "manifest": { "$ref": "#/$defs/dossierManifest" }, + "manifestJws": { "$ref": "#/$defs/compactJws" }, + "fullView": { "$ref": "#/$defs/signedLayer" }, + "publicView": { "$ref": "#/$defs/signedLayer" }, + "didDocuments": { + "type": "object", + "description": "Keyed by DID. Always contains at least manifest.issuerDid.", + "additionalProperties": { "type": "object" } + }, + "auditEntries": { + "type": "array", + "description": "The full hash-chained audit trail, ascending timestamp order.", + "items": { "$ref": "#/$defs/auditEntry" } + }, + "transferChain": { + "description": "Present iff the passport has ever changed responsible operator. A dpp-domain core type — not strict here; see EVIDENCE-DOSSIER.md's Strictness section on why input_fidelity exists.", + "type": ["object", "null"] + }, + "eolEvent": { + "description": "Present iff the passport was declared end-of-life.", + "type": ["object", "null"] + }, + "checkpoint": { + "description": "Always null in v1 — the signed-checkpoint layer is not yet built. Present as a field for forward compatibility.", + "type": "null" + }, + "calcReceipts": { + "description": "Always empty in v1 — dpp-calc invocation is not yet wired end to end.", + "type": "array", + "maxItems": 0 + } + }, + "$defs": { + "compactJws": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$", + "description": "Compact EdDSA JWS: base64url(header).base64url(JCS-canonical payload).base64url(signature)." + }, + "dossierManifest": { + "type": "object", + "additionalProperties": false, + "required": ["formatVersion", "passportId", "issuerDid", "createdAt", "nodeVersion", "contentHashes"], + "properties": { + "formatVersion": { "const": "1" }, + "passportId": { "type": "string" }, + "issuerDid": { "type": "string", "pattern": "^did:web:" }, + "createdAt": { "type": "string", "format": "date-time" }, + "nodeVersion": { "type": "string" }, + "rulesetVersion": { "type": "string" }, + "contentHashes": { + "type": "object", + "additionalProperties": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + }, + "signedLayer": { + "type": "object", + "additionalProperties": false, + "required": ["payload", "jws"], + "properties": { + "payload": { "type": "object" }, + "jws": { "$ref": "#/$defs/compactJws" } + } + }, + "auditEntry": { + "type": "object", + "additionalProperties": false, + "required": ["id", "passportId", "actor", "action", "timestamp"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "passportId": { "type": "string" }, + "actor": { "type": "string" }, + "action": { "type": "string" }, + "previousStatus": { "type": ["string", "null"] }, + "newStatus": { "type": ["string", "null"] }, + "metadata": { "type": ["object", "null"] }, + "timestamp": { "type": "string", "format": "date-time" }, + "prevHash": { "type": "string" }, + "entryHash": { "type": "string" } + } + } + } +} diff --git a/docs/project/BLUEPRINT.md b/docs/project/BLUEPRINT.md index 732f7a0..f9f8700 100644 --- a/docs/project/BLUEPRINT.md +++ b/docs/project/BLUEPRINT.md @@ -78,7 +78,7 @@ fails, the operation still succeeds. Consumers must be idempotent. | EU Central Registry sync | **Outbox done**; adapter ghost until spec | Registration intent committed in the publish transaction to the durable `registry_sync` outbox; background drain with backoff; `EuRegistrySync` HTTP adapter activates when the Commission publishes the Art. 13 API | | Unsold goods reporting | Done | ESPR Arts. 24 (disclosure) / 25 (destruction ban, 19 Jul 2026); standardised disclosure format (implementing act of 9 Feb 2026) applies Q1 2027 — conformance pass pending (implementation plan 07) | | End-of-life & transfer of responsibility | Done | Typed EOL declaration; dual-signed transfer handshake (initiate/accept, fail-closed verify); hash-chained audit | -| Evidence dossier export + offline verification | Done | `GET …/dpp/{id}/evidence` + `odal verify` via the Apache `dpp-evidence` crate — third parties verify with zero network | +| Evidence dossier generation + verification | Done | `POST/GET …/dpp/{id}/evidence`, `POST …/evidence/{id}/verify`, `odal verify` | | Trust-mode honesty (`NODE_PROFILE`) | Done | Per-port `trust_mode` in `/health`; production profile refuses to boot on ghost trust adapters | | Signed compliance-ruleset channel | Done (loader) | Ed25519-signed bundles, fail-closed verify, atomic hot-swap; active version in `/health` | | Public passport resolver | Done | Content-negotiated (JSON/HTML) | diff --git a/ops/demo/samples/textile-full.json b/ops/demo/samples/textile-full.json index f8a07c6..24e78a5 100644 --- a/ops/demo/samples/textile-full.json +++ b/ops/demo/samples/textile-full.json @@ -16,6 +16,7 @@ "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", + "gtin": "09506000134352", "fibreComposition": [ { "fibre": "organic cotton", "pct": 60.0, "countryOfOrigin": "IN" }, { "fibre": "recycled polyester", "pct": 40.0, "countryOfOrigin": "TR" } diff --git a/ops/pg/0018_lint_result_mutable.sql b/ops/pg/0018_lint_result_mutable.sql new file mode 100644 index 0000000..0254486 --- /dev/null +++ b/ops/pg/0018_lint_result_mutable.sql @@ -0,0 +1,30 @@ +-- 0018_lint_result_mutable.sql +-- Add `lintResult` to the retention guard's mutable_keys. +-- +-- `lintResult` is advisory only — it never gates +-- publish and is designed to be re-checked on demand via `POST +-- /dpp/{dppId}/lint`, including against an already-published passport. Unlike +-- `complianceResult`, it is not part of the signed payload (the JWS is a +-- frozen signature over whatever `lintResult` looked like at publish time — +-- evidence export reads that frozen snapshot, not the live row), so a +-- retention-locked passport must be allowed to change it. Keep this array in +-- lockstep with `dpp_common::event_codes::MUTABLE_FIELDS` (the dpp-resolver +-- parity test asserts they match). + +CREATE OR REPLACE FUNCTION odal.passport_retention_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + mutable_keys TEXT[] := ARRAY['status','jwsSignature','publicJwsSignature', + 'qrCodeUrl','publishedAt','retentionLocked', + 'updatedAt','lintResult']; +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'ODAL_RETENTION: passports are never deleted (ESPR retention)'; + END IF; + IF OLD.retention_locked + AND (OLD.doc - mutable_keys) IS DISTINCT FROM (NEW.doc - mutable_keys) THEN + RAISE EXCEPTION 'ODAL_RETENTION: retention-locked passport content is immutable'; + END IF; + NEW.updated_at := now(); + RETURN NEW; +END $$; diff --git a/ops/pg/0019_passport_identity_index.sql b/ops/pg/0019_passport_identity_index.sql new file mode 100644 index 0000000..bb5d655 --- /dev/null +++ b/ops/pg/0019_passport_identity_index.sql @@ -0,0 +1,13 @@ +-- ============================================================================ +-- 0019 — passport identity index. Backs the import delta-matcher's exact +-- (sector, gtin, batch) lookup across Draft and Published passports. +-- +-- GTIN is read from doc->'sectorData'->>'gtin', which 9 of 11 sectors +-- populate today (UnsoldGoods and Other carry no gtin field on their typed +-- sector data — a discard-event report and an untyped catch-all); rows for +-- those two sectors are simply never matched by this index, not broken. +-- ============================================================================ + +CREATE INDEX idx_passport_identity ON odal.passport + (sector, (doc->'sectorData'->>'gtin'), (doc->>'batchId')) + WHERE status IN ('draft','active'); diff --git a/ops/pg/0020_import_job_report.sql b/ops/pg/0020_import_job_report.sql new file mode 100644 index 0000000..b7be1a1 --- /dev/null +++ b/ops/pg/0020_import_job_report.sql @@ -0,0 +1,16 @@ +-- ============================================================================ +-- 0020 — import_job gains report and parent_job_id. +-- +-- report: the persisted, row-addressed findings report — every import job, +-- dry-run or apply, now populates this via JobStore::record_report, so the +-- report is retrievable via GET /api/v1/imports/{jobId} instead of only +-- returned synchronously. Independent of the existing `result` column +-- (apply-mode's created/errors bookkeeping). +-- +-- parent_job_id: reserved for a future re-run/apply-from-dry-run flow; not +-- set by any code path yet. +-- ============================================================================ + +ALTER TABLE odal.import_job + ADD COLUMN report JSONB, + ADD COLUMN parent_job_id UUID REFERENCES odal.import_job(id); diff --git a/ops/pg/0021_evidence_dossier.sql b/ops/pg/0021_evidence_dossier.sql new file mode 100644 index 0000000..33c1ef4 --- /dev/null +++ b/ops/pg/0021_evidence_dossier.sql @@ -0,0 +1,28 @@ +-- ============================================================================ +-- 0021 — evidence_dossier: persisted, immutable evidence-dossier snapshots. +-- Doc-style table: query columns + the full DossierV1 (camelCase) in `doc`. +-- Append-only by trigger (same pattern as 0005): a stored snapshot is a fact. +-- ============================================================================ + +CREATE TABLE odal.evidence_dossier ( + id UUID PRIMARY KEY, + passport_id UUID NOT NULL REFERENCES odal.passport(id), + actor TEXT NOT NULL, + doc_hash TEXT NOT NULL, + doc JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_evidence_dossier_passport ON odal.evidence_dossier (passport_id, created_at); + +CREATE FUNCTION odal.evidence_append_only() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'ODAL_EVIDENCE: evidence dossiers are append-only'; +END $$; +CREATE TRIGGER evidence_dossier_immutable + BEFORE UPDATE OR DELETE ON odal.evidence_dossier + FOR EACH ROW EXECUTE FUNCTION odal.evidence_append_only(); + +-- 0010's ALL-TABLES grant was a one-time snapshot; tables added later need +-- their own grant (same pattern as 0017). No UPDATE/DELETE: append-only. +GRANT SELECT, INSERT ON odal.evidence_dossier TO odal_app;