diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6e29fc..8ce1629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: cache: npm - name: Install the committed npm dependency graph run: npm ci --ignore-scripts --no-audit --no-fund - - name: Check all four 0.37 adaptors, event services and compiled jobs + - name: Check all four 0.38 adaptors, event services and compiled jobs run: npm run check - name: Check adaptor package contents run: npm run pack:dry-run @@ -36,13 +36,17 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - name: Build frozen 0.27 images, execute the native worker gate and set up the legacy pilot + - name: Build pinned 0.38 images, execute the native worker gate and set up the isolated pilot # Setup owns the build and suppresses raw upstream subprocess output. run: ./deployment/pilot.sh setup - name: Check private mount ownership run: ./deployment/check-permissions.sh - name: Run registration, governed correction and verified destination acceptance run: python3 pilot/smoke.py --timeout 300 + - name: Run native agriculture authorization and minimized assertion acceptance + run: ./deployment/compose.sh run --rm --no-deps tools python3 /opt/pilot/agriculture/smoke.py + - name: Run native explicit Evidence SDK acceptance + run: ./deployment/compose.sh run --rm --no-deps sdk-tools - name: Verify retained records and exact replay after service restart run: python3 pilot/smoke.py --restart-recovery --timeout 300 - name: Verify delivery retry, failed-step replay and queued-work recovery @@ -51,14 +55,14 @@ jobs: if: failure() shell: bash run: | - if [ -f pilot/agriculture/.runtime/prepared.json ]; then + if [ -f pilot/agriculture/.runtime-0.38/prepared.json ]; then ./deployment/pilot.sh status fi - name: Stop application services and retain isolated test data until runner disposal if: always() shell: bash run: | - if [ -f pilot/agriculture/.runtime/prepared.json ]; then + if [ -f pilot/agriculture/.runtime-0.38/prepared.json ]; then if ! ./deployment/pilot.sh stop >/dev/null 2>&1; then echo 'Pilot stop did not complete. The isolated CI runner will be disposed.' fi diff --git a/.gitignore b/.gitignore index 51a48ee..c55584f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,11 @@ npm-debug.log* .DS_Store coverage/ pilot/agriculture/.runtime/ +pilot/agriculture/.runtime-0.37/ +pilot/agriculture/.runtime-0.38/ deployment/.env +deployment/.env-0.37 +deployment/.env-0.38 deployment/.build/ deployment/.runtime/ __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c271b3..f371bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,16 @@ ### Changed - The Evidence, BREG, Casework and Relay V2 adaptors pin the published - `@registrystack/client` 0.37.0. Relay uses governed V2 resources and native + `@registrystack/client` 0.38.0. Relay uses governed V2 resources and native continuations; Casework uses current review-request bindings and results. - The retained pilot's Registry Stack images, bootstrap tools and separately - locked client remain at 0.27.0. +- The container pilot uses matching Registry Stack 0.38 clients, tools and + packages, ThunderID identity, Casework correction review, and explicit BREG + application. Evidence uses persistent OpenBao Transit signing and local TLS + origins. Its new configuration and volumes are separate from the retained + 0.27 data. +- The event bridge's explicit `hook-envelope-v1` mode validates canonical + 0.38 events and returns the native notification acknowledgement after + durable OpenFn acceptance. The legacy body mode remains available. ### Removed diff --git a/README.md b/README.md index bcd7817..e13fe04 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,16 @@ configuration may change before 1.0. The BREG, Evidence, Relay V2 and Casework adaptors target the published -`@registrystack/client@0.37.0`. Use matching 0.37 runtimes and tools for new +`@registrystack/client@0.38.0`. Use matching 0.38 runtimes and tools for new integrations. Run `npm run check` to verify all four adaptors and their OpenFn composition. Relay and Casework now use their current native APIs; consult their package READMEs when migrating older jobs. -The existing container pilot below is a retained **Registry Stack 0.27.0** -deployment with its own [dependency lock](deployment/package-lock.json). -It demonstrates the older Mint and BREG-owned review contracts. Its build does -not install the current 0.37 client graph. Upgrading that deployment requires -ThunderID, Casework review, and regenerated runtime/package configuration; -changing image tags alone is insufficient. Existing pilot data is preserved. +The container pilot uses matching **Registry Stack 0.38.0** clients, runtimes, +tools and authored packages, ThunderID for identity, and Casework for correction +review. It creates a fresh deployment with separate databases and private +configuration. The previous 0.27 pilot's `pilot/agriculture/.runtime` directory +and `registry-openfn-pilot` volumes are retained and are not migrated or reused. A self-hosted synthetic pilot connecting upstream OpenFn Lightning to Registry Stack Base Registry Engine (BREG) and signed Evidence. It demonstrates registration, @@ -22,16 +21,17 @@ reviewed correction, and an idempotent downstream update containing a verified Boolean. All supplied people, holding names, identifiers and credentials are for an isolated local pilot. -The retained 0.27 pilot uses: +The pilot uses: - [Registry Evidence adaptor](packages/registry-evidence), using the `evidence` - namespace of the published `@registrystack/client@0.27.0`. + namespace of the published `@registrystack/client@0.38.0`. - [Registry BREG adaptor](packages/registry-breg), using the same package's `breg` namespace for metadata-selected writes, exact lookups and lifecycle actions. - Upstream Lightning 2.18.2 and websocket worker 1.29.0, packaged with Node 24.19.0 on glibc and local adaptors. No OpenFn source patch is required. -- Separate BREG and Lightning PostgreSQL databases, Mint, Evidence, an - authenticated event bridge and a small SQLite-backed destination. +- Separate BREG, Casework and Lightning PostgreSQL databases, ThunderID, + Evidence with an OpenBao Transit signer, an authenticated event bridge and a + small SQLite-backed destination. [Image identities](deployment/images.lock.json), the npm lockfile, and bootstrap [tool checksums](deployment/tools.sha256) pin the delivered dependencies. @@ -41,11 +41,12 @@ The retained 0.27 pilot uses: | Workflow | Authoritative result | Response and boundary | | --- | --- | --- | | Registration | Creates one Farm using a stable submission key. | The authenticated synchronous webhook returns its UUID. An exact retry returns the same record; changed content with the same key conflicts. | -| Correction | Creates and submits a name-correction request. | Returns its request UUID. A separate reviewer approves, then explicitly applies it. Submission and approval leave the Farm unchanged. | +| Correction | Creates and submits a name-correction request. | Returns its request UUID. A separate reviewer approves in Casework, then explicitly applies through BREG. Submission and approval leave the Farm unchanged. | | Committed Farm event | Verifies HMAC delivery, requests signed registration Evidence, then updates the destination. | Asynchronous. Only the verified `registered: true`, record reference and revision reach the destination. | -BREG owns records, permissions, review, revisions, ETags and the transactional -outbox. OpenFn orchestrates these contracts. The Evidence source performs an +BREG owns records, permissions, proposals, revisions, ETags and the transactional +outbox. Casework owns review decisions; BREG accepts only the approved result +for the exact submitted proposal. OpenFn orchestrates these contracts. The Evidence source performs an exact authorized Farm lookup and returns only `localIdentifier`; Evidence signs one registration Boolean. The assertion makes no claim about eligibility, ownership, activity or land rights. An unresolved holding produces unavailable @@ -69,7 +70,7 @@ The released Lightning and worker images are `linux/amd64`; Apple Silicon uses Docker emulation. Native arm64 adaptor loading does not establish arm64 worker support. See the [container packaging notes](deployment/README.md) for the glibc worker packaging and architecture limits. Allow enough Docker disk and memory -for two databases and the upstream application builds. +for three databases and the upstream application builds. From this repository: @@ -81,14 +82,15 @@ python3 pilot/smoke.py Setup builds the pinned images, runs the real native adaptor worker check, generates private inputs and Evidence fixtures, initializes the isolated BREG -package, applies upstream Lightning migrations, and provisions the pilot +and Casework packages, initializes the persistent Transit signer, applies +upstream Lightning migrations, and provisions the pilot operator and credentials. Webhooks stay disabled until authentication is attached and checked. A failed step stops setup and identifies the step without printing raw service output. Setup is not a statement that the full acceptance journey passed; the smoke must finish with `"status": "passed"`. -Open [Lightning](http://localhost:4000). The generated login is in -`pilot/agriculture/.runtime/openfn/secrets/operator.json`; open that private file +Open [Lightning](http://localhost:4010). The generated login is in +`pilot/agriculture/.runtime-0.38/openfn/secrets/operator.json`; open that private file locally to read the email and password. Do not paste its contents into tickets, job expressions or logs. There is no shared default password. The same private folder contains the operator API token, separate intake and committed-event keys, @@ -96,9 +98,9 @@ credential associations and `project.json`, which records the actual project and trigger UUIDs. The smoke creates a fresh synthetic identity once and stores it in owner-only -`pilot/agriculture/.runtime/smoke-state.json`. It exercises registration, -unchanged and conflicting retries, correction submission, separate reviewer -approval/application, event-driven Evidence and destination effects. Repeating +`pilot/agriculture/.runtime-0.38/smoke-state.json`. It exercises registration, +unchanged and conflicting retries, correction submission, separate Casework reviewer +approval/BREG application, event-driven Evidence and destination effects. Repeating it reuses its original keys and records. It does not clear data or make a new registration on every run. Direct native HTTP checks are also available in the [agriculture guide](pilot/agriculture/README.md). @@ -106,7 +108,7 @@ registration on every run. Direct native HTTP checks are also available in the ## Forms and manual review The generated project has registration and correction webhook endpoints at -`http://localhost:4000/i/TRIGGER_UUID`. Read their UUIDs from private +`http://localhost:4010/i/TRIGGER_UUID`. Read their UUIDs from private `project.json`, and send the intake `webhook-api-key` in `x-api-key`. Keep that key in the form integration's credential store. Never give form clients the separate `committed-api-key`; it belongs to the authenticated event bridge. Do not place @@ -139,9 +141,11 @@ request UUID, inspect the displayed synthetic proposal, then decide: python3 /opt/pilot/agriculture/review.py apply REQUEST_UUID ``` -The reviewer helper binds the action to its exact inspected proposal version, -effect digest and lifecycle ETag. A stale snapshot is refused. A record ETag is -not a lifecycle ETag. The smoke invokes these explicit steps only for the +The reviewer helper binds the Casework task to its inspected source context and +revision, and the BREG application to its exact proposal version, effect digest +and lifecycle ETag. A stale snapshot is refused. A record ETag is +not a lifecycle ETag. Approval leaves BREG in `submitted` with an approved +external review result; only application moves it to `applied`. The smoke invokes these explicit steps only for the synthetic correction it created and verified. ## Stop, resume and restart recovery @@ -159,15 +163,15 @@ Stop retains named volumes, Registry history and private configuration. Start checks the retained package and reapplies the supported upstream startup path. Restart recovery restarts existing application services and verifies the same records, correction and destination effects. It does not recreate databases or -Mint's shared network namespace. +ThunderID's shared network namespace. Always use `deployment/compose.sh` for lower-level commands. It fixes the Compose -project to `registry-openfn-pilot` and preserves the operator UID/GID in ignored -`deployment/.env`. If the pilot moves to another operator, transfer ownership +project to `registry-openfn-pilot-038` and preserves the operator UID/GID in ignored +`deployment/.env-0.38`. If the pilot moves to another operator, transfer ownership of its retained private files explicitly and update that file. Do not solve a permission failure by making keys world-readable. -Reset is separate and permanently destroys this synthetic pilot's containers, +Reset is separate and permanently destroys only the current 0.38 pilot's containers, volumes, records, history and generated private keys: ```sh @@ -187,7 +191,7 @@ remove a partially activated database to make readiness pass. | Idempotency or ETag conflict | Confirm whether the payload changed or the baseline is stale. Reuse the original payload for an exact replay. For a deliberate new correction, review current state and create a new submission; do not force a write with a stale ETag. | | Already-submitted correction | The workflow rereads the request and verifies its original content before reporting `already_submitted`. A missing action alone is not proof of success. | | Rejected correction | Read the review decision. A repeat of the original submission reports the existing request ID; inspect its current BREG state and decision. It does not resubmit it. Submit a revised proposal with a new submission ID after review, or use a currently advertised revise action through the BREG adaptor. The pilot form does not automatically revise or approve. | -| Authentication failure or expired credentials | Private-key JWT obtains fresh short-lived access tokens through Mint. Check Mint readiness, host clock, client registration and private-key permissions. Correct the OpenFn credential/profile when keys change. Do not paste a temporary access token into job source or rotate unrelated keys during an exact retry. | +| Authentication failure or expired credentials | Private-key JWT obtains fresh short-lived access tokens through ThunderID. Check issuer readiness, host clock, client registration and private-key permissions. Correct the OpenFn credential/profile when keys change. Do not paste a temporary access token into job source or rotate unrelated keys during an exact retry. | | Evidence unavailable, denied or unverifiable | Leave the destination unchanged. Check the authorized exact source lookup, selected requirement, requester credentials and reviewed trust/profile configuration. Unavailable is not a signed negative. Retry the original failed committed-event work order only after the cause is resolved. | | Failed committed-event workflow after webhook acknowledgement | Use Lightning's failed work order and original input for recovery. BREG's successful HTTP handoff does not imply that the asynchronous workflow succeeded. Destination deduplication makes an exact successful effect replay safe. | | Pending or dead-letter BREG delivery | Restore bridge/OpenFn availability, inspect delivery status, then explicitly replay the eligible retained dead letter. Preserve event identity and use the listed generation. Do not edit the outbox or manufacture a new event ID. | @@ -218,16 +222,16 @@ This deliberately interrupts selected services and then restores them. It checks BREG retry/dead-letter replay before OpenFn accepts a delivery, retry of a failed destination step after acceptance, and retained queued work across application restarts. Each scenario keeps a separate synthetic identity in private -`.runtime/recovery-state.json`; it removes no records or volumes. Select +`.runtime-0.38/recovery-state.json`; it removes no records or volumes. Select `before-accept`, `after-accept`, or `pending-restart` instead of `all` for one case. For value-free BREG delivery status and an explicit eligible replay: ```sh ./deployment/compose.sh run --rm --no-deps tools bregctl webhook list \ - --runtime-config /config/breg/runtime.json + --runtime-config /config/breg/runtime.yaml ./deployment/compose.sh run --rm --no-deps tools bregctl webhook replay \ - --runtime-config /config/breg/runtime.json \ + --runtime-config /config/breg/runtime.yaml \ --event-id EVENT_UUID --delivery-id DELIVERY_ID --expected-generation GENERATION ``` @@ -239,7 +243,7 @@ uses the stable event ID, so that change does not duplicate an applied effect. ## Private data and diagnostics This is a loopback-only, supervised local deployment, not a production internet -configuration. Host ports 4000, 4001 and 4002 bind only to loopback. The tools +configuration. Host ports 4010, 4011, 4012 and 4013 bind only to loopback. The tools service has privileged package, database and reviewer material and is an operator surface. Retain access controls on the Docker daemon and host files. diff --git a/deployment/Lightning.Dockerfile b/deployment/Lightning.Dockerfile index ffc7fc2..d1465ff 100644 --- a/deployment/Lightning.Dockerfile +++ b/deployment/Lightning.Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 ARG LIGHTNING_IMAGE=openfn/lightning:v2.18.2@sha256:a2327173ece7b5ac4904744acf63f928dd2cbe496788d31266060e85bd359203 -ARG ADAPTORS_IMAGE=registry-openfn-worker:pilot +ARG ADAPTORS_IMAGE=registry-openfn-worker:pilot-038 FROM ${ADAPTORS_IMAGE} AS adaptors FROM ${LIGHTNING_IMAGE} COPY --from=adaptors --chown=lightning:root /opt/registry-adaptors /opt/registry-adaptors diff --git a/deployment/README.md b/deployment/README.md index 8157774..4016b38 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -1,106 +1,116 @@ # Pilot container packaging -This is the retained Registry Stack **0.27.0** pilot. The repository's current -adaptors target **0.37.0**; this deployment intentionally installs its separate -`deployment/package-lock.json` so rebuilding it cannot introduce client/runtime -version skew. It is not a 0.37 deployment. A new deployment needs ThunderID in -place of Mint, Casework review, and regenerated BREG/Evidence configuration and -packages. Preserve retained state and follow the product upgrade procedure; -do not repin images against the existing configuration. +This directory deploys matching Registry Stack **0.38.0** clients, tools, +runtimes and authored packages. ThunderID provides identity, Casework owns +review decisions, and BREG owns authoritative records and explicit application. +Evidence uses a persistent OpenBao Transit key through an official Agent Unix +proxy. Upstream Lightning 2.18.2 and websocket worker 1.29.0 remain unchanged. +Image identities and tool checksums are recorded in `images.lock.json` and +`tools.sha256`. -This directory packages upstream Lightning 2.18.2 and websocket worker 1.29.0 -with the local Evidence and BREG adaptors. Upstream and -Registry Stack image identities are recorded in `images.lock.json`. +The current deployment is a fresh synthetic pilot. Its fixed Compose project is +`registry-openfn-pilot-038`, and its private configuration is under +`pilot/agriculture/.runtime-0.38`. The previous `registry-openfn-pilot` volumes and +`pilot/agriculture/.runtime` directory are not reused, migrated or removed. +Keep the matching legacy checkout at commit `65d988a` if that pilot must be +operated again. A retained-record upgrade needs the product's staged migration +procedure; running current tools against a 0.27 database is not that procedure. -Run `./deployment/build-images.sh` from the repository using the frozen deployment -lock. It builds the worker first and compiles the exact four generated -Lightning job bodies with upstream adaptor export preloading and automatic -imports. The BREG and Evidence jobs must reach their own input validation, and -the destination job executes against a controlled loopback responder. The check -also proves credentials remain available between operations and disappear at -the normal job boundary. -The released engine's real child-process handshake is checked separately under -the image's configured UID and GID, with no network access. -It then builds Lightning, the event services, PostgreSQL TLS setup, and the -checksum-verified Registry Stack 0.27.0 bootstrap tools. -The PostgreSQL check initializes an isolated database from synthetic files owned -by UID1001 with mode 0600, proving that its runtime user can read the staged SQL -without relaxing the operator's file permissions. It mounts no pilot data. -Bootstrap packages come from the signed Debian snapshot dated August 24, 2026, -matching the pinned PostgreSQL base image. +Run `./deployment/pilot.sh setup` for first setup, then `start`, `stop`, or +`status`. Setup builds pinned images, prepares fresh owner-only inputs, checks +and activates the BREG and Casework packages, initializes the signer, packages +Evidence against its exact public key, and applies the upstream Lightning +migrations. Workflows are provisioned disabled, webhook authentication is +attached and tested, then triggers are enabled. Each failed stage identifies +itself while suppressing raw runtime output, which can contain credentials. +Readiness waits are bounded. Setup alone does not prove acceptance: finish with +`python3 pilot/smoke.py`. -The worker release is Alpine. The worker Dockerfile copies its compiled -application unchanged into pinned Node 24.19.0 Bookworm, installs the upstream -frozen dependency lock for glibc, and installs the deployment's locked -production dependencies. It does not rebuild or patch OpenFn source. The local -adaptor root contains `registry-evidence`, `registry-breg`, `common`, and `http`. -No dependency installation is performed when the worker starts. +`./deployment/build-images.sh` builds the worker and checks the exact generated +Lightning job bodies with the released compiler and runtime. The destination +body executes against a synthetic loopback server. The checks exercise native +client loading, automatic adaptor imports, and configuration removal at the job +boundary. A separate network-disabled container verifies the released engine's +child-process handshake under the configured operator UID/GID. No dependency +installation happens when the worker starts. -The released Lightning and worker images provide **linux/amd64 only**. This -pilot therefore pins that platform, including on Apple Silicon under Docker -emulation. The arm64 adaptor layer independently passed both Evidence and BREG native -constructor checks on Node 24.19.0/glibc 2.36. Reproduce with -`docker build --platform linux/arm64 --target adaptors -f deployment/Worker.Dockerfile .`. -An arm64 native-worker claim requires an upstream arm64 image and a -separate real-worker acceptance run. The Node and Registry Stack clients alone -being available for arm64 does not establish that claim. +The upstream worker release is Alpine. Its compiled application is copied +unchanged into pinned Node 24.19.0 Bookworm and its released dependency lock is +installed for glibc. The current adaptor dependency graph is installed at image +build time. There is no OpenFn source patch. The released Lightning and worker +images provide **linux/amd64 only**; Apple Silicon therefore uses emulation. +An arm64 client or constructor check does not establish arm64 worker support. -`compose.yaml` belongs to the synthetic agriculture pilot. Generate its ignored -runtime configuration using the pilot's setup workflow before using Compose. -It contains persistent independent Lightning and BREG databases. Mint owns a -shared network namespace for BREG, Evidence, worker, bridge and bootstrap tools, -so Registry Stack's explicit development HTTP policy uses loopback. Only the -Lightning UI (4000), BREG event bridge (4001) and BREG reviewer API (4002) are -published, all on host loopback. +ThunderID owns the shared network namespace used by BREG, Casework, Evidence, +the worker, bridge and bootstrap tools. A pinned NGINX gateway provides HTTPS +for the issuer, Evidence source access and Evidence API using a private pilot +CA. Client trust is bound to that CA; the token issuer is the actual HTTPS +origin. Remaining explicit development HTTP uses loopback inside that +namespace. Pilot TLS and database certificates expire after 365 days; an +operated deployment needs a renewal procedure that preserves its data and keys. +Host publications also bind only +to loopback: Lightning UI **4010**, bridge **4011**, BREG **4012**, and Casework +**4013**. The gateway forwards those development service ports to the native +loopback listeners inside the shared namespace. Independent Lightning, BREG and Casework PostgreSQL databases retain +their own data. PostgreSQL configuration stages owner-only SQL and TLS keys with +the database runtime's ownership before invoking the unchanged entrypoint. A +network-disabled disposable database check verifies that handoff without +mounting pilot data. -Configuration mounts are scoped to each service. In particular the worker only -receives its Evidence client profile, and Lightning receives its own bootstrap -secrets. The bootstrap tools alone receive all generated Registry Stack inputs. -The wrapper copies the database's private key and initial SQL with postgres -ownership and mode 0600 before invoking the unchanged upstream entrypoint. The -initial SQL applies only when PostgreSQL initializes a new named volume. +OpenBao has a persistent backend and owner-only initialization material. A +restart unseals the existing backend; it does not initialize a replacement or +create another signing key. The Agent uses its separate AppRole and a scoped +read/sign policy for the Evidence key. The policy requires an explicit key +version but does not restrict its numeric value. The released signer pins and +verifies version one; startup refuses rotated or multi-version keys. Do not +rotate this pilot key while services are running: after privileged rotation, +the raw Agent socket could sign another version. Evidence receives only +the workload-local socket and governed public key, without a provider token or +private signing key. This is a supervised local demonstration, not a production +secret-custody or automatic-unseal deployment. -Follow the pilot runbook for migrations, first operator setup, provisioning, -and acceptance. Build checks establish packaging and native execution; they do -not by themselves establish the full workflow or event-delivery journey. +Configuration mounts are scoped to each service. The worker receives its +Evidence client configuration, Lightning its bootstrap secrets, and Evidence its own +runtime, secrets, audit storage and signing socket. Bootstrap tools are an +operator surface with privileged package, database and reviewer material. Do +not expose their mounts or the Docker daemon. The reviewer is an explicit +synthetic teaching identity; an operated deployment needs real human sessions +and independently governed permissions. -Use `./deployment/pilot.sh setup` for the complete bootstrap, then `start`, -`stop`, or `status`. Setup preserves an existing completed runtime and verifies -its retained package; it does not clear a failed preparation or reset records. -A failed step stops startup, and raw runtime diagnostics are suppressed because -upstream errors can contain short-lived worker-token URLs. Database migrations -use the upstream release API before starting Lightning. Workflows are provisioned -disabled, webhook authentication is attached, then authentication is checked -before enabling triggers. Readiness waits are bounded. +`./deployment/compose.sh` is the supported Compose entrypoint. It fixes the +project name and operator UID/GID using an ignored environment file. Generated +files remain owned by that operator with restrictive modes. The worker image +contains a matching passwd/group entry because upstream engine children start +with an empty environment. When transferring a pilot to another operator, +transfer private-file ownership explicitly and rebuild images. Do not widen +secret permissions. `./deployment/check-permissions.sh` checks service mount +readability without printing contents. -`./deployment/compose.sh` is the supported Compose entrypoint. It creates ignored -`deployment/.env` containing the current operator UID and GID and uses that same -identity for OpenFn, Registry Stack, bootstrap and event-service containers. -The worker image also bakes a matching passwd/group entry when needed because -upstream starts its engine children with an empty environment. Rebuild images -with `build-images.sh` when transferring the pilot to a different operator. -Generated private files remain owned by that operator with restrictive modes. -The separate PostgreSQL entrypoint still drops to the image's postgres user. -`volume-init` adjusts ownership only inside the pilot's named audit, destination -and worker-cache volumes. It preserves metadata when ownership and permissions -already match, so resumed starts do not invalidate active audit writers. -No other host paths or volumes are touched. +The pilot uses the dedicated Docker subnet `10.254.38.0/24`, so it can start +when Docker's default address pools are exhausted. If that subnet conflicts +with another network, set `PILOT_SUBNET` to an unused private subnet before +first setup and retain that setting when starting the same pilot. -Run `./deployment/check-permissions.sh` after preparing or transferring a pilot. -It checks each service's private bind mount under the selected UID without -printing contents. Moving a pilot to another operator requires an explicit -ownership transfer of retained private files and updating `deployment/.env`; -the wrapper refuses to silently use another operator's settings. Do not make -private keys world-readable. Local image checks use a non-default UID/GID on -macOS/OrbStack. The focused GitHub Actions gate runs fresh setup, the complete -workflow journey, retained restart and failure recovery on Ubuntu 24.04. +External Sentry reporting and Lightning usage tracking are disabled. Lightning +retains dataclips and workflow history according to its pilot retention setting; +minimized job output does not erase original webhook inputs. Registry, Casework +and Evidence audits, local review snapshots and destination effects have their +own retention. Never publish raw logs or private runtime folders. -External Sentry reporting is explicitly disabled for worker and Lightning, and -Lightning usage tracking is disabled. The worker uses its dedicated writable -cache rather than writing to the packaged application. +Stop and restart retain data, keys and packages. Reset is separate and +permanently deletes only the current 0.38 synthetic project and marked runtime: +`./deployment/pilot.sh reset --confirm-delete-synthetic-data`. Neither setup, +start, stop nor smoke resets data. A partially activated package or failed +preparation is retained for inspection; follow the owning product recovery +procedure rather than clearing an interlock. -Reset is deliberately separate and destructive. Only -`./deployment/pilot.sh reset --confirm-delete-synthetic-data` removes the fixed -`registry-openfn-pilot` project volumes and its marked synthetic `.runtime` -directory. No reset is performed by setup, start or stop. Run the normal pilot -smoke journey after setup to establish full workflow behavior. +The Linux CI gate runs a fresh setup, the registration/correction/Evidence +journey, retained restart, exact replay and bounded failure-recovery scenarios. +Build and offline fixture checks establish different boundaries and do not +replace that live acceptance proof. + +The local Evidence client uses explicit native SDK configuration with the pinned +Transit public key, exact package revision and verification policy, and private-key +JWT authentication. Registry Stack 0.38 progressive profiles reject private HTTPS +origins. Source authorization and signed-response verification use the released +SDK under this configuration. diff --git a/deployment/Tools.Dockerfile b/deployment/Tools.Dockerfile index 744f6eb..6bfd1e1 100644 --- a/deployment/Tools.Dockerfile +++ b/deployment/Tools.Dockerfile @@ -13,13 +13,13 @@ RUN sed -i \ rm -rf /var/lib/apt/lists/* COPY deployment/tools.sha256 /tmp/tools.sha256 RUN set -eu; cd /tmp; \ - for tool in breg bregctl evidence evidencectl mint; do \ - curl --fail --location --retry 3 --output "$tool-v0.27.0-linux-amd64" "https://github.com/registrystack/registry-stack/releases/download/v0.27.0/$tool-v0.27.0-linux-amd64"; \ + for tool in breg bregctl casework caseworkctl evidence evidencectl; do \ + curl --fail --location --retry 3 --output "$tool-v0.38.0-linux-amd64" "https://github.com/registrystack/registry-stack/releases/download/v0.38.0/$tool-v0.38.0-linux-amd64"; \ done; \ sha256sum --check tools.sha256; \ - for tool in breg bregctl evidence evidencectl mint; do \ - install -m 0755 "$tool-v0.27.0-linux-amd64" "/usr/local/bin/$tool"; \ - rm "$tool-v0.27.0-linux-amd64"; \ + for tool in breg bregctl casework caseworkctl evidence evidencectl; do \ + install -m 0755 "$tool-v0.38.0-linux-amd64" "/usr/local/bin/$tool"; \ + rm "$tool-v0.38.0-linux-amd64"; \ done ENTRYPOINT [] CMD ["python3", "--version"] diff --git a/deployment/Worker.Dockerfile b/deployment/Worker.Dockerfile index 42e35b2..319f911 100644 --- a/deployment/Worker.Dockerfile +++ b/deployment/Worker.Dockerfile @@ -17,9 +17,6 @@ WORKDIR /opt/registry-adaptors COPY deployment/package.json deployment/package-lock.json ./ COPY packages/registry-evidence/package.json packages/registry-evidence/package.json COPY packages/registry-breg/package.json packages/registry-breg/package.json -# This retained pilot uses 0.27 runtimes. Keep its install graph separate from -# the current adaptor workspaces, which target Registry Stack 0.37. -RUN node -e 'const fs=require("node:fs");for(const name of ["registry-evidence","registry-breg"]){const path=`packages/${name}/package.json`;const p=JSON.parse(fs.readFileSync(path));p.dependencies["@registrystack/client"]="0.27.0";fs.writeFileSync(path,JSON.stringify(p,null,2)+"\n")}' RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund COPY deployment/check-native.cjs /opt/registry-adaptors/check-native.cjs RUN node /opt/registry-adaptors/check-native.cjs diff --git a/deployment/build-images.sh b/deployment/build-images.sh index 82f9cf5..b3f06b8 100755 --- a/deployment/build-images.sh +++ b/deployment/build-images.sh @@ -21,11 +21,11 @@ build_step() { "$@" fi } -build_step 'Build the pinned worker image.' docker build --platform linux/amd64 --target worker --build-arg "PILOT_UID=$(id -u)" --build-arg "PILOT_GID=$(id -g)" -f deployment/Worker.Dockerfile -t registry-openfn-worker:pilot . -build_step 'Check compiled worker jobs.' docker run --rm --platform linux/amd64 --entrypoint node registry-openfn-worker:pilot --experimental-vm-modules /opt/registry-adaptors/check-worker.mjs -build_step 'Check worker engine startup.' docker run --rm --platform linux/amd64 --network none --entrypoint node registry-openfn-worker:pilot /opt/registry-adaptors/check-engine.mjs -build_step 'Build the pinned Lightning image.' docker build --platform linux/amd64 -f deployment/Lightning.Dockerfile -t registry-openfn-lightning:pilot . -build_step 'Build the pilot services image.' docker build --platform linux/amd64 -f deployment/Services.Dockerfile -t registry-openfn-services:pilot . -build_step 'Build the pinned PostgreSQL image.' docker build --platform linux/amd64 -f deployment/Postgres.Dockerfile -t registry-openfn-postgres:pilot . +build_step 'Build the pinned worker image.' docker build --platform linux/amd64 --target worker --build-arg "PILOT_UID=$(id -u)" --build-arg "PILOT_GID=$(id -g)" -f deployment/Worker.Dockerfile -t registry-openfn-worker:pilot-038 . +build_step 'Check compiled worker jobs.' docker run --rm --platform linux/amd64 --entrypoint node registry-openfn-worker:pilot-038 --experimental-vm-modules /opt/registry-adaptors/check-worker.mjs +build_step 'Check worker engine startup.' docker run --rm --platform linux/amd64 --network none --entrypoint node registry-openfn-worker:pilot-038 /opt/registry-adaptors/check-engine.mjs +build_step 'Build the pinned Lightning image.' docker build --platform linux/amd64 -f deployment/Lightning.Dockerfile -t registry-openfn-lightning:pilot-038 . +build_step 'Build the pilot services image.' docker build --platform linux/amd64 -f deployment/Services.Dockerfile -t registry-openfn-services:pilot-038 . +build_step 'Build the pinned PostgreSQL image.' docker build --platform linux/amd64 -f deployment/Postgres.Dockerfile -t registry-openfn-postgres:pilot-038 . build_step 'Check PostgreSQL private input ownership.' sh deployment/check-postgres.sh -build_step 'Build the pinned Registry Stack tools image.' docker build --platform linux/amd64 -f deployment/Tools.Dockerfile -t registry-openfn-tools:pilot . +build_step 'Build the pinned Registry Stack tools image.' docker build --platform linux/amd64 -f deployment/Tools.Dockerfile -t registry-openfn-tools:pilot-038 . diff --git a/deployment/check-permissions.sh b/deployment/check-permissions.sh index 1b876cc..a702140 100755 --- a/deployment/check-permissions.sh +++ b/deployment/check-permissions.sh @@ -2,7 +2,7 @@ # Check bind readability as the runtime UIDs without exposing file contents. set -eu cd "$(dirname "$0")/.." -runtime_root="$(pwd)/pilot/agriculture/.runtime" +runtime_root="$(pwd)/pilot/agriculture/.runtime-0.38" node_image='node:24.19.0-bookworm@sha256:4196d66a565c6f195728d9952f161f4adfe2ad753052a08b7ec7f1c5a6bda42b' check_tree() { service_name="$1" @@ -17,8 +17,11 @@ check_tree() { fi } runtime_uid="$(id -u):$(id -g)" -check_tree mint "$runtime_uid" mint +check_tree issuer "$runtime_uid" issuer +check_tree tls-gateway "$runtime_uid" tls +check_tree openbao "$runtime_uid" openbao check_tree breg "$runtime_uid" breg +check_tree casework "$runtime_uid" casework check_tree evidence "$runtime_uid" evidence check_tree worker "$runtime_uid" evidence-client check_tree bridge "$runtime_uid" bridge diff --git a/deployment/check-postgres.sh b/deployment/check-postgres.sh index baeba94..da8eb46 100644 --- a/deployment/check-postgres.sh +++ b/deployment/check-postgres.sh @@ -5,7 +5,7 @@ set -eu docker run --rm -i --platform linux/amd64 --network none --user root \ --tmpfs /var/lib/postgresql/data:rw,nosuid,nodev,size=256m \ --env POSTGRES_HOST_AUTH_METHOD=trust --entrypoint sh \ - registry-openfn-postgres:pilot -se <<'CHECK' + registry-openfn-postgres:pilot-038 -se <<'CHECK' install -d -o 1001 -g 1001 -m 0700 /config/postgres openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=synthetic-check \ -keyout /config/postgres/server.key -out /config/postgres/server.crt >/dev/null 2>&1 diff --git a/deployment/check-worker.mjs b/deployment/check-worker.mjs index 153f394..7107505 100644 --- a/deployment/check-worker.mjs +++ b/deployment/check-worker.mjs @@ -11,7 +11,7 @@ const require = createRequire('/opt/registry-adaptors/package.json'); const client = require('@registrystack/client'); assert.equal(typeof client.evidence.EvidenceClient, 'function'); new client.breg.BaseRegistryClient({ baseUrl: 'http://127.0.0.1:8090' }); -assert.equal(JSON.parse(readFileSync('/opt/registry-adaptors/node_modules/@registrystack/client/package.json')).version, '0.27.0'); +assert.equal(JSON.parse(readFileSync('/opt/registry-adaptors/node_modules/@registrystack/client/package.json')).version, '0.38.0'); assert.ok(process.report.getReport().header.glibcVersionRuntime, 'worker must use glibc'); const { default: compile, preloadAdaptorExports } = await import('/app/packages/compiler/dist/index.js'); const { default: run } = await import('/app/packages/runtime/dist/index.js'); @@ -46,7 +46,31 @@ const document = projectDocument({ accessProfile: 'openfn-service', createFarmOperation: 'records.farm.create', createCorrectionOperation: 'records.name-correction.create', - requirement: 'urn:example:requirement:holding-registered:v1', + evidenceRequest: { + responseFormat: 'signed-jws', + requirement: 'urn:example:requirement:holding-registered:v1', + purpose: 'holding-verification', + audience: 'urn:example:audience:openfn-pilot', + evidenceType: 'urn:example:evidence:holding-registered', + issuedBy: 'urn:example:issuer:agriculture-holding-evidence', + providedBy: 'https://127.0.0.1:8445', + configurationRevision: `sha256:${'0'.repeat(64)}`, + expectedAssuranceProfile: 'production', + subjects: [{ + role: 'subject', + selectorProfile: 'breg-8-registry-4-farm-19-by-local-identifier', + selectorValues: {'local-identifier': {valueFrom: 'data.values.local-identifier'}}, + }], + expectedOutputs: [{ + handle: 'registered', + concept: 'urn:example:concept:holding-registered:registered', + required: true, + form: 'boolean', + }], + maximumAssertionLifetimeSeconds: 300, + clockSkewSeconds: 30, + subjectExpectations: 'acceptFirstUse', + }, registeredConcept: 'urn:example:concept:holding-registered:registered', }); assert.equal(document.workflows.length, 3); diff --git a/deployment/compose.sh b/deployment/compose.sh index b71b427..8d843f4 100755 --- a/deployment/compose.sh +++ b/deployment/compose.sh @@ -2,13 +2,14 @@ # A stable project name prevents commands from selecting another Compose stack. set -eu base_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -if [ ! -f "$base_dir/.env" ]; then - (umask 077; printf 'PILOT_UID=%s\nPILOT_GID=%s\n' "$(id -u)" "$(id -g)" > "$base_dir/.env") +env_file="$base_dir/.env-0.38" +if [ ! -f "$env_file" ]; then + (umask 077; printf 'PILOT_UID=%s\nPILOT_GID=%s\n' "$(id -u)" "$(id -g)" > "$env_file") fi -configured_uid=$(sed -n 's/^PILOT_UID=\([0-9][0-9]*\)$/\1/p' "$base_dir/.env") -configured_gid=$(sed -n 's/^PILOT_GID=\([0-9][0-9]*\)$/\1/p' "$base_dir/.env") +configured_uid=$(sed -n 's/^PILOT_UID=\([0-9][0-9]*\)$/\1/p' "$env_file") +configured_gid=$(sed -n 's/^PILOT_GID=\([0-9][0-9]*\)$/\1/p' "$env_file") if [ "$configured_uid" != "$(id -u)" ] || [ "$configured_gid" != "$(id -g)" ]; then - printf '%s\n' 'deployment/.env must contain this operator UID and GID. Review ownership before moving an existing runtime to another operator; do not widen secret permissions.' >&2 + printf '%s\n' 'deployment/.env-0.38 must contain this operator UID and GID. Review ownership before moving an existing runtime to another operator; do not widen secret permissions.' >&2 exit 1 fi -exec docker compose --project-name registry-openfn-pilot --env-file "$base_dir/.env" --file "$base_dir/compose.yaml" "$@" +exec docker compose --project-name registry-openfn-pilot-038 --env-file "$env_file" --file "$base_dir/compose.yaml" "$@" diff --git a/deployment/compose.yaml b/deployment/compose.yaml index c332440..6964490 100644 --- a/deployment/compose.yaml +++ b/deployment/compose.yaml @@ -1,4 +1,4 @@ -name: registry-openfn-pilot +name: registry-openfn-pilot-038 services: volume-init: @@ -8,17 +8,19 @@ services: environment: PILOT_UID: ${PILOT_UID:-1000} PILOT_GID: ${PILOT_GID:-1000} - # Do not touch metadata of already-owned audit files on resumed starts. - command: [sh, -ec, 'find /mint-audit /evidence-audit /destination-data /worker-cache \( ! -uid "$$PILOT_UID" -o ! -gid "$$PILOT_GID" \) -exec chown "$$PILOT_UID:$$PILOT_GID" {} +; find /mint-audit /evidence-audit /destination-data /worker-cache -maxdepth 0 ! -perm 0700 -exec chmod 0700 {} +'] + command: [sh, -ec, 'find /breg-audit /casework-audit /evidence-audit /evidence-transit /openbao-data /destination-data /worker-cache \( ! -uid "$$PILOT_UID" -o ! -gid "$$PILOT_GID" \) -exec chown "$$PILOT_UID:$$PILOT_GID" {} +; find /breg-audit /casework-audit /evidence-audit /evidence-transit /openbao-data /destination-data /worker-cache -maxdepth 0 ! -perm 0700 -exec chmod 0700 {} +'] volumes: - - mint-audit:/mint-audit - - evidence-audit:/evidence-audit - - destination-data:/destination-data - - worker-cache:/worker-cache + - {type: volume, source: breg-audit, target: /breg-audit, volume: {nocopy: true}} + - {type: volume, source: casework-audit, target: /casework-audit, volume: {nocopy: true}} + - {type: volume, source: evidence-audit, target: /evidence-audit, volume: {nocopy: true}} + - {type: volume, source: evidence-transit, target: /evidence-transit, volume: {nocopy: true}} + - {type: volume, source: openbao-data, target: /openbao-data, volume: {nocopy: true}} + - {type: volume, source: destination-data, target: /destination-data, volume: {nocopy: true}} + - {type: volume, source: worker-cache, target: /worker-cache, volume: {nocopy: true}} lightning-db: image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 - env_file: ../pilot/agriculture/.runtime/openfn/postgres.env + env_file: ../pilot/agriculture/.runtime-0.38/openfn/postgres.env volumes: - lightning-db:/var/lib/postgresql/data healthcheck: @@ -29,12 +31,26 @@ services: restart: unless-stopped breg-db: - image: registry-openfn-postgres:pilot + image: registry-openfn-postgres:pilot-038 platform: linux/amd64 - env_file: ../pilot/agriculture/.runtime/breg-db/postgres.env + env_file: ../pilot/agriculture/.runtime-0.38/breg-db/postgres.env volumes: - breg-db:/var/lib/postgresql/data - - ../pilot/agriculture/.runtime/breg-db:/config/postgres:ro + - ../pilot/agriculture/.runtime-0.38/breg-db:/config/postgres:ro + healthcheck: + test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'] + interval: 3s + timeout: 3s + retries: 30 + restart: unless-stopped + + casework-db: + image: registry-openfn-postgres:pilot-038 + platform: linux/amd64 + env_file: ../pilot/agriculture/.runtime-0.38/casework-db/postgres.env + volumes: + - casework-db:/var/lib/postgresql/data + - ../pilot/agriculture/.runtime-0.38/casework-db:/config/postgres:ro healthcheck: test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'] interval: 3s @@ -44,21 +60,21 @@ services: lightning: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: registry-openfn-lightning:pilot + image: registry-openfn-lightning:pilot-038 platform: linux/amd64 - env_file: ../pilot/agriculture/.runtime/openfn/lightning.env + env_file: ../pilot/agriculture/.runtime-0.38/openfn/lightning.env environment: PORT: '4000' URL_SCHEME: http URL_HOST: localhost - URL_PORT: '4000' + URL_PORT: '4010' USAGE_TRACKING_ENABLED: 'false' LOCAL_ADAPTORS: 'true' OPENFN_ADAPTORS_REPO: /opt/registry-adaptors volumes: - ../pilot/lightning:/opt/pilot/lightning:ro - - ../pilot/agriculture/.runtime/openfn/secrets:/run/pilot-secrets - ports: ['127.0.0.1:4000:4000'] + - ../pilot/agriculture/.runtime-0.38/openfn/secrets:/run/pilot-secrets + ports: ['127.0.0.1:4010:4000'] depends_on: lightning-db: {condition: service_healthy} healthcheck: @@ -68,82 +84,166 @@ services: retries: 60 restart: unless-stopped - # Mint can start from offline configuration before the BREG package is built. - # Owning this namespace keeps development HTTP on loopback without weakening - # Registry Stack's destination or token endpoint policies. - mint: + issuer: + user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" + image: ghcr.io/thunder-id/thunderid:1.0.1@sha256:d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e + platform: linux/amd64 + working_dir: /opt/thunderid + command: [./start.sh] + volumes: + - ../pilot/agriculture/.runtime-0.38/issuer/database:/opt/thunderid/database + - ../pilot/agriculture/.runtime-0.38/issuer/certs:/opt/thunderid/config/certs + - ../pilot/agriculture/.runtime-0.38/issuer/secrets:/opt/thunderid/config/secrets + - ../pilot/agriculture/.runtime-0.38/issuer/deployment.yaml:/opt/thunderid/deployment.yaml:ro + - ../pilot/agriculture/.runtime-0.38/issuer/resources:/opt/thunderid/config/resources:ro + ports: + - '127.0.0.1:4011:8097' + - '127.0.0.1:4012:8095' + - '127.0.0.1:4013:8096' + restart: unless-stopped + + tls-gateway: + user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" + image: nginx:1.30.5-alpine3.24@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 + platform: linux/amd64 + network_mode: service:issuer + command: [nginx, -g, 'daemon off;', -c, /etc/nginx/nginx.conf] + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ../pilot/agriculture/.runtime-0.38/tls/server.crt:/etc/pilot-tls/server.crt:ro + - ../pilot/agriculture/.runtime-0.38/tls/server.key:/etc/pilot-tls/server.key:ro + depends_on: + issuer: {condition: service_started} + restart: unless-stopped + + openbao: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: ghcr.io/registrystack/mint:v0.27.0@sha256:cf16981eb2e992722c0e00f3c2b68c85a875843c838bd7ba7f728b456322282e + image: ghcr.io/openbao/openbao:2.7.0@sha256:71156a1c6623a5fa3f5e61b0c6a8ead0faf0df29a778339188443551995d1315 platform: linux/amd64 - command: [serve, --config, /config/mint/mint.json] + network_mode: service:issuer + command: [server] + environment: + BAO_ADDR: http://127.0.0.1:8200 + SKIP_CHOWN: 'true' volumes: - - ../pilot/agriculture/.runtime/mint:/config/mint:ro - - mint-audit:/var/lib/mint + - type: volume + source: openbao-data + target: /openbao/file + volume: + nocopy: true + - ./openbao/server.hcl:/openbao/config/server.hcl:ro + - ./openbao/evidence-signing.hcl:/openbao/policy/evidence-signing.hcl:ro + - ./openbao/bootstrap.sh:/openbao/bootstrap.sh:ro + - ../pilot/agriculture/.runtime-0.38/openbao:/run/openbao-secrets depends_on: + issuer: {condition: service_started} + volume-init: {condition: service_completed_successfully} + restart: unless-stopped + + evidence-transit: + user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" + image: ghcr.io/openbao/openbao:2.7.0@sha256:71156a1c6623a5fa3f5e61b0c6a8ead0faf0df29a778339188443551995d1315 + platform: linux/amd64 + network_mode: service:issuer + command: [agent, -config=/openbao/config/evidence-agent.hcl] + environment: + SKIP_CHOWN: 'true' + volumes: + - ./openbao/evidence-agent.hcl:/openbao/config/evidence-agent.hcl:ro + - ../pilot/agriculture/.runtime-0.38/openbao/role-id:/run/openbao-credentials/role-id:ro + - ../pilot/agriculture/.runtime-0.38/openbao/secret-id:/run/openbao-credentials/secret-id:ro + - {type: volume, source: evidence-transit, target: /run/registry-evidence, volume: {nocopy: true}} + depends_on: + openbao: {condition: service_started} volume-init: {condition: service_completed_successfully} - ports: - - '127.0.0.1:4001:8081' - - '127.0.0.1:4002:8090' restart: unless-stopped breg: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: ghcr.io/registrystack/breg:v0.27.0@sha256:222e4ee3e27f7ee5f237dc06693a07773222b39ef15051abb27bac2cae632586 + image: ghcr.io/registrystack/breg:v0.38.0@sha256:82ee71ae85411dcf53530f91f33202d23d3cec135b4bb2049dff5c4558a7dafa platform: linux/amd64 - network_mode: service:mint - command: [--config, /config/breg/runtime.json] + network_mode: service:issuer + command: [--runtime-config, /config/breg/runtime.yaml] environment: - SSL_CERT_FILE: /config/breg/secrets/database-ca.pem + SSL_CERT_FILE: /config/breg/secrets/trust-bundle.pem volumes: - - ../pilot/agriculture/.runtime/breg:/config/breg:ro + - ../pilot/agriculture/.runtime-0.38/breg:/config/breg:ro + - {type: volume, source: breg-audit, target: /var/lib/registry-breg, volume: {nocopy: true}} depends_on: - mint: {condition: service_started} + issuer: {condition: service_started} breg-db: {condition: service_healthy} restart: unless-stopped + casework: + user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" + image: ghcr.io/registrystack/casework:v0.38.0@sha256:45eee73bb656eec5c64a9ebb508b28b1676b8b96f38b4a107b2de871ccbce0d4 + platform: linux/amd64 + network_mode: service:issuer + command: [--runtime-config, /config/casework/runtime.yaml, serve] + environment: + SSL_CERT_FILE: /config/casework/secrets/pilot-ca.pem + volumes: + - ../pilot/agriculture/.runtime-0.38/casework:/config/casework:ro + - {type: volume, source: casework-audit, target: /var/lib/registry-casework, volume: {nocopy: true}} + depends_on: + issuer: {condition: service_started} + casework-db: {condition: service_healthy} + restart: unless-stopped + evidence: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: ghcr.io/registrystack/evidence:v0.27.0@sha256:9b9ddae7fe9d1185f9423a7ca57ea612d178a154df5f42d26f91ff62dc03f4d8 + image: ghcr.io/registrystack/evidence:v0.38.0@sha256:0a91d8ecd795db2d738b521081e3d78d6fa47e0acbb8f1ad929fcc0c41e2a4d5 platform: linux/amd64 - network_mode: service:mint - command: [serve, --runtime, /config/evidence/runtime.yaml] + network_mode: service:issuer + command: [serve, --runtime-config, /config/evidence/runtime.yaml] + environment: + SSL_CERT_FILE: /config/evidence/tls/pilot-ca.pem volumes: - - ../pilot/agriculture/.runtime/evidence:/config/evidence:ro - - evidence-audit:/var/lib/evidence + - ../pilot/agriculture/.runtime-0.38/evidence:/config/evidence:ro + - ../pilot/agriculture/.runtime-0.38/tls/ca.pem:/config/evidence/tls/pilot-ca.pem:ro + - {type: volume, source: evidence-audit, target: /var/lib/registry-evidence, volume: {nocopy: true}} + - {type: volume, source: evidence-transit, target: /run/registry-evidence, volume: {nocopy: true}} depends_on: - mint: {condition: service_started} + issuer: {condition: service_started} breg: {condition: service_started} + evidence-transit: {condition: service_started} + tls-gateway: {condition: service_started} restart: unless-stopped worker: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: registry-openfn-worker:pilot + image: registry-openfn-worker:pilot-038 platform: linux/amd64 - network_mode: service:mint - env_file: ../pilot/agriculture/.runtime/openfn/worker.env + network_mode: service:issuer + env_file: ../pilot/agriculture/.runtime-0.38/openfn/worker.env environment: OPENFN_ADAPTORS_REPO: /opt/registry-adaptors + SSL_CERT_FILE: /config/evidence-client/pilot-ca.pem WORKER_LIGHTNING_SERVICE_URL: ws://lightning:4000/worker WORKER_STATE_PROPS_TO_REMOVE: configuration WORKER_REPO_DIR: /var/cache/openfn/repo WORKER_SENTRY_DSN: '' SENTRY_DSN: '' volumes: - - ../pilot/agriculture/.runtime/evidence-client:/config/evidence-client:ro - - worker-cache:/var/cache/openfn + - ../pilot/agriculture/.runtime-0.38/evidence-client:/config/evidence-client:ro + - ../pilot/agriculture/.runtime-0.38/tls/ca.pem:/config/evidence-client/pilot-ca.pem:ro + - ../pilot/agriculture/.runtime-0.38/tls/ca.pem:/etc/ssl/certs/ca-certificates.crt:ro + - {type: volume, source: worker-cache, target: /var/cache/openfn, volume: {nocopy: true}} depends_on: - mint: {condition: service_started} + issuer: {condition: service_started} lightning: {condition: service_healthy} restart: unless-stopped bridge: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: registry-openfn-services:pilot + image: registry-openfn-services:pilot-038 platform: linux/amd64 - network_mode: service:mint + network_mode: service:issuer command: [node, services/event-bridge/src/server.js] - env_file: ../pilot/agriculture/.runtime/openfn/bridge.env + env_file: ../pilot/agriculture/.runtime-0.38/openfn/bridge.env environment: + BREG_BIND_HOST: '127.0.0.1' + BREG_EVENT_BODY_FORMAT: hook-envelope-v1 PORT: '8081' ALLOW_HTTP: 'true' BREG_HMAC_KEY_FILE: /config/bridge/breg-hmac-key @@ -155,15 +255,15 @@ services: MAX_DELIVERY_SKEW_SECONDS: '300' OPENFN_TIMEOUT_MS: '3000' volumes: - - ../pilot/agriculture/.runtime/bridge:/config/bridge:ro + - ../pilot/agriculture/.runtime-0.38/bridge:/config/bridge:ro depends_on: - mint: {condition: service_started} + issuer: {condition: service_started} lightning: {condition: service_healthy} restart: unless-stopped destination: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: registry-openfn-services:pilot + image: registry-openfn-services:pilot-038 platform: linux/amd64 command: [node, services/destination/src/server.js] environment: @@ -171,39 +271,74 @@ services: DESTINATION_API_KEY_FILE: /config/destination/api-key DESTINATION_DB_PATH: /data/destination.sqlite volumes: - - ../pilot/agriculture/.runtime/destination:/config/destination:ro - - destination-data:/data + - ../pilot/agriculture/.runtime-0.38/destination:/config/destination:ro + - {type: volume, source: destination-data, target: /data, volume: {nocopy: true}} depends_on: volume-init: {condition: service_completed_successfully} restart: unless-stopped tools: user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" - image: registry-openfn-tools:pilot + image: registry-openfn-tools:pilot-038 platform: linux/amd64 - network_mode: service:mint + network_mode: service:issuer profiles: [tools] working_dir: /workspace environment: - SSL_CERT_FILE: /config/breg/secrets/database-ca.pem + SSL_CERT_FILE: /config/breg/secrets/trust-bundle.pem + PILOT_RUNTIME_ROOT: /workspace/pilot/agriculture/.runtime-0.38 volumes: - ..:/workspace:ro - ../pilot/agriculture:/opt/pilot/agriculture:ro - - ../pilot/agriculture/.runtime:/workspace/pilot/agriculture/.runtime - - ../pilot/agriculture/.runtime/breg:/config/breg - - ../pilot/agriculture/.runtime/evidence:/config/evidence - - ../pilot/agriculture/.runtime/mint:/config/mint - - ../pilot/agriculture/.runtime/evidence-client:/config/evidence-client + - ../pilot/agriculture/.runtime-0.38:/workspace/pilot/agriculture/.runtime-0.38 + - ../pilot/agriculture/.runtime-0.38/breg:/config/breg + - ../pilot/agriculture/.runtime-0.38/casework:/config/casework + - ../pilot/agriculture/.runtime-0.38/evidence:/config/evidence + - ../pilot/agriculture/.runtime-0.38/evidence-client:/config/evidence-client + - ../pilot/agriculture/.runtime-0.38/tls/ca.pem:/config/evidence/tls/pilot-ca.pem:ro + - ../pilot/agriculture/.runtime-0.38/tls/ca.pem:/config/evidence-client/pilot-ca.pem:ro + - {type: volume, source: evidence-transit, target: /run/registry-evidence, volume: {nocopy: true}} + - {type: volume, source: breg-audit, target: /var/lib/registry-breg, volume: {nocopy: true}} + - {type: volume, source: casework-audit, target: /var/lib/registry-casework, volume: {nocopy: true}} + - {type: volume, source: evidence-audit, target: /var/lib/registry-evidence, volume: {nocopy: true}} depends_on: - mint: {condition: service_started} + issuer: {condition: service_started} breg-db: {condition: service_healthy} + casework-db: {condition: service_healthy} entrypoint: [] command: [python3, --version] + sdk-tools: + user: "${PILOT_UID:-1000}:${PILOT_GID:-1000}" + image: registry-openfn-worker:pilot-038 + platform: linux/amd64 + network_mode: service:issuer + profiles: [tools] + working_dir: /opt/registry-adaptors + volumes: + - ../pilot/agriculture:/opt/pilot/agriculture:ro + - ../pilot/agriculture/.runtime-0.38/evidence-client:/config/evidence-client:ro + - ../pilot/agriculture/.runtime-0.38/tls/ca.pem:/run/pilot-ca.pem:ro + depends_on: + issuer: {condition: service_started} + evidence: {condition: service_started} + entrypoint: [] + command: [node, /opt/pilot/agriculture/evidence-smoke.mjs] + volumes: lightning-db: breg-db: - mint-audit: + casework-db: + breg-audit: + casework-audit: evidence-audit: + evidence-transit: + openbao-data: worker-cache: destination-data: + +networks: + default: + ipam: + config: + - subnet: ${PILOT_SUBNET:-10.254.38.0/24} diff --git a/deployment/images.lock.json b/deployment/images.lock.json index 5b516fc..e07aa5c 100644 --- a/deployment/images.lock.json +++ b/deployment/images.lock.json @@ -1,15 +1,18 @@ { "schemaVersion": 1, - "resolvedAt": "2026-09-09", + "resolvedAt": "2026-10-01", "platform": "linux/amd64", "lightning": "openfn/lightning:v2.18.2@sha256:a2327173ece7b5ac4904744acf63f928dd2cbe496788d31266060e85bd359203", "worker": "openfn/ws-worker:v1.29.0@sha256:827f4897d120c322d977da7bc2810174332eb5ba4902fdb7279178812d787c18", "node": "node:24.19.0-bookworm@sha256:4196d66a565c6f195728d9952f161f4adfe2ad753052a08b7ec7f1c5a6bda42b", + "nginx": "nginx:1.30.5-alpine3.24@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94", "postgres": "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", - "registryClientVersion": "0.27.0", + "registryClientVersion": "0.38.0", "toolsDebianSnapshot": "20260824T000000Z", - "notes": "Released Lightning and worker images publish linux/amd64 only. The worker application is copied unchanged to the pinned glibc Node image; native optional dependencies are installed from the released worker lockfile. No arm64 worker claim is made.", - "breg": "ghcr.io/registrystack/breg:v0.27.0@sha256:222e4ee3e27f7ee5f237dc06693a07773222b39ef15051abb27bac2cae632586", - "evidence": "ghcr.io/registrystack/evidence:v0.27.0@sha256:9b9ddae7fe9d1185f9423a7ca57ea612d178a154df5f42d26f91ff62dc03f4d8", - "mint": "ghcr.io/registrystack/mint:v0.27.0@sha256:cf16981eb2e992722c0e00f3c2b68c85a875843c838bd7ba7f728b456322282e" + "notes": "Registry Stack references and checksums come from the published v0.38.0 release manifest. OpenBao 2.7.0 and stable NGINX 1.30.5 were resolved from their official image registries. Released Lightning and worker images publish linux/amd64 only. The worker application is copied unchanged to the pinned glibc Node image; native optional dependencies are installed from the released worker lockfile. No arm64 worker claim is made.", + "breg": "ghcr.io/registrystack/breg:v0.38.0@sha256:82ee71ae85411dcf53530f91f33202d23d3cec135b4bb2049dff5c4558a7dafa", + "casework": "ghcr.io/registrystack/casework:v0.38.0@sha256:45eee73bb656eec5c64a9ebb508b28b1676b8b96f38b4a107b2de871ccbce0d4", + "evidence": "ghcr.io/registrystack/evidence:v0.38.0@sha256:0a91d8ecd795db2d738b521081e3d78d6fa47e0acbb8f1ad929fcc0c41e2a4d5", + "openbao": "ghcr.io/openbao/openbao:2.7.0@sha256:71156a1c6623a5fa3f5e61b0c6a8ead0faf0df29a778339188443551995d1315", + "thunderid": "ghcr.io/thunder-id/thunderid:1.0.1@sha256:d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e" } diff --git a/deployment/nginx.conf b/deployment/nginx.conf new file mode 100644 index 0000000..b4fa44a --- /dev/null +++ b/deployment/nginx.conf @@ -0,0 +1,61 @@ +pid /tmp/nginx.pid; +error_log stderr notice; + +events {} + +http { + access_log off; + proxy_temp_path /tmp/proxy_temp; + client_body_temp_path /tmp/client_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_certificate /etc/pilot-tls/server.crt; + ssl_certificate_key /etc/pilot-tls/server.key; + + # Docker publishes the namespace's network interface, while native services + # bind loopback. Separate development listeners bridge those host-only ports. + server { + listen 8095; + location / { proxy_pass http://127.0.0.1:8090; } + } + + server { + listen 8096; + location / { proxy_pass http://127.0.0.1:8092; } + } + + server { + listen 8097; + location / { proxy_pass http://127.0.0.1:8081; } + } + + server { + listen 8443 ssl; + location / { + proxy_set_header Host 127.0.0.1:8443; + proxy_set_header X-Forwarded-Proto https; + proxy_pass http://127.0.0.1:8091; + } + } + + server { + listen 8444 ssl; + location / { + proxy_set_header Host 127.0.0.1:8444; + proxy_set_header X-Forwarded-Proto https; + proxy_pass http://127.0.0.1:8090; + } + } + + server { + listen 8445 ssl; + location / { + proxy_set_header Host 127.0.0.1:8445; + proxy_set_header X-Forwarded-Proto https; + proxy_pass http://127.0.0.1:8080; + } + } +} diff --git a/deployment/openbao-state.py b/deployment/openbao-state.py new file mode 100755 index 0000000..b625374 --- /dev/null +++ b/deployment/openbao-state.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Move bounded OpenBao bootstrap output into owner-only pilot files.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess +import urllib.error +import urllib.request + + +def private_write(path: Path, value: str) -> None: + if path.exists(): + raise RuntimeError(f"refusing to overwrite retained OpenBao state: {path.name}") + with path.open("x", encoding="utf-8") as handle: + handle.write(value.rstrip("\n") + "\n") + path.chmod(0o600) + + +def bounded_json(path: Path) -> dict: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024: + raise RuntimeError("OpenBao bootstrap output is not a bounded ordinary file") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError("OpenBao bootstrap output is malformed") from error + if not isinstance(value, dict): + raise RuntimeError("OpenBao bootstrap output is malformed") + return value + + +def record_init(root: Path) -> None: + temporary = root / "init.json.tmp" + document = bounded_json(temporary) + keys = document.get("unseal_keys_b64") + token = document.get("root_token") + if not isinstance(keys, list) or len(keys) != 1 or not isinstance(keys[0], str) or not keys[0]: + raise RuntimeError("OpenBao did not return the one requested unseal share") + if not isinstance(token, str) or not token: + raise RuntimeError("OpenBao did not return a root token") + private_write(root / "unseal-key", keys[0]) + private_write(root / "root-token", token) + temporary.unlink() + + +def record_public(runtime: Path, evidencectl: Path) -> None: + openbao = runtime / "openbao" + temporary = openbao / "evidence-signing-metadata.json.tmp" + document = bounded_json(temporary) + try: + data = document["data"] + keys = data["keys"] + key = keys["1"] + pem = key["public_key"] + except (KeyError, TypeError) as error: + raise RuntimeError("OpenBao Transit metadata omitted key version 1") from error + if ( + not isinstance(data, dict) + or not isinstance(keys, dict) + or set(keys) != {"1"} + or data.get("latest_version") != 1 + or data.get("auto_rotate_period") != 0 + or data.get("type") != "ecdsa-p256" + or data.get("derived") is not False + or data.get("deletion_allowed") is not False + or data.get("exportable") is not False + or data.get("allow_plaintext_backup") is not False + or data.get("imported_key") is not False + or data.get("soft_deleted") is not False + or data.get("supports_signing") is not True + ): + raise RuntimeError("OpenBao Transit key is not the retained single-version signing key") + if not isinstance(pem, str) or "BEGIN PUBLIC KEY" not in pem or "PRIVATE" in pem: + raise RuntimeError("OpenBao Transit returned an invalid public key") + output_dir = runtime / "evidence" / "transit-public" + output_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + pem_path = output_dir / "evidence-signing.pem" + jwk_path = output_dir / "evidence-signing.jwk.json" + if pem_path.exists() or jwk_path.exists(): + if not pem_path.is_file() or not jwk_path.is_file() or pem_path.read_text(encoding="utf-8").strip() != pem.strip(): + raise RuntimeError("retained Evidence Transit public key does not match provider version 1") + temporary.unlink() + return + private_write(pem_path, pem) + result = subprocess.run( + [str(evidencectl), "jwk", "from-pem", str(pem_path), "--output", str(jwk_path)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode: + raise RuntimeError("evidencectl refused the OpenBao Transit public key") + jwk_path.chmod(0o600) + temporary.unlink() + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + + +def unseal(root: Path) -> None: + key_path = root / "unseal-key" + metadata = key_path.lstat() + if key_path.is_symlink() or not key_path.is_file() or metadata.st_mode & 0o077: + raise RuntimeError("OpenBao unseal key is not an owner-only ordinary file") + if metadata.st_size > 4096: + raise RuntimeError("OpenBao unseal key is unexpectedly large") + key = key_path.read_text(encoding="ascii").strip() + if not key or any(character.isspace() for character in key): + raise RuntimeError("OpenBao unseal key is malformed") + request = urllib.request.Request( + "http://127.0.0.1:8200/v1/sys/unseal", + data=json.dumps({"key": key}, separators=(",", ":")).encode("ascii"), + method="PUT", + headers={"Content-Type": "application/json"}, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + try: + with opener.open(request, timeout=5) as response: + body = response.read(65537) + if response.status != 200 or len(body) > 65536: + raise RuntimeError("OpenBao refused the unseal share") + except (urllib.error.URLError, TimeoutError) as error: + raise RuntimeError("OpenBao unseal request failed") from error + try: + document = json.loads(body) + except json.JSONDecodeError as error: + raise RuntimeError("OpenBao returned a malformed unseal response") from error + if not isinstance(document, dict) or document.get("sealed") is not False: + raise RuntimeError("OpenBao remains sealed after accepting the configured share") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="command", required=True) + init = subcommands.add_parser("record-init") + init.add_argument("openbao_root", type=Path) + public = subcommands.add_parser("record-public") + public.add_argument("runtime_root", type=Path) + public.add_argument("--evidencectl", type=Path, default=Path("/usr/local/bin/evidencectl")) + unseal_parser = subcommands.add_parser("unseal") + unseal_parser.add_argument("openbao_root", type=Path) + args = parser.parse_args() + os.umask(0o077) + if args.command == "record-init": + record_init(args.openbao_root.resolve()) + elif args.command == "record-public": + record_public(args.runtime_root.resolve(), args.evidencectl) + else: + unseal(args.openbao_root.resolve()) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + raise SystemExit(f"OpenBao state preparation failed: {error}") diff --git a/deployment/openbao/bootstrap.sh b/deployment/openbao/bootstrap.sh new file mode 100755 index 0000000..4b0b03a --- /dev/null +++ b/deployment/openbao/bootstrap.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Run inside the isolated OpenBao server after it has been unsealed. +set -eu +secret_root=/run/openbao-secrets +export BAO_ADDR=http://127.0.0.1:8200 +export BAO_TOKEN="$(cat "$secret_root/root-token")" + +# Reconcile the scoped policy on retained starts without rotating key state. +bao policy write evidence-signing /openbao/policy/evidence-signing.hcl >/dev/null + +if [ ! -f "$secret_root/transit-configured" ]; then + bao secrets enable transit >/dev/null + bao write -f transit/keys/evidence-signing type=ecdsa-p256 >/dev/null + bao auth enable approle >/dev/null + bao write auth/approle/role/evidence-signing \ + token_policies=evidence-signing token_ttl=1h token_max_ttl=4h \ + secret_id_ttl=0 secret_id_num_uses=0 >/dev/null + bao read -field=role_id auth/approle/role/evidence-signing/role-id > "$secret_root/role-id" + bao write -f -field=secret_id auth/approle/role/evidence-signing/secret-id > "$secret_root/secret-id" + chmod 0600 "$secret_root/role-id" "$secret_root/secret-id" + : > "$secret_root/transit-configured" + chmod 0600 "$secret_root/transit-configured" +fi + +metadata="$secret_root/evidence-signing-metadata.json.tmp" +if [ -e "$metadata" ]; then + printf '%s\n' 'Preserved OpenBao Transit metadata staging file exists; inspect recovery state before retrying.' >&2 + exit 1 +fi +(umask 077; set -C; bao read -format=json transit/keys/evidence-signing > "$metadata") diff --git a/deployment/openbao/evidence-agent.hcl b/deployment/openbao/evidence-agent.hcl new file mode 100644 index 0000000..8f3673d --- /dev/null +++ b/deployment/openbao/evidence-agent.hcl @@ -0,0 +1,30 @@ +pid_file = "/run/registry-evidence/transit-proxy.pid" + +vault { + address = "http://127.0.0.1:8200" + retry { + num_retries = -1 + } +} + +auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/run/openbao-credentials/role-id" + secret_id_file_path = "/run/openbao-credentials/secret-id" + remove_secret_id_file_after_reading = false + } + } +} + +api_proxy { + use_auto_auth_token = "force" +} + +listener "unix" { + address = "/run/registry-evidence/transit-proxy.sock" + tls_disable = true + socket_mode = "0600" + require_request_header = true +} diff --git a/deployment/openbao/evidence-signing.hcl b/deployment/openbao/evidence-signing.hcl new file mode 100644 index 0000000..0829077 --- /dev/null +++ b/deployment/openbao/evidence-signing.hcl @@ -0,0 +1,16 @@ +path "transit/keys/evidence-signing" { + capabilities = ["read"] +} + +path "transit/sign/evidence-signing/sha2-256" { + capabilities = ["update"] + required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] + allowed_parameters = { + "input" = [] + # OpenBao 2.7 refuses the released client's integer under a numeric + # allowlist. The runtime pins version 1; startup rejects rotated keys. + "key_version" = [] + "marshaling_algorithm" = ["jws"] + "prehashed" = [true] + } +} diff --git a/deployment/openbao/server.hcl b/deployment/openbao/server.hcl new file mode 100644 index 0000000..ae964b4 --- /dev/null +++ b/deployment/openbao/server.hcl @@ -0,0 +1,15 @@ +ui = false +disable_mlock = true +api_addr = "http://127.0.0.1:8200" +cluster_addr = "http://127.0.0.1:8201" + +listener "tcp" { + address = "127.0.0.1:8200" + cluster_address = "127.0.0.1:8201" + tls_disable = true +} + +storage "raft" { + path = "/openbao/file" + node_id = "registry-openfn-pilot-038" +} diff --git a/deployment/package-lock.json b/deployment/package-lock.json index 57f2d86..15837bb 100644 --- a/deployment/package-lock.json +++ b/deployment/package-lock.json @@ -1,16 +1,16 @@ { - "name": "registry-openfn-legacy-pilot-dependencies", + "name": "registry-openfn-pilot-dependencies", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "registry-openfn-legacy-pilot-dependencies", + "name": "registry-openfn-pilot-dependencies", "version": "0.1.0", "dependencies": { "@openfn/language-common": "3.3.4", "@openfn/language-http": "7.3.3", - "@registrystack/client": "0.27.0" + "@registrystack/client": "0.38.0" }, "engines": { "node": ">=22.12.0" @@ -93,23 +93,23 @@ } }, "node_modules/@registrystack/client": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.27.0.tgz", - "integrity": "sha512-wELi6zYe/r6VzunfgLGWUwAVPhLhtF1fPTArCuHxwdjlBfZhCMYbBh/QWv20M97NJm6hgp7f0y4mYqolXl/tvA==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", + "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", "license": "Apache-2.0", "engines": { "node": ">=22.12.0" }, "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.27.0", - "@registrystack/client-linux-arm64-gnu": "0.27.0", - "@registrystack/client-linux-x64-gnu": "0.27.0" + "@registrystack/client-darwin-arm64": "0.38.0", + "@registrystack/client-linux-arm64-gnu": "0.38.0", + "@registrystack/client-linux-x64-gnu": "0.38.0" } }, "node_modules/@registrystack/client-darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.27.0.tgz", - "integrity": "sha512-utqr47FzIIzAcIHq26aRgKfPdJ0zAhuDifE3iMh24BeyrD9erLQQpufFZvOLrvkNZ6ffr4RPSz5XvmhwbL4icQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", + "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", "cpu": [ "arm64" ], @@ -123,9 +123,9 @@ } }, "node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.27.0.tgz", - "integrity": "sha512-TNAVMleJhX9IwZExoq0OrUw/HgrRCC7ehvIgtz6fVDzOLbgDDBAf/pSujNeLipeqL74BYmy0SHRoQJ8h28Hwdw==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", + "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", "cpu": [ "arm64" ], @@ -139,9 +139,9 @@ } }, "node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.27.0.tgz", - "integrity": "sha512-80UiF58s5ydBSR1XZQNfxd5ybKQj9LlvocfzRu06qGNkyjEsV/pjhZzMktQhQl1ji83bdtPPZbmvNvej9vWZwA==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", + "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", "cpu": [ "x64" ], diff --git a/deployment/package.json b/deployment/package.json index cf14175..95d87a8 100644 --- a/deployment/package.json +++ b/deployment/package.json @@ -1,12 +1,12 @@ { - "name": "registry-openfn-legacy-pilot-dependencies", + "name": "registry-openfn-pilot-dependencies", "version": "0.1.0", "private": true, - "description": "Frozen dependency graph for the retained Registry Stack 0.27.0 pilot.", + "description": "Frozen dependency graph for the isolated Registry Stack 0.38.0 pilot.", "dependencies": { "@openfn/language-common": "3.3.4", "@openfn/language-http": "7.3.3", - "@registrystack/client": "0.27.0" + "@registrystack/client": "0.38.0" }, "overrides": { "csv-parse": "7.0.2" diff --git a/deployment/pilot.sh b/deployment/pilot.sh index 8cb467c..03f71b9 100755 --- a/deployment/pilot.sh +++ b/deployment/pilot.sh @@ -1,10 +1,11 @@ #!/bin/sh -# Isolated synthetic pilot lifecycle. It never selects another Compose project. +# Isolated synthetic 0.38 pilot lifecycle. It never selects the retained 0.27 project. set -eu base_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_dir=$(CDPATH= cd -- "$base_dir/.." && pwd) -runtime_dir="$repo_dir/pilot/agriculture/.runtime" +runtime_dir="$repo_dir/pilot/agriculture/.runtime-0.38" node_image='node:24.19.0-bookworm@sha256:4196d66a565c6f195728d9952f161f4adfe2ad753052a08b7ec7f1c5a6bda42b' +thunderid_image='ghcr.io/thunder-id/thunderid:1.0.1@sha256:d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e' operator_uid="$(id -u):$(id -g)" cd "$repo_dir" @@ -13,80 +14,186 @@ step() { label="$1" shift printf '%s\n' "$label" - # Upstream startup failures can include worker JWT URLs. Do not emit or retain - # raw subprocess output from lifecycle actions; each failed step is identified. + # Upstream failures may include bearer tokens, assertions or bootstrap + # credentials. Identify the failed boundary without relaying raw output. if ! "$@" >/dev/null 2>&1; then printf '%s\n' "$label failed. Raw runtime output was suppressed to protect credentials." >&2 exit 1 fi } +tools() { + compose run --rm --no-deps tools "$@" +} node_command() { command_name="$1" docker run --rm --platform linux/amd64 --user "$operator_uid" \ - --network registry-openfn-pilot_default \ + --network registry-openfn-pilot-038_default \ --env OPENFN_URL=http://lightning:4000 \ --mount "type=bind,src=$repo_dir,dst=/workspace,readonly" \ - --mount "type=bind,src=$runtime_dir,dst=/workspace/pilot/agriculture/.runtime" \ + --mount "type=bind,src=$runtime_dir,dst=/workspace/pilot/agriculture/.runtime-0.38" \ --workdir /workspace "$node_image" \ node pilot/lightning/provision.mjs "$command_name" } wait_for() { - compose run --rm --no-deps tools python3 /workspace/deployment/wait-http.py "$1" "$2" "${3:-90}" + ca_file="${4:-}" + if [ -n "$ca_file" ]; then + tools python3 /workspace/deployment/wait-http.py "$1" "$2" "${3:-90}" "$ca_file" + else + tools python3 /workspace/deployment/wait-http.py "$1" "$2" "${3:-90}" + fi } require_prepared() { if [ ! -f "$runtime_dir/prepared.json" ]; then - printf '%s\n' 'No completed pilot configuration. Run deployment/pilot.sh setup first.' >&2 + printf '%s\n' 'No completed 0.38 pilot configuration. Run deployment/pilot.sh setup first.' >&2 exit 1 fi + python3 - "$runtime_dir/prepared.json" <<'PY' +import json, pathlib, sys +path = pathlib.Path(sys.argv[1]) +try: + marker = json.loads(path.read_text()) +except Exception: + raise SystemExit('The 0.38 prepared marker is unreadable.') +if marker.get('schema') != 'synthetic-agriculture-pilot/v1' or marker.get('version') != '0.38.0': + raise SystemExit('The selected runtime is not the isolated 0.38 agriculture pilot.') +PY +} +issuer_one_shot() { + password_file="$1" + shift + ( + ADMIN_PASSWORD=$(cat "$password_file") + export ADMIN_PASSWORD + docker run --rm --platform linux/amd64 --user "$operator_uid" --env ADMIN_PASSWORD \ + --workdir /opt/thunderid \ + --mount "type=bind,src=$runtime_dir/issuer/database,dst=/opt/thunderid/database" \ + --mount "type=bind,src=$runtime_dir/issuer/certs,dst=/opt/thunderid/config/certs" \ + --mount "type=bind,src=$runtime_dir/issuer/secrets,dst=/opt/thunderid/config/secrets" \ + --mount "type=bind,src=$runtime_dir/issuer/deployment.yaml,dst=/opt/thunderid/deployment.yaml" \ + "$@" + ) +} +prepare_issuer_state() { + issuer_root="$runtime_dir/issuer" + if [ ! -f "$issuer_root/.setup-complete" ]; then + if [ -n "$(find "$issuer_root/database" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + printf '%s\n' 'ThunderID database state exists without its setup marker; inspect the retained fresh-pilot state before retrying.' >&2 + exit 1 + fi + step 'Seed the pinned ThunderID database schema.' \ + docker run --rm --platform linux/amd64 --user "$operator_uid" \ + --mount "type=bind,src=$issuer_root/database,dst=/seed" --entrypoint sh \ + "$thunderid_image" -c 'cp -r /opt/thunderid/database/. /seed/' + step 'Run the one-time stock ThunderID setup.' \ + issuer_one_shot "$issuer_root/secrets/admin-password" "$thunderid_image" ./setup.sh + (umask 077; : > "$issuer_root/.setup-complete") + fi + step 'Apply the additive ThunderID agent schema and machine clients.' \ + issuer_one_shot "$issuer_root/secrets/bootstrap-password" \ + --mount "type=bind,src=$issuer_root/registry-schema,dst=/mounted/registry-schema" \ + --mount "type=bind,src=$issuer_root/resources,dst=/opt/thunderid/config/resources" \ + --entrypoint ./thunderid "$thunderid_image" bootstrap --defaults /mounted/registry-schema +} +prepare_openbao_state() { + openbao_root="$runtime_dir/openbao" + step 'Start the isolated persistent OpenBao provider.' compose up -d openbao + step 'Wait for the sealed or uninitialized OpenBao API.' \ + wait_for 'http://127.0.0.1:8200/v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200' OpenBao 90 + if [ ! -f "$openbao_root/root-token" ]; then + if [ -e "$openbao_root/init.json.tmp" ] || [ -e "$openbao_root/unseal-key" ]; then + printf '%s\n' 'Preserved OpenBao initialization artifacts exist without a complete root token; inspect recovery state before retrying.' >&2 + exit 1 + fi + step 'Initialize the isolated OpenBao provider once.' \ + compose exec -T openbao sh -ec 'umask 077; bao operator init -key-shares=1 -key-threshold=1 -format=json > /run/openbao-secrets/init.json.tmp' + step 'Record the OpenBao unseal share and bootstrap token privately.' \ + tools python3 /workspace/deployment/openbao-state.py record-init /workspace/pilot/agriculture/.runtime-0.38/openbao + fi + step 'Unseal only this pilot OpenBao provider.' \ + tools python3 /workspace/deployment/openbao-state.py unseal \ + /workspace/pilot/agriculture/.runtime-0.38/openbao + step 'Wait for the unsealed OpenBao API.' wait_for http://127.0.0.1:8200/v1/sys/health OpenBao 90 + step 'Create or verify the scoped non-exportable Evidence Transit key.' \ + compose exec -T openbao sh /openbao/bootstrap.sh + step 'Bind the exact Transit version-one public key into Evidence authoring.' \ + tools python3 /workspace/deployment/openbao-state.py record-public /workspace/pilot/agriculture/.runtime-0.38 + step 'Start the official OpenBao Agent Unix-socket proxy.' compose up -d evidence-transit + step 'Wait for the private Evidence Transit socket.' \ + tools python3 -c 'import os,stat,time; p="/run/registry-evidence/transit-proxy.sock"; end=time.time()+60 +while time.time()&2 + printf '%s\n' 'The 0.38 preparation staging directory exists. Inspect it before retrying; it was preserved.' >&2 exit 1 fi (umask 077; mkdir -p "$base_dir/.runtime"; mkdir "$staging_dir") - step 'Generate private synthetic runtime inputs and run Evidence fixtures offline.' \ + step 'Generate private synthetic 0.38 inputs and offline fixtures.' \ docker run --rm --platform linux/amd64 --network none --user "$operator_uid" \ --mount "type=bind,src=$repo_dir,dst=/workspace,readonly" \ --mount "type=bind,src=$staging_dir,dst=/output" \ - registry-openfn-tools:pilot python3 /workspace/pilot/agriculture/prepare.py \ + registry-openfn-tools:pilot-038 python3 /workspace/pilot/agriculture/prepare.py \ --bin-dir /usr/local/bin --output /output/runtime mv "$staging_dir/runtime" "$runtime_dir" rmdir "$staging_dir" fi require_prepared - # Prepare only private local files, before any Docker network is needed. step 'Prepare private OpenFn credentials.' \ docker run --rm --platform linux/amd64 --network none --user "$operator_uid" \ --mount "type=bind,src=$repo_dir,dst=/workspace,readonly" \ - --mount "type=bind,src=$runtime_dir,dst=/workspace/pilot/agriculture/.runtime" \ + --mount "type=bind,src=$runtime_dir,dst=/workspace/pilot/agriculture/.runtime-0.38" \ --workdir /workspace "$node_image" node pilot/lightning/provision.mjs prepare step 'Check runtime secret ownership.' "$base_dir/check-permissions.sh" start_services @@ -95,7 +202,7 @@ case "${1:-}" in stop) require_prepared compose stop - printf '%s\n' 'Pilot stopped. Databases, history and private configuration are retained.' + printf '%s\n' 'The 0.38 pilot stopped. Its isolated databases, keys and history are retained.' ;; status) require_prepared @@ -103,18 +210,13 @@ case "${1:-}" in ;; reset) if [ "${2:-}" != '--confirm-delete-synthetic-data' ] || [ "$#" -ne 2 ]; then - printf '%s\n' 'Reset permanently deletes this pilot databases, history and private keys. To authorize it, use: deployment/pilot.sh reset --confirm-delete-synthetic-data' >&2 + printf '%s\n' 'Reset permanently deletes only the 0.38 pilot databases, history and private keys. To authorize it, use: deployment/pilot.sh reset --confirm-delete-synthetic-data' >&2 exit 2 fi require_prepared - # Validate the marker before deleting only this fixed pilot directory. - docker run --rm --platform linux/amd64 --network none --user "$operator_uid" \ - --mount "type=bind,src=$runtime_dir,dst=/runtime,readonly" "$node_image" \ - node -e 'const p=require("/runtime/prepared.json");if(p.schema!=="synthetic-agriculture-pilot/v1"||p.version!=="0.27.0")process.exit(1)' - step 'Delete explicitly authorized synthetic pilot containers and volumes.' compose down --volumes - # The path is fixed above, not supplied by an argument or an environment var. + step 'Delete explicitly authorized isolated 0.38 containers and volumes.' compose down --volumes rm -rf -- "$runtime_dir" - printf '%s\n' 'Synthetic pilot state deleted. Run setup for a new isolated pilot.' + printf '%s\n' 'The isolated 0.38 synthetic pilot state was deleted. The retained 0.27 pilot was not selected.' ;; *) printf '%s\n' 'Usage: deployment/pilot.sh setup|start|stop|status|reset --confirm-delete-synthetic-data' >&2 diff --git a/deployment/tools.sha256 b/deployment/tools.sha256 index b0eb338..37c33f2 100644 --- a/deployment/tools.sha256 +++ b/deployment/tools.sha256 @@ -1,5 +1,6 @@ -d6fc64014079206da5f849dd9775375e7f302564890c10d232565c2dfb47c060 breg-v0.27.0-linux-amd64 -3a73c1e0bfab13c25ec6ceee7a39c59d0f5c3ecf561576a7b4fe246d1ba99679 bregctl-v0.27.0-linux-amd64 -524df3e7de28032045a5fcace079697c8f1d597093b755195c14d4b420614096 evidence-v0.27.0-linux-amd64 -0f9c0886c2c418159505afbdc69d185858c6b3c2e4ee4d762ea03da9fffd871b evidencectl-v0.27.0-linux-amd64 -d50591bae3760126ae1c34e6d77311d475161d30d591646506a0d14f3f09d9d9 mint-v0.27.0-linux-amd64 +6e9404805de5df02205847f6db9bfe62f8891a08c7823b285dc6f5f3ba8a0090 breg-v0.38.0-linux-amd64 +9f922cffbf1a8997b9b287b6ad95e570e64bb36a2653ebf276a3fa1611b3a4fb bregctl-v0.38.0-linux-amd64 +7c6d3405cfd0c7c54e8895d80d4ab3fa157c793e3afc81a14df37c1f03bcec4b casework-v0.38.0-linux-amd64 +bc0f3c4925ea1780ca5b6c1d0eee168506025fe137897e55d1c4a898d72983cf caseworkctl-v0.38.0-linux-amd64 +17831dc7adcd8f3643e9058227dbe9c59443bfccd91c2be4adc6ba450eff3fbb evidence-v0.38.0-linux-amd64 +daf1be2348bf261173609be2163931d772658849c02f14eeadd1e1b36a724342 evidencectl-v0.38.0-linux-amd64 diff --git a/deployment/wait-http.py b/deployment/wait-http.py index 89fc0ca..3fb92e4 100644 --- a/deployment/wait-http.py +++ b/deployment/wait-http.py @@ -2,16 +2,18 @@ """Bounded readiness checks. Never print response bodies or credential headers.""" import sys import time +import ssl import urllib.error import urllib.request url = sys.argv[1] label = sys.argv[2] timeout = int(sys.argv[3]) if len(sys.argv) > 3 else 90 +context = ssl.create_default_context(cafile=sys.argv[4]) if len(sys.argv) > 4 else None deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: - with urllib.request.urlopen(url, timeout=3) as response: + with urllib.request.urlopen(url, timeout=3, context=context) as response: if response.status == 200: print(f'{label} ready.') sys.exit(0) diff --git a/package-lock.json b/package-lock.json index f0d8677..caa6bcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -370,68 +370,6 @@ "source-map": "^0.7.6" } }, - "node_modules/@registrystack/client": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.37.0.tgz", - "integrity": "sha512-4AR3t4CexPM6Pn0wGHZHfp7jJnTLN/03/HYVKvERjOwSh96SuShvsHNiLut+DfP2timO9cQZ2snlBqWxHOMPmg==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.37.0", - "@registrystack/client-linux-arm64-gnu": "0.37.0", - "@registrystack/client-linux-x64-gnu": "0.37.0" - } - }, - "node_modules/@registrystack/client-darwin-arm64": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.37.0.tgz", - "integrity": "sha512-3sUJmNwOeUiI6MZ7BIhFiDjGTrxUoVqY3+Us3zR6VbLfqn7x+qXsZPJyZTe9ebtdC4rw6MvCSYrE6dxG3+xbAg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.37.0.tgz", - "integrity": "sha512-akdRJ2rswfngWbV8NomICSIYBpxfJDKENgsZkqou+uD+zQDaWdzVjudp2SvCyzwFJHbNHPPjMywBJbcfj6dkZA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.37.0.tgz", - "integrity": "sha512-nmOZwb+kt46LiE9W7Sxcj+b758pPQgO2poPsoiDQrwbn4Y0E9M+WvkO8htvDIDeCPv+Lu2tW8KQS60/0lqR4BA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@types/mute-stream": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.1.tgz", @@ -2939,7 +2877,7 @@ "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2949,13 +2887,75 @@ "node": ">=22.12.0" } }, + "packages/registry-breg/node_modules/@registrystack/client": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", + "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "@registrystack/client-darwin-arm64": "0.38.0", + "@registrystack/client-linux-arm64-gnu": "0.38.0", + "@registrystack/client-linux-x64-gnu": "0.38.0" + } + }, + "packages/registry-breg/node_modules/@registrystack/client-darwin-arm64": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", + "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-breg/node_modules/@registrystack/client-linux-arm64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", + "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-breg/node_modules/@registrystack/client-linux-x64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", + "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, "packages/registry-casework": { "name": "@openfn/language-registry-casework", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2965,13 +2965,75 @@ "node": ">=22.12.0" } }, + "packages/registry-casework/node_modules/@registrystack/client": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", + "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "@registrystack/client-darwin-arm64": "0.38.0", + "@registrystack/client-linux-arm64-gnu": "0.38.0", + "@registrystack/client-linux-x64-gnu": "0.38.0" + } + }, + "packages/registry-casework/node_modules/@registrystack/client-darwin-arm64": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", + "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-casework/node_modules/@registrystack/client-linux-arm64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", + "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-casework/node_modules/@registrystack/client-linux-x64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", + "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, "packages/registry-evidence": { "name": "@openfn/language-registry-evidence", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2981,13 +3043,75 @@ "node": ">=22.12.0" } }, + "packages/registry-evidence/node_modules/@registrystack/client": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", + "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "@registrystack/client-darwin-arm64": "0.38.0", + "@registrystack/client-linux-arm64-gnu": "0.38.0", + "@registrystack/client-linux-x64-gnu": "0.38.0" + } + }, + "packages/registry-evidence/node_modules/@registrystack/client-darwin-arm64": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", + "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-evidence/node_modules/@registrystack/client-linux-arm64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", + "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-evidence/node_modules/@registrystack/client-linux-x64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", + "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, "packages/registry-relay": { "name": "@openfn/language-registry-relay", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2996,6 +3120,68 @@ "engines": { "node": ">=22.12.0" } + }, + "packages/registry-relay/node_modules/@registrystack/client": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", + "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "@registrystack/client-darwin-arm64": "0.38.0", + "@registrystack/client-linux-arm64-gnu": "0.38.0", + "@registrystack/client-linux-x64-gnu": "0.38.0" + } + }, + "packages/registry-relay/node_modules/@registrystack/client-darwin-arm64": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", + "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-relay/node_modules/@registrystack/client-linux-arm64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", + "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } + }, + "packages/registry-relay/node_modules/@registrystack/client-linux-x64-gnu": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", + "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=22.12.0" + } } } } diff --git a/packages/registry-breg/README.md b/packages/registry-breg/README.md index bd62b31..3eef939 100644 --- a/packages/registry-breg/README.md +++ b/packages/registry-breg/README.md @@ -1,7 +1,7 @@ # @openfn/language-registry-breg OpenFn operations over the native `breg` namespace in the exact published -`@registrystack/client@0.37.0`. The adaptor uses +`@registrystack/client@0.38.0`. The adaptor uses `@openfn/language-common@3.3.4` and reexports its composition helpers. It does not implement Registry authorization or an alternative HTTP client. @@ -86,7 +86,7 @@ through the native client, and requires exactly one matching operation/stage and the caller's held `ifMatch`. A changed proposal fails before any action POST. Supported operation names are `submit_request`, `revise_request`, `cancel_request`, and `apply_request`, subject to the current metadata, actor -and record state. Casework owns review decisions in Registry Stack 0.37; +and record state. Casework owns review decisions in Registry Stack 0.38; approval and rejection are not BREG lifecycle actions. Create a governed correction with `createChangeRequest`, read the created request @@ -132,5 +132,5 @@ copies bounded, plain JSON across the OpenFn VM boundary into the SDK realm; opaque handles stay local. Tests cover native lookup/read/write/lifecycle HTTP contracts, continuation, precondition refusal, failure redaction and runtime credential removal. Private-key JWT configuration and token transport failure -are exercised; a successful live Mint exchange and real PostgreSQL governance +are exercised; a successful live OAuth exchange and real PostgreSQL governance are integration checks outside these mock tests. diff --git a/packages/registry-breg/package.json b/packages/registry-breg/package.json index b25c761..e569c3f 100644 --- a/packages/registry-breg/package.json +++ b/packages/registry-breg/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-breg/test/breg.test.js b/packages/registry-breg/test/breg.test.js index 525b023..78c766c 100644 --- a/packages/registry-breg/test/breg.test.js +++ b/packages/registry-breg/test/breg.test.js @@ -131,7 +131,7 @@ test("safe stable failures distinguish not found, denied, conflict and temporary } }); -test("0.37 located request and query refusals stay invalid requests without exposing paths", async () => { +test("0.38 located request and query refusals stay invalid requests without exposing paths", async () => { for (const [code, detail, fieldPath] of [ ["request.invalid", "The request is invalid.", "/data/legalName"], ["query.invalid", "The query request is invalid.", "$select"], diff --git a/packages/registry-casework/README.md b/packages/registry-casework/README.md index 632fc8c..4481263 100644 --- a/packages/registry-casework/README.md +++ b/packages/registry-casework/README.md @@ -1,7 +1,7 @@ # Registry Casework Requester adaptor This OpenFn adaptor wraps the Requester review API in the published -Registry Stack Node client 0.37.0. It creates or recovers review requests, +Registry Stack Node client 0.38.0. It creates or recovers review requests, reads request state and correlated results, adds requester-visible notes, pages requester-visible history and result feeds, and cancels an accepted request. It also preserves the maintained source-inbox and task-grant inspection surface @@ -79,7 +79,7 @@ credentials, configuration, and caller inputs are not copied into the result. ## Request body -The create request follows the native 0.37.0 contract. For submitted context: +The create request follows the native 0.38.0 contract. For submitted context: ```json { @@ -133,7 +133,7 @@ execute( ## Verification -The package check exercises fake bindings and the installed 0.37.0 native +The package check exercises fake bindings and the installed 0.38.0 native client against a loopback HTTP service: ```sh diff --git a/packages/registry-casework/package.json b/packages/registry-casework/package.json index d2a199a..a1c8da8 100644 --- a/packages/registry-casework/package.json +++ b/packages/registry-casework/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-casework/test/casework.test.js b/packages/registry-casework/test/casework.test.js index 6508915..bedbfc9 100644 --- a/packages/registry-casework/test/casework.test.js +++ b/packages/registry-casework/test/casework.test.js @@ -577,7 +577,7 @@ test("package pins the published native client and authentication schema", () => const schema = JSON.parse( readFileSync(new URL("../configuration-schema.json", import.meta.url), "utf8"), ); - assert.equal(manifest.dependencies["@registrystack/client"], "0.37.0"); + assert.equal(manifest.dependencies["@registrystack/client"], "0.38.0"); assert.deepEqual(schema.required, ["baseUrl", "profile"]); assert.deepEqual(schema.oneOf, [ { required: ["token"] }, diff --git a/packages/registry-evidence/README.md b/packages/registry-evidence/README.md index e9bbca1..0166faa 100644 --- a/packages/registry-evidence/README.md +++ b/packages/registry-evidence/README.md @@ -12,7 +12,7 @@ as: ## Client packaging -This package pins `@registrystack/client` to **0.37.0** and uses its `evidence` +This package pins `@registrystack/client` to **0.38.0** and uses its `evidence` namespace. Supported native targets are macOS arm64, Linux amd64 glibc, and Linux arm64 glibc. Alpine/musl is unsupported. Use a glibc OpenFn worker image. diff --git a/packages/registry-evidence/package.json b/packages/registry-evidence/package.json index 79b45f9..6a35127 100644 --- a/packages/registry-evidence/package.json +++ b/packages/registry-evidence/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-evidence/src/index.js b/packages/registry-evidence/src/index.js index e9e2f97..c8188c1 100644 --- a/packages/registry-evidence/src/index.js +++ b/packages/registry-evidence/src/index.js @@ -120,7 +120,7 @@ export async function callEvidence(state, request) { requirement: request.requirement, assertion: verified.evidence, jws: verified.assertion.toString("utf8"), - // The separately locked 0.27 pilot predates retained verification. + // Keep retained verification bytes when the native result exposes them. ...(verified.retainedVerification === undefined ? {} : { retained_verification: verified.retainedVerification.toString("base64"), }), diff --git a/packages/registry-relay/README.md b/packages/registry-relay/README.md index 3ce05f2..9d9b734 100644 --- a/packages/registry-relay/README.md +++ b/packages/registry-relay/README.md @@ -1,6 +1,6 @@ # OpenFn Registry Relay Adaptor -OpenFn operations for Registry Relay 0.37.0. The adaptor uses the published +OpenFn operations for Registry Relay 0.38.0. The adaptor uses the published `@registrystack/client` Relay namespace, so route construction, continuations, response validation, authentication, and protocol failures follow the maintained Relay V2 client contract. @@ -105,7 +105,7 @@ describes one governed resource and the operations Relay actually exposes. ## Aggregate data -Relay 0.37 publishes aggregate data through its SDMX 2.1 surface: +Relay 0.38 publishes aggregate data through its SDMX 2.1 surface: ```js queryAggregate({ diff --git a/packages/registry-relay/package.json b/packages/registry-relay/package.json index 65c1172..a9a70e4 100644 --- a/packages/registry-relay/package.json +++ b/packages/registry-relay/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.37.0" + "@registrystack/client": "0.38.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-relay/test/relay.test.js b/packages/registry-relay/test/relay.test.js index bd75ef7..2cb0ea6 100644 --- a/packages/registry-relay/test/relay.test.js +++ b/packages/registry-relay/test/relay.test.js @@ -83,7 +83,7 @@ function baseState(data = {}) { }; } -test("getRecord uses the 0.37 native V2 route and preserves composition credentials", async () => { +test("getRecord uses the 0.38 native V2 route and preserves composition credentials", async () => { requests.length = 0; const state = await getRecord({ resource: "farmers", diff --git a/pilot/agriculture/README.md b/pilot/agriculture/README.md index 2fe0f55..61ff210 100644 --- a/pilot/agriculture/README.md +++ b/pilot/agriculture/README.md @@ -2,19 +2,22 @@ This project adapts the maintained Registry Stack agricultural-holdings core starter. It retains its Farm, holder, responsibility and reviewed name-correction -model, including the original PublicSchema attribution. All supplied names, +model, including the original PublicSchema attribution. The old 0.27 runtime and databases are +retained separately; the current pilot starts with fresh synthetic records. All supplied names, identifiers, inputs and identities are synthetic. The complete deployment and OpenFn workflows are described in the repository -README. `prepare.py --bin-dir ` creates an ignored, -owner-only `.runtime` directory. It needs Python 3 with PyYAML and OpenSSL. +README. `prepare.py --bin-dir ` creates an ignored, +owner-only `.runtime-0.38` directory. It needs Python 3 with PyYAML and OpenSSL. Preparation generates fresh keys, fixed service bindings, separate database roles, -a direct BREG source export, a compiled Evidence bundle and a native client -profile. It executes eight Evidence fixtures before producing the deployment. +a direct BREG source export and Evidence authoring inputs. It checks the authored +BREG and Casework contracts and runs Evidence fixtures. +The deployed Evidence package is compiled after the persistent Transit signer +is initialized, with its exact public key and explicit native client configuration. It does not connect to a database or start services. Existing output is refused; normal restart reuses it and retains records and credentials. -`initialize.py` runs inside the deployment tools container after the pilot Mint +`initialize.py` runs inside the deployment tools container after the pilot ThunderID and PostgreSQL are ready. It uses `bregctl test`, `package`, `apply --initial`, and `verify`. The schema test database is separate from the pilot record database. It never resets an existing database or clears a migration interlock. An @@ -25,9 +28,9 @@ interrupted activation needs the maintained BREG recovery procedure. - `openfn-service` creates farms, resolves their unique `localIdentifier`, and creates/submits its own corrections. It has no review, application, direct farm patch, collection listing, or holder maintenance authority. -- The separate `reviewer` principal can inspect, approve and explicitly apply a - proposed correction. `excludeSubmitter: true` and manual application remain - authored BREG requirements. OpenFn receives no reviewer private key. +- The separate `reviewer` principal can inspect, approve in Casework and explicitly apply a + proposed correction. `excludeInitiator: true` and manual application remain + authored Casework and BREG requirements. OpenFn receives no reviewer private key. - `evidence-source` can only perform an exact Farm lookup returning `localIdentifier`. It cannot read names, list records or write. - `holding-registered` signs one Boolean concept. It means a unique holding is @@ -49,7 +52,8 @@ Copy the correction request UUID from the registration/correction workflow resul Run these commands through the deployment's tools service, which has the private reviewer credential. `inspect` prints the synthetic before/after proposal and saves that exact response. An approval or application uses its advertised action, -proposal version, effect digest and lifecycle ETag. Stale snapshots are refused. +Casework context and task revision, followed by the BREG proposal version, effect +digest and lifecycle ETag. Stale snapshots are refused. ```sh deployment/compose.sh run --rm tools \ @@ -71,8 +75,8 @@ Repeating a correction with changed content requires a new submission identifier ## Native verification The retained starter journey checks typed holder relationships and dates, and the -reviewed correction lifecycle. The integration journey exercises the new service -creation and both authorized exact lookups. The real HTTP smoke separately proves +reviewed correction model. The real HTTP smoke exercises service creation and +both authorized exact lookups, and proves refusal of direct farm patch and Evidence-source creation, source name exclusion, reviewer approval without mutation, explicit application, a cryptographically verified registered assertion, and missing-record unavailability: @@ -80,10 +84,15 @@ verified registered assertion, and missing-record unavailability: ```sh deployment/compose.sh run --rm tools \ python3 /opt/pilot/agriculture/smoke.py +deployment/compose.sh run --rm --no-deps sdk-tools \ + node /opt/pilot/agriculture/evidence-smoke.mjs ``` The smoke uses `SYNTHETIC-DIRECT-SMOKE-001`, separate from the workflow input farm. -It retains that synthetic record and privately stores request verification contexts -under `.runtime/breg/smoke`. Repeated smoke runs create fresh synthetic correction +It retains that synthetic record and privately stores reports and review snapshots +under `.runtime-0.38/breg`. The Evidence check uses the released Node SDK with +pinned signing keys and the exact packaged verification policy. The local HTTPS +endpoint uses explicit configuration because 0.38 progressive profiles refuse +private HTTPS origins. Repeated smoke runs create fresh synthetic correction proposals. They do not reset a database. Its committed events can reach the pilot workflow once the event bridge is enabled. diff --git a/pilot/agriculture/casework/casework.yaml b/pilot/agriculture/casework/casework.yaml new file mode 100644 index 0000000..cf6aeef --- /dev/null +++ b/pilot/agriculture/casework/casework.yaml @@ -0,0 +1,73 @@ +apiVersion: registry.registrystack.org/casework/v1alpha1 +kind: CaseworkProject +casework: + id: agriculture-review + version: "1" +accessProfiles: + - id: reviewer + principalClaim: registry_principal + requiredScopes: [casework:staff] + role: staff + - id: supervisor + principalClaim: registry_principal + requiredScopes: [casework:supervisor] + role: supervisor + - id: administrator + principalClaim: registry_principal + requiredScopes: [casework:admin] + role: administrator + - id: integration-requester + principalClaim: sub + requiredScopes: [casework:reviews:request] + role: requester +sources: + - id: agricultural-holdings + adapter: breg + description: sources/agricultural-holdings.json + requests: + - entity: name-correction + queue: corrections + contextProjection: + - record + - name + - reason + - supporting-reference +queues: + - id: corrections + label: Agricultural holding name corrections +reviewKinds: + - id: name-correction + version: "1" + purpose: approval + contextStrategy: source + stages: + - id: review + queue: corrections + decidingProfiles: [reviewer] + excludeInitiator: true + requiredApprovals: 1 + retention: + terminalDays: 30 + accountabilityDays: 365 + displaySchema: + type: object + additionalProperties: false + properties: + record: {type: string, format: uuid} + name: {type: string, minLength: 1, maxLength: 160} + reason: {type: string, minLength: 1, maxLength: 500} + supportingReference: {type: string, minLength: 1, maxLength: 500} + outcomes: + - id: changes-requested + label: Request changes + settlement: changes_requested + reasonRequired: true +reviewProducers: + - id: registry-breg + profile: integration-requester + issuer: https://127.0.0.1:8443 + subject: __CASEWORK_PRODUCER_SUBJECT__ + trustedInitiatorIssuer: https://127.0.0.1:8443 + sourceNamespaces: [agricultural-holdings] + kinds: [name-correction] + recoveryDays: 7 diff --git a/pilot/agriculture/casework/dev-clients.yaml b/pilot/agriculture/casework/dev-clients.yaml new file mode 100644 index 0000000..6f640dc --- /dev/null +++ b/pilot/agriculture/casework/dev-clients.yaml @@ -0,0 +1,23 @@ +version: 1 +clients: + - id: casework-admin + accessProfile: administrator + scopes: [casework:admin] + claims: + registry_actor_kind: human + registry_principal: synthetic-casework-admin + - id: reviewer + accessProfile: reviewer + scopes: [casework:staff] + claims: + registry_actor_kind: human + registry_principal: synthetic-reviewer + - id: casework-producer + accessProfile: integration-requester + scopes: [casework:reviews:request] + claims: {} +directory: + - team: agriculture-reviewers + queue: corrections + staff: [reviewer] + supervisors: [] diff --git a/pilot/agriculture/casework/fixtures/name-correction.yaml b/pilot/agriculture/casework/fixtures/name-correction.yaml new file mode 100644 index 0000000..46dcf24 --- /dev/null +++ b/pilot/agriculture/casework/fixtures/name-correction.yaml @@ -0,0 +1,10 @@ +apiVersion: registry.registrystack.org/casework-fixture/v1alpha1 +kind: CaseworkFixture +name: agriculture-name-correction-offline +source: + id: agricultural-holdings + requestEntity: name-correction + reviewStage: review +expect: + queue: corrections + applicationMode: manual diff --git a/pilot/agriculture/casework/sources/agricultural-holdings.breg-runtime.yaml b/pilot/agriculture/casework/sources/agricultural-holdings.breg-runtime.yaml new file mode 100644 index 0000000..36a852b --- /dev/null +++ b/pilot/agriculture/casework/sources/agricultural-holdings.breg-runtime.yaml @@ -0,0 +1,26 @@ +# Candidate BReg operator binding. Review the endpoints and secret references, then merge this into launcher-owned runtime config. +eventDestinations: + casework: + origin: http://localhost:8100 + path: /events/sources/agricultural-holdings + networkProfile: loopbackDevelopmentHttp + dnsFamily: ipv4Only + allowedPrivateCidrs: [] + hmacSha256KeyRef: secret:file/breg-casework-webhook + classificationCeiling: restricted + deliveryCeilings: + attemptTimeoutMilliseconds: 5000 + maximumAttempts: 5 +reviewAuthorities: + "casework": + endpoint: https://casework.example.test + profile: "integration-requester" + producerId: "registry-breg" + recoveryDays: 7 + privateKeyJwt: + tokenEndpoint: https://identity.example.test/oauth2/token + clientIdRef: secret:file/casework-producer-client-id + clientAssertionKeyRef: secret:file/casework-producer-private-jwk + assertionAudience: https://identity.example.test + resource: https://casework.example.test + scopes: [casework:reviews:request] diff --git a/pilot/agriculture/casework/sources/agricultural-holdings.json b/pilot/agriculture/casework/sources/agricultural-holdings.json new file mode 100644 index 0000000..1b03df6 --- /dev/null +++ b/pilot/agriculture/casework/sources/agricultural-holdings.json @@ -0,0 +1,148 @@ +{ + "apiVersion": "registry.registrystack.org/casework-source-description/v1alpha1", + "authority": "none", + "kind": "BRegCaseworkSourceDescription", + "origin": "bregctl explain change-requests", + "request": { + "actions": [ + { + "method": "POST", + "operation": "submit_request", + "preconditions": [ + "Idempotency-Key", + "If-Match" + ], + "routeId": "records.name-correction.request.submit" + }, + { + "method": "POST", + "operation": "revise_request", + "preconditions": [ + "Idempotency-Key", + "If-Match" + ], + "routeId": "records.name-correction.request.revise" + }, + { + "method": "POST", + "operation": "cancel_request", + "preconditions": [ + "Idempotency-Key", + "If-Match" + ], + "routeId": "records.name-correction.request.cancel" + }, + { + "method": "POST", + "operation": "apply_request", + "preconditions": [ + "Idempotency-Key", + "If-Match", + "proposalVersion", + "effectDigest" + ], + "routeId": "records.name-correction.request.apply" + } + ], + "application": {}, + "applyPermissions": [ + { + "profile": "reviewer", + "rowBoundaries": [], + "targetEntity": "farm" + } + ], + "bounds": { + "maximumFieldMutations": 128, + "maximumSnapshotBytes": 2097152, + "maximumTargets": 16 + }, + "contractFingerprint": "sha256:258d82787cbdf4506778cf9deac111b3bf7540f926b312ebc9b083fab0ee2b03", + "effects": [ + { + "dependsOn": [], + "fields": [ + { + "kind": "set", + "target": { + "apiName": "name", + "field": "name" + }, + "value": { + "field": { + "apiName": "name", + "field": "name" + }, + "kind": "from_field" + } + } + ], + "id": "effect-1", + "operation": "patch", + "target": { + "binding": { + "fromField": { + "apiName": "record", + "field": "record" + }, + "kind": "existing" + }, + "entity": "farm" + } + } + ], + "fields": [ + { + "apiName": "record", + "field": "record", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "apiName": "name", + "field": "name", + "schema": { + "maxLength": 160, + "minLength": 1, + "type": "string" + } + }, + { + "apiName": "reason", + "field": "reason", + "schema": { + "maxLength": 500, + "minLength": 1, + "type": "string" + } + }, + { + "apiName": "supportingReference", + "field": "supporting-reference", + "schema": { + "maxLength": 500, + "minLength": 1, + "type": "string" + } + } + ], + "onApproved": { + "mode": "manual" + }, + "planner": { + "abi": "registry.change-request-plan/v1", + "kind": "declarative" + }, + "presencePermissions": [], + "requestEntity": "name-correction", + "requestRoute": "name-corrections", + "review": { + "authority": "casework", + "policyId": "name-correction" + } + }, + "sourceId": "agricultural-holdings", + "sourceRevision": "sha256:15b42deee1cb724bee342be3167e722794ee067aca81406215440eedda735720" +} diff --git a/pilot/agriculture/evidence-client.py b/pilot/agriculture/evidence-client.py new file mode 100644 index 0000000..13f0f4f --- /dev/null +++ b/pilot/agriculture/evidence-client.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Generate the private explicit Evidence client binding from its governed bundle.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys + +import yaml + + +DEFAULT_ROOT = Path("/workspace/pilot/agriculture/.runtime-0.38") +SCHEMA = "synthetic-agriculture-evidence-client/v1" +PRIVATE_JWK_FIELDS = {"d", "p", "q", "dp", "dq", "qi", "oth", "k"} +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") + + +def _load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise RuntimeError(f"invalid pilot JSON document: {path.name}") from error + + +def _load_yaml(path: Path) -> object: + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError) as error: + raise RuntimeError(f"invalid governed Evidence document: {path.name}") from error + + +def _one(items: object, label: str) -> dict: + if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict): + raise RuntimeError(f"the governed Evidence bundle must define one {label}") + return items[0] + + +def _sole(items: object, label: str) -> object: + if not isinstance(items, list) or len(items) != 1: + raise RuntimeError(f"the governed Evidence bundle must define one {label}") + return items[0] + + +def _string(value: object, label: str) -> str: + if not isinstance(value, str) or not value or len(value) > 2048: + raise RuntimeError(f"the governed Evidence bundle has an invalid {label}") + return value + + +def _relative_file(root: Path, value: object, label: str) -> Path: + relative = PurePosixPath(_string(value, label)) + if relative.is_absolute() or ".." in relative.parts: + raise RuntimeError(f"{label} must be a runtime-relative file") + unresolved = root / Path(*relative.parts) + if unresolved.is_symlink(): + raise RuntimeError(f"{label} must not be a symbolic link") + path = unresolved.resolve(strict=True) + if root != path.parent and root not in path.parents: + raise RuntimeError(f"{label} leaves the selected runtime") + if not path.is_file() or path.is_symlink(): + raise RuntimeError(f"{label} must name a regular runtime file") + return path + + +def _atomic_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(path.parent, 0o700) + body = json.dumps(value, indent=2, sort_keys=True) + "\n" + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(body) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _bundle_check(candidate: Path) -> dict: + try: + result = subprocess.run( + ["evidence", "bundle-check", "--bundle", str(candidate), "--json"], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=30, + env={key: value for key, value in os.environ.items() if key != "REGISTRY_EVIDENCE_RUNTIME"}, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise RuntimeError("the released Evidence bundle validator did not complete") from error + if result.returncode: + raise RuntimeError("the released Evidence runtime rejected the governed bundle") + try: + report = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError("the released Evidence runtime returned an invalid bundle report") from error + if not isinstance(report, dict) or not DIGEST.fullmatch(str(report.get("packageDigest", ""))): + raise RuntimeError("the Evidence bundle report has an invalid package digest") + return report + + +def _public_jwk(value: object, label: str) -> dict: + if not isinstance(value, dict) or PRIVATE_JWK_FIELDS.intersection(value): + raise RuntimeError(f"{label} is not a public JWK") + if ( + value.get("kty") != "EC" + or value.get("crv") != "P-256" + or value.get("alg") not in (None, "ES256") + or not isinstance(value.get("kid"), str) + or not isinstance(value.get("x"), str) + or not isinstance(value.get("y"), str) + ): + raise RuntimeError(f"{label} is not the expected ES256 public JWK") + return value + + +def prepare_client(runtime_root: Path) -> dict: + """Write the exact explicit Node client configuration and request policy.""" + root = Path(runtime_root).resolve(strict=True) + candidate = root / "evidence" / "candidate" + bundle = _load_yaml(candidate / "evidence.yaml") + if not isinstance(bundle, dict) or bundle.get("version") != 1: + raise RuntimeError("the governed Evidence bundle has an unsupported version") + if bundle.get("assuranceProfile") != "production": + raise RuntimeError("the pilot requires the production Evidence assurance profile") + + requirement = _one(bundle.get("requirements"), "requirement") + requirement_id = _string(requirement.get("id"), "requirement id") + purpose = _string(_sole(requirement.get("purposes"), "requirement purpose"), "purpose") + evidence_type = _string(requirement.get("evidenceType"), "evidence type") + subject_role = _one(requirement.get("subjectRoles"), "subject role") + if subject_role.get("cardinality") != "one": + raise RuntimeError("the pilot Evidence subject role must have cardinality one") + role = _string(subject_role.get("role"), "subject role") + selector_profile = _string( + _sole(subject_role.get("selectorProfiles"), "subject selector profile"), + "subject selector profile", + ) + selector_profiles = bundle.get("selectorProfiles") + if not isinstance(selector_profiles, dict) or not isinstance(selector_profiles.get(selector_profile), dict): + raise RuntimeError("the governed Evidence selector profile is missing") + selector_fields = selector_profiles[selector_profile].get("fields") + if not isinstance(selector_fields, dict) or list(selector_fields) != ["local-identifier"]: + raise RuntimeError("the pilot requires the governed local-identifier selector") + + issuer = _string((bundle.get("issuer") or {}).get("id"), "issuer") + provider = _string((bundle.get("service") or {}).get("providerId"), "provider") + base_url = _string((bundle.get("service") or {}).get("publicOrigin"), "public origin") + if base_url != provider or base_url != "https://127.0.0.1:8445": + raise RuntimeError("the Evidence provider and public origin must be the fixed HTTPS pilot endpoint") + + signing = bundle.get("signing") + if not isinstance(signing, dict) or signing.get("algorithm") != "ES256": + raise RuntimeError("the governed Evidence bundle must use ES256") + if signing.get("format") != "flattened-jws-json": + raise RuntimeError("the governed Evidence bundle must use flattened JWS JSON") + active_path = _relative_file(candidate, signing.get("activePublicJwkFile"), "active public JWK") + active_jwk = _public_jwk(_load_json(active_path), "active public JWK") + transit_jwk = _public_jwk( + _load_json(root / "evidence" / "transit-public" / "evidence-signing.jwk.json"), + "Transit public JWK", + ) + if active_jwk != transit_jwk: + raise RuntimeError("the governed Evidence key differs from the retained Transit public key") + revoked = signing.get("revokedKeyIds") + if not isinstance(revoked, list) or not all(isinstance(value, str) and value for value in revoked): + raise RuntimeError("the governed Evidence revoked-key list is invalid") + + grant_profiles = bundle.get("authorityProfiles") + if not isinstance(grant_profiles, dict) or len(grant_profiles) != 1: + raise RuntimeError("the governed Evidence bundle must define one authority profile") + profile = next(iter(grant_profiles.values())) + if not isinstance(profile, dict) or profile.get("kind") != "explicit-request": + raise RuntimeError("the governed Evidence authority must use explicit requests") + grant = _one(profile.get("grants"), "authority grant") + granted_subject = _one(grant.get("subjects"), "grant subject") + if ( + grant.get("requirement") != requirement_id + or grant.get("purpose") != purpose + or grant.get("audienceFrom") != "authenticated-requester" + or grant.get("responseFormats") != ["signed-jws"] + or granted_subject.get("role") != role + or granted_subject.get("selectorProfile") != selector_profile + or granted_subject.get("valueOrigin") != "request" + ): + raise RuntimeError("the governed Evidence authority grant differs from the request policy") + + report = _bundle_check(candidate) + revision_entry = _one(report.get("requirements"), "configuration revision") + revision = revision_entry.get("configurationRevision") + if revision_entry.get("id") != requirement_id or not isinstance(revision, str) or not DIGEST.fullmatch(revision): + raise RuntimeError("the Evidence bundle report does not bind the governed requirement") + + expected_outputs = [] + for concept in requirement.get("concepts", []): + if not isinstance(concept, dict): + raise RuntimeError("the governed Evidence concept is invalid") + expected_outputs.append({ + "handle": _string(concept.get("handle"), "concept handle"), + "concept": _string(concept.get("id"), "concept id"), + "required": concept.get("required"), + "form": concept.get("form"), + }) + if not expected_outputs or any( + output["required"] is not True or output["form"] not in {"boolean", "string", "number", "integer"} + for output in expected_outputs + ): + raise RuntimeError("the governed Evidence concepts are not supported by this pilot binding") + + index = _load_json(root / "issuer" / "clients.json") + if not isinstance(index, dict) or index.get("schema") != "synthetic-agriculture-thunderid/v1": + raise RuntimeError("the retained issuer client index is invalid") + clients = index.get("clients") + client = clients.get("openfn-evidence") if isinstance(clients, dict) else None + if not isinstance(client, dict): + raise RuntimeError("the retained Evidence OAuth client is missing") + scopes = client.get("scopes") + claims = client.get("claims") + if scopes != ["evidence:request"] or not isinstance(claims, dict): + raise RuntimeError("the retained Evidence OAuth client has unexpected permissions") + audience = _string(claims.get("evidence_audience"), "Evidence audience") + private_key_path = _relative_file(root, client.get("privateJwk"), "Evidence client private JWK") + if private_key_path.stat().st_mode & 0o077: + raise RuntimeError("the retained Evidence OAuth client key is not owner-only") + private_key = _load_json(private_key_path) + if ( + not isinstance(private_key, dict) + or private_key.get("kty") != "EC" + or private_key.get("crv") != "P-256" + or private_key.get("alg") not in (None, "ES256") + or not all( + isinstance(private_key.get(field), str) and private_key[field] + for field in ("kid", "x", "y", "d") + ) + ): + raise RuntimeError("the retained Evidence OAuth client key is invalid") + ca_path = _relative_file(root, index.get("caBundle"), "pilot CA bundle") + ca_bundle = ca_path.read_text(encoding="utf-8") + if ca_bundle.count("-----BEGIN CERTIFICATE-----") != 1 or not ca_bundle.endswith("\n"): + raise RuntimeError("the retained pilot CA bundle is invalid") + + token_endpoint = _string(index.get("tokenEndpoint"), "token endpoint") + assertion_audience = _string(index.get("clientAssertionAudience"), "client assertion audience") + if token_endpoint != "https://127.0.0.1:8443/oauth2/token" or assertion_audience != "https://127.0.0.1:8443": + raise RuntimeError("the retained issuer endpoints differ from the fixed HTTPS pilot endpoints") + resource = _string(client.get("resource"), "OAuth resource") + if resource != index.get("resource"): + raise RuntimeError("the retained Evidence OAuth resource differs from the issuer resource") + + configuration = { + "baseUrl": base_url, + "trustedJwks": {"keys": [active_jwk]}, + "revokedKeyIds": revoked, + "token": {"privateKeyJwt": { + "tokenEndpoint": token_endpoint, + "clientId": _string(client.get("clientId"), "OAuth client id"), + "clientKey": private_key, + "audience": assertion_audience, + "resource": resource, + "scopes": scopes, + "trustedRootCertificates": ca_bundle, + }}, + "trustedRootCertificates": ca_bundle, + } + request = { + "responseFormat": "signed-jws", + "requirement": requirement_id, + "purpose": purpose, + "audience": audience, + "evidenceType": evidence_type, + "issuedBy": issuer, + "providedBy": provider, + "configurationRevision": revision, + "expectedAssuranceProfile": "production", + "subjects": [{ + "role": role, + "selectorProfile": selector_profile, + "selectorValues": { + "local-identifier": {"valueFrom": "data.values.local-identifier"}, + }, + }], + "expectedOutputs": expected_outputs, + "maximumAssertionLifetimeSeconds": signing.get("maximumAssertionValiditySeconds"), + "clockSkewSeconds": signing.get("verifierClockSkewSeconds"), + "subjectExpectations": "acceptFirstUse", + } + if not isinstance(request["maximumAssertionLifetimeSeconds"], int) or not 1 <= request["maximumAssertionLifetimeSeconds"] <= 31_536_000: + raise RuntimeError("the governed Evidence assertion lifetime is invalid") + if not isinstance(request["clockSkewSeconds"], int) or not 0 <= request["clockSkewSeconds"] <= 300: + raise RuntimeError("the governed Evidence clock skew is invalid") + + generated = { + "schema": SCHEMA, + "packageDigest": report["packageDigest"], + "evidence": { + "configuration": {"evidence": configuration}, + "request": request, + }, + } + _atomic_json(root / "evidence-client" / "generated.json", generated) + _atomic_json(root / "openfn" / "evidence-credential.json", {"evidence": configuration}) + return generated + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) + arguments = parser.parse_args() + os.umask(0o077) + prepare_client(arguments.root) + print("Explicit Evidence client binding prepared.") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"Evidence client preparation stopped: {error}", file=sys.stderr) + sys.exit(1) diff --git a/pilot/agriculture/evidence-smoke.mjs b/pilot/agriculture/evidence-smoke.mjs new file mode 100644 index 0000000..9fa2102 --- /dev/null +++ b/pilot/agriculture/evidence-smoke.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync, statSync } from 'node:fs'; + +const GENERATED = '/config/evidence-client/generated.json'; +const CA_BUNDLE = '/run/pilot-ca.pem'; +const IDENTIFIER = 'SYNTHETIC-DIRECT-SMOKE-001'; +const MISSING_IDENTIFIER = 'SYNTHETIC-DIRECT-SMOKE-MISSING'; + +const require = createRequire('/opt/registry-adaptors/package.json'); +const { EvidenceClient, EvidenceClientError } = require('@registrystack/client').evidence; +const clientPackage = require('/opt/registry-adaptors/node_modules/@registrystack/client/package.json'); + +assert.equal(clientPackage.version, '0.38.0'); +assert.equal(process.env.SSL_CERT_FILE, undefined, 'explicit client proof must not inherit SSL_CERT_FILE'); +assert.equal(process.env.NODE_EXTRA_CA_CERTS, undefined, 'explicit client proof must not inherit NODE_EXTRA_CA_CERTS'); +assert.equal(statSync(GENERATED).mode & 0o777, 0o600, 'generated client binding must remain owner-only'); + +const generated = JSON.parse(readFileSync(GENERATED, 'utf8')); +assert.equal(generated.schema, 'synthetic-agriculture-evidence-client/v1'); +const configuration = generated?.evidence?.configuration?.evidence; +const requestTemplate = generated?.evidence?.request; +assert.ok(configuration && requestTemplate, 'generated explicit Evidence binding is incomplete'); +const mountedCa = readFileSync(CA_BUNDLE, 'utf8'); +assert.equal(configuration.trustedRootCertificates, mountedCa, 'Evidence service CA is not the pinned pilot CA'); +assert.equal( + configuration.token?.privateKeyJwt?.trustedRootCertificates, + mountedCa, + 'token endpoint CA is not the pinned pilot CA', +); + +const client = new EvidenceClient(configuration); + +function requestFor(identifier) { + const request = structuredClone(requestTemplate); + assert.equal(request.subjects.length, 1); + assert.deepEqual(request.subjects[0].selectorValues, { + 'local-identifier': { valueFrom: 'data.values.local-identifier' }, + }); + request.subjects[0].selectorValues = { 'local-identifier': identifier }; + return request; +} + +// Constructor and prepare are deliberately available as an offline image gate. +// The default acceptance continues through token exchange, request and verify. +if (process.argv.includes('--construct-only')) { + client.prepare(requestFor(IDENTIFIER)); + console.log(JSON.stringify({ verified: 'explicit-evidence-client-construction', sdk: clientPackage.version })); + process.exit(0); +} + +const prepared = client.prepare(requestFor(IDENTIFIER)); +const raw = await client.send(prepared); +const verified = client.verify(prepared, raw); +assert.ok(Buffer.isBuffer(raw.body) && raw.body.length > 0, 'Evidence returned no signed response'); +assert.deepEqual(verified.evidence.supportedValues, [{ + providesValueFor: 'urn:example:concept:holding-registered:registered', + value: true, +}]); +assert.equal(verified.pinnedSubjectExpectations.length, 1); +assert.equal(verified.pinnedSubjectExpectations[0].role, 'subject'); +const minimized = JSON.stringify(verified.evidence); +assert.equal(minimized.includes(IDENTIFIER), false, 'signed assertion disclosed the source identifier'); +assert.equal(minimized.includes('SYNTHETIC-PRIVATE-NAME-CANARY'), false, 'signed assertion disclosed a private source field'); + +const missing = client.prepare(requestFor(MISSING_IDENTIFIER)); +let unavailable; +try { + await client.send(missing); +} catch (error) { + unavailable = error; +} +assert.ok(unavailable instanceof EvidenceClientError, 'missing source did not return a native Evidence error'); +assert.equal(unavailable.kind, 'not_available', 'missing source was not mapped to Evidence unavailability'); + +console.log(JSON.stringify({ + verified: 'native-explicit-evidence-send-and-verify', + assurance: requestTemplate.expectedAssuranceProfile, + unavailable: unavailable.kind, + sdk: clientPackage.version, +})); diff --git a/pilot/agriculture/evidence-starter/targets/local/settings.yaml b/pilot/agriculture/evidence-starter/targets/local/settings.yaml deleted file mode 100644 index 570c6aa..0000000 --- a/pilot/agriculture/evidence-starter/targets/local/settings.yaml +++ /dev/null @@ -1,135 +0,0 @@ -{ - "formatVersion": 1, - "governance": { - "version": 1, - "assuranceProfile": "local", - "service": { - "providerId": "urn:example:provider:record-evidence", - "trustDomain": "urn:example:trust:record-evidence", - "publicOrigin": "http://127.0.0.1:8080" - }, - "issuer": { - "id": "urn:example:issuer:record-evidence" - }, - "authentication": { - "kind": "oidc-access-token", - "issuer": "http://127.0.0.1:8091", - "audiences": [ - "urn:example:audience:agriculture-pilot" - ], - "tokenTypes": [ - "at+jwt" - ], - "algorithms": [ - "ES256" - ], - "jwksUri": "http://127.0.0.1:8091/.well-known/jwks.json", - "principalClaim": "sub", - "requesterTagsClaim": "evidence_tags", - "evidenceAudienceClaim": "evidence_audience", - "grantIdClaim": "evidence_grant_id", - "grantAuthorityClaim": "evidence_authority", - "maximumTokenLifetimeSeconds": 300, - "revokedKeyIds": [] - }, - "audit": { - "format": "keyed-jsonl", - "hashSecretRef": "secret:file/audit-hmac-key", - "hashKeyVersion": 1, - "failClosed": true - }, - "subjectBinding": { - "secretRef": "secret:file/subject-binding-hmac-key", - "keyVersion": 1 - }, - "rateLimits": { - "requestsPerPrincipalPerMinute": 60, - "burstPerPrincipal": 10, - "failedSelectorAttemptsPerPrincipalAuthorityPerMinute": 10 - }, - "signing": { - "format": "flattened-jws-json", - "algorithm": "ES256", - "publishedPublicJwkFiles": [], - "revokedKeyIds": [], - "jwksPath": "/.well-known/evidence/jwks.json", - "maximumAssertionValiditySeconds": 300, - "verifierClockSkewSeconds": 30 - }, - "responseFormats": [ - "signed-jws" - ], - "sourceConnections": { - "registry": { - "baseUrl": "http://127.0.0.1:8090", - "authentication": { - "kind": "oauth2-client-credentials", - "tokenEndpoint": "http://127.0.0.1:8091/token", - "clientIdRef": "secret:file/registry-client-id", - "clientAssertionKeyRef": "secret:file/registry-client-key", - "maximumCacheSeconds": 60 - }, - "concurrencyLimit": 4, - "admissionTimeoutMilliseconds": 5000, - "tokenTimeoutMilliseconds": 5000 - } - }, - "authorityProfiles": { - "holding-verifier": { - "kind": "explicit-request", - "requesterTags": [ - "holding-verifier" - ], - "grants": [ - { - "requirement": "urn:example:requirement:holding-registered:v1", - "purpose": "holding-verification", - "audienceFrom": "authenticated-requester", - "responseFormats": [ - "signed-jws" - ], - "subjects": [ - { - "role": "subject", - "selectorProfile": "breg-8-registry-4-farm-19-by-local-identifier", - "valueOrigin": "request" - } - ] - } - ] - } - } - }, - "runtime": { - "version": 1, - "bundleDirectory": "/absolute/path/to/candidate/bundle", - "listener": { - "bindHost": "127.0.0.1", - "port": 8080, - "tlsTermination": "operator-controlled-upstream", - "trustProxyIdentityHeaders": false, - "maximumRequestBytes": 65536, - "maximumConcurrentRequests": 64, - "requestTimeoutMilliseconds": 10000, - "shutdownGraceMilliseconds": 30000 - }, - "secretProviders": { - "file": { - "root": "/absolute/path/to/evidence/secrets" - } - }, - "signer": { - "kind": "local-jwk", - "privateKeyRef": "secret:file/signing-p256-private-jwk" - }, - "auditStorage": { - "path": "/absolute/path/to/evidence/audit/evidence.jsonl", - "maximumFileBytes": 1073741824 - }, - "outboundTls": { - "systemRoots": true, - "trustProfiles": {} - } - }, - "publicKeys": {} -} diff --git a/pilot/agriculture/evidence-starter/targets/production/settings.yaml b/pilot/agriculture/evidence-starter/targets/production/settings.yaml new file mode 100644 index 0000000..ceaa734 --- /dev/null +++ b/pilot/agriculture/evidence-starter/targets/production/settings.yaml @@ -0,0 +1,113 @@ +# The Transit public key is supplied to `evidencectl target new`; no local +# signing key is generated or retained. The TLS gateway and its private CA are +# operator bindings, while the governed endpoints stay fixed and reviewable. +formatVersion: 1 +governance: + version: 1 + assuranceProfile: production + service: + providerId: https://127.0.0.1:8445 + publicOrigin: https://127.0.0.1:8445 + trustDomain: urn:example:trust:agriculture-pilot + issuer: + id: urn:example:issuer:agriculture-holding-evidence + publication: + serviceId: urn:example:service:agriculture-holding-evidence + title: Synthetic agricultural holding evidence + description: Minimum-disclosure synthetic holding registration evidence + endpointUrl: https://127.0.0.1:8445 + jurisdictions: [urn:example:jurisdiction:synthetic] + authentication: + oidc: + issuer: https://127.0.0.1:8443 + audience: urn:example:audience:agriculture-pilot + tokenTypes: [at+jwt] + algorithms: [RS256] + jwksSource: + kind: uri + uri: https://127.0.0.1:8443/oauth2/jwks + tlsTrustProfile: pilot-ca + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] + audit: + hashKeyRef: secret:file/audit-hmac-key + hashKeyVersion: 1 + subjectBinding: + secretRef: secret:file/subject-binding-hmac-key + keyVersion: 1 + rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 + signing: + format: flattened-jws-json + algorithm: ES256 + publishedPublicJwkFiles: [] + revokedKeyIds: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 + responseFormats: [signed-jws] + sourceConnections: + registry: + baseUrl: https://127.0.0.1:8444 + tlsTrustProfile: pilot-ca + authentication: + kind: oauth2-client-credentials + tokenEndpoint: https://127.0.0.1:8443/oauth2/token + clientIdRef: secret:file/registry-client-id + clientAssertionKeyRef: secret:file/registry-client-key + clientAssertionAudience: https://127.0.0.1:8443 + resource: urn:example:audience:agriculture-pilot + scope: pilot:evidence-source + maximumCacheSeconds: 60 + concurrencyLimit: 4 + admissionTimeoutMilliseconds: 5000 + tokenTimeoutMilliseconds: 5000 + authorityProfiles: + holding-verifier: + kind: explicit-request + requesterTags: [holding-verifier] + grants: + - requirement: urn:example:requirement:holding-registered:v1 + purpose: holding-verification + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: subject + selectorProfile: breg-8-registry-4-farm-19-by-local-identifier + valueOrigin: request +runtime: + apiVersion: registry.registrystack.org/evidence-runtime/v1alpha1 + kind: EvidenceRuntimeConfig + package: + root: /config/evidence/candidate + listener: + bind: 127.0.0.1:8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + secretProviders: + file: + root: /config/evidence/secrets + signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 1 + timeoutMilliseconds: 2000 + audit: + path: /var/lib/registry-evidence/evidence.jsonl + outboundTls: + systemRoots: true + trustProfiles: + pilot-ca: + caBundleFile: /config/evidence/tls/pilot-ca.pem +publicKeys: {} diff --git a/pilot/agriculture/initialize.py b/pilot/agriculture/initialize.py index bb2b7dc..2631223 100644 --- a/pilot/agriculture/initialize.py +++ b/pilot/agriculture/initialize.py @@ -1,73 +1,455 @@ #!/usr/bin/env python3 -"""Rehearse, package, and activate only the prepared synthetic Compose database. +"""Activate the fresh 0.38 core, then package the Transit-bound Evidence service.""" -Run inside the tools container after Mint and the new BREG database are ready. -A repeat verifies the retained package and does not seed or reset records. -""" +from __future__ import annotations + +import argparse import json import os from pathlib import Path +import shutil +import ssl import subprocess import sys +import urllib.error +import urllib.request + +import yaml + +from issuer import token as issue_token + + +ROOT = Path(os.environ.get("PILOT_RUNTIME_ROOT", "/config")) +HERE = Path(__file__).resolve().parent + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file_pointer, code, message, headers, url): + return None + + +DIRECTORY_OPENER = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect() +) + + +def issuer_jwks() -> dict: + bindings = json.loads( + (ROOT / "issuer-bindings.json").read_text(encoding="utf-8") + ) + uri = bindings.get("jwksUri") + if uri != "https://127.0.0.1:8443/oauth2/jwks": + raise RuntimeError("issuer JWKS endpoint differs from the fixed pilot endpoint") + context = ssl.create_default_context(cafile=str(ROOT / "tls" / "ca.pem")) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context), + NoRedirect(), + ) + request = urllib.request.Request(uri, headers={"Accept": "application/json"}) + try: + with opener.open(request, timeout=10) as response: + if response.status != 200: + raise RuntimeError( + f"issuer JWKS request was refused (HTTP {response.status})" + ) + body = response.read(65537) + except urllib.error.HTTPError as error: + raise RuntimeError( + f"issuer JWKS request was refused (HTTP {error.code})" + ) from None + except (OSError, urllib.error.URLError): + raise RuntimeError("issuer JWKS request did not complete") from None + if len(body) > 65536: + raise RuntimeError("issuer JWKS document exceeds the pilot size limit") + try: + document = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError): + raise RuntimeError("issuer returned an invalid JWKS document") from None + keys = document.get("keys") if isinstance(document, dict) else None + if not isinstance(keys, list) or not keys: + raise RuntimeError("issuer JWKS document has no public keys") + kids: set[str] = set() + compatible_keys: list[dict] = [] + private_fields = {"d", "p", "q", "dp", "dq", "qi", "oth", "k"} + public_fields = {"kty", "kid", "alg", "use", "key_ops", "n", "e"} + for key in keys: + if not isinstance(key, dict) or key.get("kty") not in {"RSA", "EC", "OKP"}: + raise RuntimeError("issuer JWKS document contains an unsupported key") + kid = key.get("kid") + if not isinstance(kid, str) or not kid or kid in kids: + raise RuntimeError("issuer JWKS document has invalid key identifiers") + if private_fields.intersection(key): + raise RuntimeError("issuer JWKS document contains private key material") + if ( + key.get("kty") == "RSA" + and key.get("alg") == "RS256" + and key.get("use") in {None, "sig"} + and key.get("key_ops") in (None, ["verify"]) + and isinstance(key.get("n"), str) + and isinstance(key.get("e"), str) + ): + compatible_keys.append({ + name: value for name, value in key.items() + if name in public_fields + }) + kids.add(kid) + if not compatible_keys: + raise RuntimeError("issuer JWKS has no RS256 signing key for the pilot") + return {"keys": compatible_keys} -ROOT = Path('/config/breg') +def initialize_issuer_jwks() -> None: + document = issuer_jwks() + destinations = ( + ROOT / "breg" / "secrets" / "issuer-jwks", + ROOT / "casework" / "secrets" / "issuer-jwks", + ) + for destination in destinations: + if destination.exists(): + try: + retained = json.loads(destination.read_text(encoding="utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise RuntimeError("retained issuer JWKS document is invalid") from None + if retained != document: + raise RuntimeError( + "live issuer keys differ from the retained pilot trust document" + ) + else: + save(destination, document) -def run(binary, *args): - result = subprocess.run([binary, *map(str,args)], capture_output=True, text=True) + +def run(binary: str, *args: object) -> str: + result = subprocess.run( + [binary, *map(str, args)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) if result.returncode: - # Product diagnostics exclude tokens and record values. Never print stdout, - # since mint's success output contains an access token. - detail = result.stderr if binary == 'mint' else result.stderr + result.stdout - raise RuntimeError(f'{binary} {args[0]} failed: {detail[-5000:]}') + raise RuntimeError( + f"{Path(binary).name} command failed (exit {result.returncode}); " + "raw output was withheld" + ) return result.stdout -def save(path, value): - temporary = path.with_name(path.name+'.tmp') - temporary.write_text(value if isinstance(value,str) else json.dumps(value,indent=2)+'\n') +def save(path: Path, value: object) -> None: + temporary = path.with_name(path.name + ".tmp") + if isinstance(value, str): + body = value + elif path.suffix in {".yaml", ".yml"}: + body = yaml.safe_dump(value, sort_keys=False) + else: + body = json.dumps(value, indent=2, sort_keys=True) + "\n" + temporary.write_text(body, encoding="utf-8") temporary.chmod(0o600) temporary.replace(path) -def main(): - os.umask(0o077) - os.environ['SSL_CERT_FILE']=str(ROOT/'secrets/database-ca.pem') - runtime=ROOT/'runtime.json' - if (ROOT/'initialized.json').exists(): - run('bregctl','verify','--runtime-config',runtime) - print('Retained agriculture package verified; records and credentials preserved.') +def initialize_breg() -> None: + breg = ROOT / "breg" + runtime = breg / "runtime.yaml" + if (breg / "initialized.json").exists(): + run("bregctl", "verify", "--runtime-config", runtime) + return + + for profile, client, scopes in ( + ("reader", "reader", None), + ("editor", "editor", None), + ("reviewer", "reviewer", ["starter:reviewer"]), + ): + bearer = issue_token(ROOT, client, scopes) + if bearer.count(".") != 2 or any(char.isspace() for char in bearer): + raise RuntimeError("issuer returned an invalid access token") + save(breg / "secrets" / f"token-{profile}", bearer) + + receipt = breg / "schema-test-receipt.json" + if not receipt.exists(): + report = run( + "bregctl", "--format", "json", "test", breg / "registry", + "--runtime-config", breg / "test-runtime.yaml", + "--credentials", breg / "schema-test-credentials.json", + "--output", receipt, + ) + save(breg / "schema-test-report.json", report) + + build = breg / "build" + if not (build / "package").exists(): + if build.exists(): + raise RuntimeError( + "incomplete BReg package build exists; inspect it before retrying" + ) + report = run( + "bregctl", "--format", "json", "package", breg / "registry", + "--test-receipt", receipt, + "--revision", "agriculture-openfn-pilot-0.38.0", + "--output", build, + ) + save(breg / "package-report.json", report) + package_report = json.loads( + (breg / "package-report.json").read_text(encoding="utf-8") + ) + configuration = yaml.safe_load( + (breg / "runtime-template.yaml").read_text(encoding="utf-8") + ) + configuration["package"]["expectedDigest"] = package_report["packageDigest"] + save(runtime, configuration) + run( + "bregctl", "apply", "--runtime-config", runtime, + "--package", build / "package", "--initial", + ) + run("bregctl", "verify", "--runtime-config", runtime) + save( + breg / "initialized.json", + {"packageDigest": package_report["packageDigest"]}, + ) + + +def initialize_casework() -> None: + casework = ROOT / "casework" + runtime = casework / "runtime.yaml" + if (casework / "initialized.json").exists(): + run("caseworkctl", "status", "--runtime-config", runtime, "--format", "json") + return + plan = run( + "caseworkctl", "plan", "--runtime-config", runtime, "--format", "json" + ) + save(casework / "plan-report.json", plan) + applied = run( + "caseworkctl", "apply", "--runtime-config", runtime, + "--operator-reference", "agriculture-openfn-pilot-0.38.0", + "--format", "json", + ) + report = json.loads(applied) + save(casework / "apply-report.json", report) + run("caseworkctl", "status", "--runtime-config", runtime, "--format", "json") + save( + casework / "initialized.json", + {"packageDigest": report["packageDigest"]}, + ) + + +def initialize_core() -> None: + os.environ["SSL_CERT_FILE"] = str(ROOT / "breg" / "secrets" / "trust-bundle.pem") + initialize_issuer_jwks() + initialize_breg() + initialize_casework() + print("BReg and Casework packages activated in their fresh databases.") + + +def casework_directory_request( + path: str, + bearer: str, + *, + method: str = "GET", + body: dict | None = None, + headers: dict[str, str] | None = None, +) -> dict: + if not path.startswith("/v1/directory") or path.startswith("//"): + raise RuntimeError("unexpected Casework directory path") + request_headers = { + "Authorization": "Bearer " + bearer, + "Accept": "application/json", + "Registry-Casework-Profile": "administrator", + **(headers or {}), + } + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + request_headers["Content-Type"] = "application/json" + request = urllib.request.Request( + "http://127.0.0.1:8092" + path, + method=method, + data=data, + headers=request_headers, + ) + try: + with DIRECTORY_OPENER.open(request, timeout=10) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as error: + raise RuntimeError( + f"Casework directory request was refused (HTTP {error.code})" + ) from None + except (OSError, urllib.error.URLError, ValueError): + raise RuntimeError("Casework directory request did not complete") from None + + +def initialize_directory() -> None: + os.environ["SSL_CERT_FILE"] = str(ROOT / "tls" / "ca.pem") + bindings = json.loads( + (ROOT / "issuer-bindings.json").read_text(encoding="utf-8") + ) + issuer = bindings["issuer"] + administrator = issue_token(ROOT, "casework-admin") + member = {"issuer": issuer, "subject": "synthetic-reviewer"} + expected = { + "id": "agriculture-reviewers", + "members": [member], + "supervisors": [], + "servedQueues": ["corrections"], + } + + def verify(directory: dict) -> int: + revision = directory.get("revision") + teams = directory.get("teams") + if not isinstance(revision, int) or not isinstance(teams, list): + raise RuntimeError("Casework returned an invalid directory response") + comparable = [ + {key: team.get(key) for key in expected} + for team in teams + if isinstance(team, dict) + ] + if comparable != [expected]: + raise RuntimeError( + "Casework directory differs from the fixed pilot authority" + ) + return revision + + current = casework_directory_request("/v1/directory", administrator) + if current.get("revision") == 0 and current.get("teams") == []: + current = casework_directory_request( + "/v1/directory/bootstrap", + administrator, + method="POST", + body={ + "teamId": expected["id"], + "staff": [member], + "supervisors": [], + "queueId": "corrections", + }, + headers={ + "If-Match": '"0"', + "Idempotency-Key": "agriculture-pilot-directory-v1", + }, + ) + revision = verify(current) + save( + ROOT / "casework" / "directory-initialized.json", + { + "revision": revision, + "team": expected["id"], + "queue": "corrections", + "staff": member, + }, + ) + print("Casework reviewer directory is ready.") + + +def prepare_evidence_client() -> None: + run(sys.executable, HERE / "evidence-client.py", "--root", ROOT) + generated = json.loads( + (ROOT / "evidence-client" / "generated.json").read_text(encoding="utf-8") + ) + evidence = generated.get("evidence") if isinstance(generated, dict) else None + request = evidence.get("request") if isinstance(evidence, dict) else None + if not isinstance(request, dict): + raise RuntimeError("explicit Evidence client binding has no request policy") + bindings_path = ROOT / "workflow-bindings.json" + bindings = json.loads(bindings_path.read_text(encoding="utf-8")) + if not isinstance(bindings, dict) or not isinstance(bindings.get("evidence"), dict): + raise RuntimeError("pilot workflow bindings are invalid") + bindings["evidence"]["request"] = request + save(bindings_path, bindings) + + +def initialize_evidence() -> None: + evidence = ROOT / "evidence" + runtime = evidence / "runtime.yaml" + os.environ["SSL_CERT_FILE"] = str(ROOT / "tls" / "ca.pem") + if (evidence / "initialized.json").exists(): + run("evidencectl", "doctor", "--runtime-config", runtime) + prepare_evidence_client() + print("Transit-bound Evidence package verified.") return - if not (ROOT/'schema-test-receipt.json').exists(): - clients=json.loads((ROOT/'registry/clients.yaml').read_text())['clients'] - for client in clients: - token=run('mint','token','--url','http://127.0.0.1:8091/token','--client-id',client['id'],'--key',ROOT/f"clients/{client['id']}/signing-p256-private-jwk").strip() - if token.count('.') != 2 or any(c.isspace() for c in token): - raise RuntimeError('Mint returned an invalid token shape') - for profile in client['accessProfiles']: - save(ROOT/f'secrets/token-{profile}',token) - report=run('bregctl','--format','json','test',ROOT/'registry','--runtime-config',ROOT/'test-runtime.json','--credentials',ROOT/'schema-test-credentials.json','--database-id','agriculture-pilot-db','--output',ROOT/'schema-test-receipt.json') - save(ROOT/'test-report.json',report) - if not (ROOT/'build/package').exists(): - if (ROOT/'build').exists(): - raise RuntimeError('Incomplete package build exists; inspect it before retrying. No files were removed.') - report=run('bregctl','--format','json','package',ROOT/'registry','--database-id','agriculture-pilot-db','--test-receipt',ROOT/'schema-test-receipt.json','--output',ROOT/'build') - save(ROOT/'package-report.json',report) - revision=json.loads((ROOT/'package-report.json').read_text())['packageRevision'] - configuration=json.loads((ROOT/'runtime-template.json').read_text()) - configuration['package']['activeRevision']=revision - save(runtime,configuration) - # --initial is the maintained first activation flow. Never clear an interlock - # or mutate the database with an improvised recovery command. - run('bregctl','apply','--runtime-config',runtime,'--package',ROOT/'build/package','--initial') - run('bregctl','verify','--runtime-config',runtime) - save(ROOT/'initialized.json',{'packageRevision':revision}) - print('Agriculture schema journeys passed and the exact local package was activated.') - - -if __name__=='__main__': + public_key = ( + evidence / "transit-public" / "evidence-signing.jwk.json" + ) + if not public_key.is_file(): + raise RuntimeError( + "OpenBao has not exported the pinned Transit version 1 public JWK" + ) + target = evidence / "target" + if not target.exists(): + run( + "evidencectl", "target", "new", target, + "--settings", + evidence / "project" / "targets" / "production" / "settings.yaml", + "--signing-public-key", public_key, + ) + source_import = run( + "evidencectl", "source", "import", evidence / "source-export", + "--project", evidence / "project", "--target", target, + ) + save(evidence / "source-import-report.json", source_import) + run( + "evidencectl", "check", evidence / "project", + "--target", target, "--production", + ) + fixtures = run( + "evidencectl", "test", evidence / "project", + "--target", target, "--format", "json", + ) + save(evidence / "transit-fixture-report.json", fixtures) + candidate = evidence / "candidate" + if not (candidate / "evidence.yaml").exists(): + if candidate.exists(): + raise RuntimeError( + "incomplete Evidence candidate exists; inspect it before retrying" + ) + package = run( + "evidencectl", "package", evidence / "project", + "--target", target, + "--output", candidate, + "--revision", "agriculture-openfn-pilot-0.38.0", + "--format", "json", + ) + save(evidence / "package-report.json", package) + target_runtime = target / "runtime.yaml" + if runtime.exists(): + if runtime.read_bytes() != target_runtime.read_bytes(): + raise RuntimeError( + "retained Evidence runtime differs from the compiled target" + ) + else: + shutil.copyfile(target_runtime, runtime) + runtime.chmod(0o400) + run("evidencectl", "doctor", "--runtime-config", runtime) + prepare_evidence_client() + package_report = json.loads( + (evidence / "package-report.json").read_text(encoding="utf-8") + ) + save( + evidence / "initialized.json", + {"packageRevision": package_report.get("revision")}, + ) + prepared = json.loads( + (ROOT / "prepared.json").read_text(encoding="utf-8") + ) + prepared["evidencePackage"] = "transit-version-1" + save(ROOT / "prepared.json", prepared) + print("Evidence production package compiled against Transit key version 1.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "component", choices=("core", "directory", "evidence", "all") + ) + arguments = parser.parse_args() + os.umask(0o077) + if arguments.component in {"core", "all"}: + initialize_core() + if arguments.component == "directory": + initialize_directory() + if arguments.component in {"evidence", "all"}: + initialize_evidence() + + +if __name__ == "__main__": try: main() except Exception as error: - print(f'Initialization stopped: {error}',file=sys.stderr) + print(f"Initialization stopped: {error}", file=sys.stderr) sys.exit(1) diff --git a/pilot/agriculture/issuer.py b/pilot/agriculture/issuer.py new file mode 100755 index 0000000..551c12e --- /dev/null +++ b/pilot/agriculture/issuer.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +"""Render and use the pilot's pinned, stock ThunderID development issuer.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from pathlib import Path +import secrets +import subprocess +import ssl +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + +import yaml + + +ISSUER = "https://127.0.0.1:8443" +TOKEN_ENDPOINT = ISSUER + "/oauth2/token" +JWKS_URI = ISSUER + "/oauth2/jwks" +DEFAULT_RESOURCE = "urn:example:audience:agriculture-pilot" +ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +DEFAULT_OU = "default" +DEFAULT_AGENT_TYPE = "default" + +_PINNED_AGENT_SCHEMA = """\ +resource_type: agent_type +id: 01900000-0000-7000-8000-000000000011 +name: default +ouHandle: default +schema: + modelProvider: + type: string + displayName: Model Provider + required: false + enum: + - openai + - anthropic + - gemini + - mistral + - custom + model: + type: string + displayName: Model + required: false + function: + type: string + displayName: Function + required: false + enum: + - task-automation + - rag-retrieval + - code-gen + - data-analysis + - orchestrator + - sub-agent + - assistant + - custom +""" + + +def _write(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if path.exists(): + raise RuntimeError(f"issuer output already exists: {path}") + if isinstance(value, str): + text = value + elif path.suffix in {".yaml", ".yml"}: + # ThunderID's directory importer supplies document boundaries between + # files. A per-file `---` creates an empty document and is rejected. + text = yaml.safe_dump(value, sort_keys=False) + else: + text = json.dumps(value, indent=2, sort_keys=True) + "\n" + path.write_text(text, encoding="utf-8") + path.chmod(0o600) + + +def _derived_uuid(seed: str) -> str: + value = list(hashlib.sha256(seed.encode()).hexdigest()[:32]) + value[12], value[16] = "7", "8" + raw = "".join(value) + return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}" + + +def _scope_resources(scopes: set[str]) -> list[dict]: + resources: dict[str, dict] = {} + for scope in sorted(scopes): + pieces = scope.split(":") + if len(pieces) < 2 or any( + not piece or any(not (char.isalnum() or char in "-_") for char in piece) + for piece in pieces + ): + raise ValueError(f"invalid ThunderID scope handle: {scope}") + handle, action = pieces[0], ":".join(pieces[1:]) + if len(handle) > 64 or len(action) > 64: + raise ValueError(f"ThunderID scope handle is too long: {scope}") + resource = resources.setdefault( + handle, + { + "name": handle, + "handle": handle, + "description": f"pilot resource {handle}", + "actions": [], + }, + ) + resource["actions"].append( + { + "name": action, + "handle": action, + "description": f"pilot permission {scope}", + "kind": "resource", + } + ) + return list(resources.values()) + + +def _deployment_yaml() -> str: + return f"""\ +server: + http_only: true + hostname: "0.0.0.0" + public_url: {ISSUER} + port: 8091 + security: + direct_auth_secret: "file://config/secrets/direct_auth_secret" +tls: + min_version: "1.3" + cert_file: "config/certs/server.cert" + key_file: "config/certs/server.key" +database: + config: + type: "sqlite" + sqlite: {{ path: "database/configdb.db" }} + runtime_transient: + type: "sqlite" + sqlite: {{ path: "database/runtime_transient.db" }} + entity: + type: "sqlite" + sqlite: {{ path: "database/entitydb.db" }} + runtime_persistent: + type: "sqlite" + sqlite: {{ path: "database/runtime_persistent.db" }} +crypto: + encryption: + key: "file://config/certs/crypto.key" + password_hashing: + algorithm: "PBKDF2" + keys: + - id: "default-key" + cert_file: "config/certs/signing.cert" + key_file: "config/certs/signing.key" + - id: "ecdsa-key" + cert_file: "config/certs/ecdsa-signing.cert" + key_file: "config/certs/ecdsa-signing.key" +jwt: + preferred_key_id: "default-key" +resource: + store: composite +role: + store: composite +identity_provider: + store: declarative +passkey: + allowed_origins: + - "127.0.0.1:8443" +""" + + +def _prepare_tls(root: Path) -> Path: + tls = root / "tls" + tls.mkdir(mode=0o700) + commands = [ + ["req", "-x509", "-new", "-nodes", "-newkey", "rsa:3072", "-sha256", "-days", "365", + "-subj", "/CN=Synthetic agriculture pilot CA", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-addext", "subjectKeyIdentifier=hash", + "-keyout", str(tls / "ca.key"), "-out", str(tls / "ca.pem")], + ["req", "-new", "-nodes", "-newkey", "rsa:3072", "-subj", "/CN=127.0.0.1", + "-keyout", str(tls / "server.key"), "-out", str(tls / "server.csr")], + ] + for command in commands: + subprocess.run(["openssl", *command], check=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + _write( + tls / "server.ext", + "basicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\n" + "subjectKeyIdentifier=hash\n" + "authorityKeyIdentifier=keyid,issuer\n" + "subjectAltName=IP:127.0.0.1\n", + ) + subprocess.run( + ["openssl", "x509", "-req", "-sha256", "-days", "365", "-in", str(tls / "server.csr"), + "-CA", str(tls / "ca.pem"), "-CAkey", str(tls / "ca.key"), "-CAcreateserial", + "-extfile", str(tls / "server.ext"), "-out", str(tls / "server.crt")], + check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + for path in tls.iterdir(): + path.chmod(0o600) + return tls / "ca.pem" + + +def prepare_issuer(root: Path, clients: list[dict]) -> dict: + """Render native ThunderID resources and one private key per declared client.""" + root = Path(root).resolve() + issuer_root = root / "issuer" + if issuer_root.exists(): + raise RuntimeError("issuer state already exists; retain it or prepare a fresh runtime") + issuer_root.mkdir(parents=True, mode=0o700) + os.chmod(issuer_root, 0o700) + for directory in ("database", "certs", "secrets", "resources", "registry-schema"): + (issuer_root / directory).mkdir(mode=0o700) + ca_bundle = _prepare_tls(root) + + session_id = secrets.token_urlsafe(24) + server_id = _derived_uuid(f"{session_id}:server") + normalized: list[dict] = [] + logical_ids: set[str] = set() + client_ids: set[str] = set() + all_scopes: set[str] = set() + claim_kinds: dict[str, str] = {} + resource = None + + for raw in clients: + logical_id = raw.get("logicalId") + client_id = raw.get("clientId") + client_resource = raw.get("resource", DEFAULT_RESOURCE) + scopes = raw.get("scopes") + claims = raw.get("claims", {}) + key_directory = Path(raw.get("keyDirectory", "")) + if ( + not isinstance(logical_id, str) + or not logical_id + or logical_id in logical_ids + or not isinstance(client_id, str) + or not client_id + or client_id in client_ids + ): + raise ValueError("issuer clients need distinct non-empty logicalId and clientId values") + if resource is None: + resource = client_resource + if client_resource != resource or not isinstance(resource, str) or not resource: + raise ValueError("this pilot uses one explicit ThunderID resource audience") + if not isinstance(scopes, list) or not scopes or not all(isinstance(v, str) for v in scopes): + raise ValueError(f"issuer client {logical_id} needs explicit scopes") + if not isinstance(claims, dict) or not all(isinstance(k, str) for k in claims): + raise ValueError(f"issuer client {logical_id} has invalid claims") + if claims.get("registry_actor_kind") == "human" and raw.get("allowHumanFixture") is not True: + raise ValueError(f"issuer client {logical_id} must explicitly allow its synthetic human fixture") + if not key_directory.is_absolute() or root not in key_directory.parents: + raise ValueError(f"issuer client {logical_id} keyDirectory must be inside the runtime root") + for name, value in claims.items(): + kind = "array" if isinstance(value, list) and all(isinstance(v, str) for v in value) else "string" + if kind == "string" and not isinstance(value, str): + raise ValueError(f"issuer claim {name} must be a string or string array") + if name in claim_kinds and claim_kinds[name] != kind: + raise ValueError(f"issuer claim {name} has conflicting types") + claim_kinds[name] = kind + + subprocess.run( + ["evidencectl", "keygen", "signing", "--output-dir", str(key_directory)], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + private_jwk = key_directory / "signing-p256-private-jwk" + public_jwk = key_directory / "signing-p256-public.jwk.json" + public = json.loads(public_jwk.read_text(encoding="utf-8")) + if any(name in public for name in ("d", "p", "q")): + raise RuntimeError("issuer client public JWK contains private material") + native_subject = _derived_uuid(f"{session_id}:agent:{client_id}") + role_id = _derived_uuid(f"{session_id}:role:{client_id}") + normalized.append( + { + "logicalId": logical_id, + "clientId": client_id, + "subject": native_subject, + "requestedSubject": raw.get("subject"), + "resource": resource, + "scopes": sorted(set(scopes)), + "claims": claims, + "privateJwk": private_jwk.relative_to(root).as_posix(), + "publicJwk": public_jwk.relative_to(root).as_posix(), + "public": public, + "roleId": role_id, + } + ) + logical_ids.add(logical_id) + client_ids.add(client_id) + all_scopes.update(scopes) + + if not normalized: + raise ValueError("issuer needs at least one client") + + _write(issuer_root / "deployment.yaml", _deployment_yaml()) + _write(issuer_root / "secrets" / "admin-password", secrets.token_urlsafe(32) + "\n") + _write(issuer_root / "secrets" / "bootstrap-password", secrets.token_urlsafe(32) + "\n") + _write( + issuer_root / "resources" / "resource_servers" / f"{server_id}.yaml", + { + "resource_type": "resource_server", + "id": server_id, + "name": "Agriculture pilot", + "description": "Synthetic agriculture pilot APIs", + "identifier": resource, + "ouHandle": DEFAULT_OU, + "delimiter": ":", + "resources": _scope_resources(all_scopes), + }, + ) + + schema = yaml.safe_load(_PINNED_AGENT_SCHEMA) + for name, kind in sorted(claim_kinds.items()): + schema["schema"][name] = ( + {"type": "array", "items": {"type": "string"}, "displayName": name, "required": False} + if kind == "array" + else {"type": "string", "displayName": name, "required": False} + ) + _write(issuer_root / "registry-schema" / "agent-type.yaml", schema) + + client_index: dict[str, dict] = {} + for client in normalized: + public_jwks = json.dumps({"keys": [client.pop("public")]}, separators=(",", ":")) + _write( + issuer_root / "resources" / "roles" / f"{client['roleId']}.yaml", + { + "resource_type": "role", + "id": client["roleId"], + "name": f"Pilot {client['logicalId']}", + "description": "Explicit synthetic pilot permissions", + "ouHandle": DEFAULT_OU, + "permissions": [{"resourceServerId": server_id, "permissions": client["scopes"]}], + "assignments": [{"id": client["subject"], "type": "agent"}], + }, + ) + _write( + issuer_root / "registry-schema" / "agents" / f"{client['subject']}.yaml", + { + "resource_type": "agent", + "id": client["subject"], + "type": DEFAULT_AGENT_TYPE, + "ouHandle": DEFAULT_OU, + "name": f"Pilot {client['logicalId']}", + "description": "Synthetic agriculture pilot client", + "attributes": client["claims"], + "inboundAuthConfig": [ + { + "type": "oauth2", + "config": { + "clientId": client["clientId"], + "grantTypes": ["client_credentials"], + "tokenEndpointAuthMethod": "private_key_jwt", + "publicClient": False, + "certificate": {"type": "JWKS", "value": public_jwks}, + "token": { + "accessToken": { + "clientConfig": { + "validityPeriod": 300, + "attributes": sorted(client["claims"]), + } + } + }, + }, + } + ], + }, + ) + client.pop("roleId") + client_index[client["logicalId"]] = client + + index = { + "schema": "synthetic-agriculture-thunderid/v1", + "issuer": ISSUER, + "tokenEndpoint": TOKEN_ENDPOINT, + "clientAssertionAudience": ISSUER, + "jwksUri": JWKS_URI, + "resource": resource, + "caBundle": ca_bundle.relative_to(root).as_posix(), + "sessionId": session_id, + "clients": client_index, + } + _write(issuer_root / "clients.json", index) + returned = {key: value for key, value in index.items() if key != "sessionId"} + returned["clients"] = { + logical_id: { + **client, + "privateJwk": str(root / client["privateJwk"]), + "publicJwk": str(root / client["publicJwk"]), + } + for logical_id, client in index["clients"].items() + } + returned["caBundle"] = str(ca_bundle) + return returned + + +def _b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _der_length(length: int) -> bytes: + if length < 128: + return bytes([length]) + encoded = length.to_bytes((length.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(encoded)]) + encoded + + +def _der(tag: int, value: bytes) -> bytes: + return bytes([tag]) + _der_length(len(value)) + value + + +def _private_pem(jwk: dict) -> bytes: + try: + scalar = base64.urlsafe_b64decode(jwk["d"] + "==") + x = base64.urlsafe_b64decode(jwk["x"] + "==") + y = base64.urlsafe_b64decode(jwk["y"] + "==") + except (KeyError, ValueError) as error: + raise RuntimeError("issuer client key is not a private EC JWK") from error + if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256" or len(scalar) != 32 or len(x) != 32 or len(y) != 32: + raise RuntimeError("issuer client key must be an EC P-256 private JWK") + oid_prime256v1 = bytes.fromhex("06082a8648ce3d030107") + body = _der(0x02, b"\x01") + _der(0x04, scalar) + body += bytes([0xA0]) + _der_length(len(oid_prime256v1)) + oid_prime256v1 + point = b"\x04" + x + y + bit_string = _der(0x03, b"\x00" + point) + body += bytes([0xA1]) + _der_length(len(bit_string)) + bit_string + encoded = base64.b64encode(_der(0x30, body)) + return b"-----BEGIN EC PRIVATE KEY-----\n" + b"\n".join( + encoded[index : index + 64] for index in range(0, len(encoded), 64) + ) + b"\n-----END EC PRIVATE KEY-----\n" + + +def _read_der_integer(data: bytes, offset: int) -> tuple[int, int]: + if offset >= len(data) or data[offset] != 0x02: + raise RuntimeError("OpenSSL returned a malformed ECDSA signature") + offset += 1 + length = data[offset] + offset += 1 + if length & 0x80: + count = length & 0x7F + length = int.from_bytes(data[offset : offset + count], "big") + offset += count + value = int.from_bytes(data[offset : offset + length], "big") + return value, offset + length + + +def _jose_signature(der: bytes) -> bytes: + if len(der) < 8 or der[0] != 0x30: + raise RuntimeError("OpenSSL returned a malformed ECDSA signature") + offset = 2 + if der[1] & 0x80: + count = der[1] & 0x7F + offset = 2 + count + r, offset = _read_der_integer(der, offset) + s, offset = _read_der_integer(der, offset) + if offset != len(der): + raise RuntimeError("OpenSSL returned a malformed ECDSA signature") + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + + +def token( + runtime_root: Path, + logical_id: str, + scopes: list[str] | None = None, + *, + timeout: float = 5.0, +) -> str: + """Mint one bearer token for a prepared client's fixed resource and scopes.""" + root = Path(runtime_root).resolve() + index_path = root / "issuer" / "clients.json" + if index_path.stat().st_mode & 0o077: + raise RuntimeError("issuer client index must be owner-only") + index = json.loads(index_path.read_text(encoding="utf-8")) + try: + client = index["clients"][logical_id] + private_path = root / client["privateJwk"] + except (KeyError, TypeError) as error: + raise RuntimeError(f"unknown prepared issuer client: {logical_id}") from error + registered_scopes = client.get("scopes") + if not isinstance(registered_scopes, list) or not all( + isinstance(scope, str) and scope for scope in registered_scopes + ): + raise RuntimeError("issuer client index has invalid registered scopes") + requested_scopes = registered_scopes if scopes is None else scopes + if ( + not isinstance(requested_scopes, list) + or not requested_scopes + or not all(isinstance(scope, str) and scope for scope in requested_scopes) + or len(set(requested_scopes)) != len(requested_scopes) + or not set(requested_scopes).issubset(registered_scopes) + ): + raise RuntimeError("requested issuer scopes must be a non-empty registered subset") + if private_path.stat().st_mode & 0o077: + raise RuntimeError("issuer client private key must be owner-only") + jwk = json.loads(private_path.read_text(encoding="utf-8")) + now = int(time.time()) + header = {"alg": "ES256", "typ": "JWT", "kid": jwk.get("kid")} + if not isinstance(header["kid"], str) or not header["kid"]: + raise RuntimeError("issuer client key has no kid") + claims = { + "iss": client["clientId"], + "sub": client["clientId"], + "aud": index["clientAssertionAudience"], + "iat": now, + "exp": now + 60, + "jti": secrets.token_urlsafe(24), + } + signing_input = ( + _b64(json.dumps(header, separators=(",", ":")).encode()) + + "." + + _b64(json.dumps(claims, separators=(",", ":")).encode()) + ).encode("ascii") + key_fd, key_name = tempfile.mkstemp(prefix="pilot-thunderid-", suffix=".pem") + try: + os.fchmod(key_fd, 0o600) + with os.fdopen(key_fd, "wb") as handle: + handle.write(_private_pem(jwk)) + signed = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", key_name], + input=signing_input, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + ).stdout + finally: + os.unlink(key_name) + assertion = signing_input.decode("ascii") + "." + _b64(_jose_signature(signed)) + form = urllib.parse.urlencode( + { + "grant_type": "client_credentials", + "client_id": client["clientId"], + "client_assertion_type": ASSERTION_TYPE, + "client_assertion": assertion, + "resource": client["resource"], + "scope": " ".join(requested_scopes), + } + ).encode("ascii") + request = urllib.request.Request( + index["tokenEndpoint"], + data=form, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + context = ssl.create_default_context(cafile=str(root / index["caBundle"])) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context), + _NoRedirect(), + ) + try: + with opener.open(request, timeout=timeout) as response: + body = response.read(65537) + if response.status != 200 or len(body) > 65536: + raise RuntimeError("ThunderID token endpoint refused the client") + except (urllib.error.URLError, TimeoutError) as error: + raise RuntimeError("ThunderID token request failed") from error + try: + document = json.loads(body) + access_token = document["access_token"] + except (json.JSONDecodeError, KeyError, TypeError) as error: + raise RuntimeError("ThunderID token response is malformed") from error + if not isinstance(access_token, str) or access_token.count(".") != 2 or any(char.isspace() for char in access_token): + raise RuntimeError("ThunderID token response has no compact access token") + if str(document.get("token_type", "Bearer")).lower() != "bearer": + raise RuntimeError("ThunderID token response has an unexpected token type") + returned_scopes = document.get("scope") + if returned_scopes is not None and not set(requested_scopes).issubset(str(returned_scopes).split()): + raise RuntimeError("ThunderID token response omitted a requested scope") + return access_token + + +def _main() -> None: + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["token"]) + parser.add_argument("logical_id") + parser.add_argument( + "--runtime-root", + type=Path, + default=Path("/workspace/pilot/agriculture/.runtime-0.38"), + ) + args = parser.parse_args() + if args.command == "token": + print(token(args.runtime_root, args.logical_id)) + + +if __name__ == "__main__": + try: + _main() + except Exception as error: + raise SystemExit(f"ThunderID token request failed: {error}") diff --git a/pilot/agriculture/prepare.py b/pilot/agriculture/prepare.py index cdc8164..cfc6130 100644 --- a/pilot/agriculture/prepare.py +++ b/pilot/agriculture/prepare.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""Prepare a new, private synthetic deployment. Never connects to a database.""" +"""Prepare a fresh, offline Registry Stack 0.38 agriculture pilot.""" + +from __future__ import annotations + import argparse import json import os @@ -7,120 +10,684 @@ import secrets import shutil import subprocess +import tempfile + import yaml +from issuer import prepare_issuer + + HERE = Path(__file__).resolve().parent -PROFILE = 'breg-8-registry-4-farm-19-by-local-identifier' -REQUIREMENT = 'urn:example:requirement:holding-registered:v1' +VERSION = "0.38.0" +RESOURCE = "urn:example:audience:agriculture-pilot" +PROFILE = "breg-8-registry-4-farm-19-by-local-identifier" +REQUIREMENT = "urn:example:requirement:holding-registered:v1" -def write(path, value): +def write(path: Path, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with path.open('x', encoding='utf-8') as handle: - handle.write(value if isinstance(value, str) else json.dumps(value, indent=2) + '\n') + if path.exists(): + raise RuntimeError(f"prepared output already exists: {path}") + if isinstance(value, str): + body = value + elif path.suffix in {".yaml", ".yml"}: + body = yaml.safe_dump(value, sort_keys=False) + else: + body = json.dumps(value, indent=2, sort_keys=True) + "\n" + path.write_text(body, encoding="utf-8") path.chmod(0o600) -def run(binary, *args): - result = subprocess.run([str(binary), *map(str, args)], capture_output=True, text=True) +def run(binary: Path | str, *args: object) -> str: + result = subprocess.run( + [str(binary), *map(str, args)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) if result.returncode: - raise RuntimeError(f'{Path(binary).name} {args[0]} failed: {result.stderr[-3000:]}') + raise RuntimeError( + f"{Path(binary).name} command failed (exit {result.returncode}); " + "raw output was withheld" + ) return result.stdout -def prepare(root, bins): +def check_versions(bins: Path) -> None: + for name in ("bregctl", "caseworkctl", "evidencectl", "evidence"): + reported = run(bins / name, "--version").split()[-1] + if reported not in {VERSION, VERSION + "-dev"}: + raise SystemExit( + f"{name} reports {reported}; this pilot requires Registry Stack {VERSION}" + ) + + +def secure_prepared_tree(root: Path) -> None: + for path in root.rglob("*"): + path.chmod(0o700 if path.is_dir() else 0o600) + + +def client_descriptors(root: Path) -> list[dict]: + def client( + logical_id: str, + scopes: list[str], + claims: dict[str, object], + *, + human: bool = False, + ) -> dict: + value = { + "logicalId": logical_id, + "clientId": logical_id, + "subject": f"synthetic-{logical_id}", + "resource": RESOURCE, + "scopes": scopes, + "claims": claims, + "keyDirectory": str(root / "clients" / logical_id), + } + if human: + value["allowHumanFixture"] = True + return value + + return [ + client("reader", ["starter:reader"], { + "registry_actor_kind": "service", + "registry_principal": "synthetic-reader", + "registry_purpose": "starter-learning", + }), + client("editor", ["starter:editor"], { + "registry_actor_kind": "human", + "registry_principal": "synthetic-editor", + "registry_purpose": "starter-learning", + }, human=True), + client("reviewer", ["casework:staff", "starter:reviewer"], { + "registry_actor_kind": "human", + "registry_principal": "synthetic-reviewer", + "registry_purpose": "starter-learning", + }, human=True), + client("openfn-service", ["pilot:submit"], { + "registry_actor_kind": "human", + "registry_principal": "synthetic-openfn-service", + "registry_purpose": "agriculture-registration", + }, human=True), + client("evidence-source", ["pilot:evidence-source"], { + "registry_actor_kind": "service", + "registry_principal": "synthetic-evidence-source", + "registry_purpose": "evidence-source-read", + }), + client("casework-reader", ["casework:source-reader"], { + "registry_actor_kind": "service", + "registry_principal": "casework-reader", + "registry_purpose": "casework-sync", + }), + client("casework-producer", ["casework:reviews:request"], { + "registry_actor_kind": "service", + }), + client("casework-admin", ["casework:admin"], { + "registry_actor_kind": "human", + "registry_principal": "synthetic-casework-admin", + }, human=True), + client("openfn-evidence", ["evidence:request"], { + "registry_actor_kind": "service", + "evidence_tags": ["holding-verifier"], + "evidence_audience": "urn:example:audience:openfn-pilot", + }), + ] + + +def database_files( + root: Path, + service: str, + database: str, + roles: tuple[str, str], +) -> None: + database_root = root / f"{service}-db" + secrets_root = root / service / "secrets" + admin_password = secrets.token_hex(24) + migration_password = secrets.token_hex(24) + runtime_password = secrets.token_hex(24) + migration_role, runtime_role = roles + write( + database_root / "postgres.env", + f"POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={admin_password}\nPOSTGRES_DB=postgres\n", + ) + sql = ( + f"CREATE ROLE {migration_role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " + f"NOINHERIT NOBYPASSRLS PASSWORD '{migration_password}';\n" + f"CREATE ROLE {runtime_role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " + f"NOINHERIT NOBYPASSRLS PASSWORD '{runtime_password}';\n" + ) + databases = [database, database + "_test"] if service == "breg" else [database] + for name in databases: + owner = "" if service == "breg" else f" OWNER {migration_role}" + sql += ( + f"CREATE DATABASE {name}{owner};\n" + f"\\connect {name}\n" + "CREATE EXTENSION IF NOT EXISTS btree_gist;\n" + f"REVOKE ALL ON DATABASE {name} FROM PUBLIC;\n" + f"GRANT CONNECT ON DATABASE {name} TO {migration_role}, {runtime_role};\n" + ) + if service == "breg": + schemas = ( + "registry_internal", "registry_data", "registry_source", + "registry_derived", "registry_context", + ) + sql += "".join( + f"CREATE SCHEMA {schema} AUTHORIZATION {migration_role};\n" + for schema in schemas + ) + sql += ( + "REVOKE ALL ON SCHEMA " + ", ".join(schemas) + + " FROM PUBLIC;\n" + ) + write(database_root / "bootstrap.sql", sql) + host = f"{service}-db" + write( + secrets_root / "runtime-database-url", + f"postgresql://{runtime_role}:{runtime_password}@{host}:5432/{database}", + ) + write( + secrets_root / "migration-database-url", + f"postgresql://{migration_role}:{migration_password}@{host}:5432/{database}", + ) + if service == "breg": + write( + secrets_root / "test-runtime-database-url", + f"postgresql://{runtime_role}:{runtime_password}@{host}:5432/{database}_test", + ) + write( + secrets_root / "test-migration-database-url", + f"postgresql://{migration_role}:{migration_password}@{host}:5432/{database}_test", + ) + + run( + "openssl", "req", "-x509", "-new", "-nodes", "-newkey", "rsa:2048", + "-sha256", "-days", "365", "-subj", f"/CN=Synthetic {service} database CA", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-addext", "subjectKeyIdentifier=hash", + "-keyout", database_root / "ca.key", "-out", database_root / "ca.pem", + ) + run( + "openssl", "req", "-new", "-nodes", "-newkey", "rsa:2048", + "-subj", f"/CN={host}", "-keyout", database_root / "server.key", + "-out", database_root / "server.csr", + ) + write( + database_root / "server.ext", + "basicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\n" + f"subjectAltName=DNS:{host}\n" + "subjectKeyIdentifier=hash\n" + "authorityKeyIdentifier=keyid,issuer\n", + ) + run( + "openssl", "x509", "-req", "-sha256", "-days", "365", + "-in", database_root / "server.csr", "-CA", database_root / "ca.pem", + "-CAkey", database_root / "ca.key", "-CAcreateserial", + "-extfile", database_root / "server.ext", "-out", database_root / "server.crt", + ) + shutil.copyfile(database_root / "ca.pem", secrets_root / "database-ca.pem") + (secrets_root / "database-ca.pem").chmod(0o600) + + +def schema_credentials(registry: Path, destination: Path) -> None: + bindings = [] + for filename in ("journeys.yaml",): + document = yaml.safe_load( + (registry / "tests" / filename).read_text(encoding="utf-8") + ) + for journey in document["journeys"]: + for step in journey["steps"]: + profile = step["accessProfile"] + bindings.append({ + "journeyId": journey["id"], + "stepId": step["id"], + "credential": { + "type": "bearer", + "tokenRef": f"secret:file/token-{profile}", + }, + }) + write(destination, { + "apiVersion": "registry.registrystack.org/breg-schema-test-credentials/v1", + "kind": "SchemaTestCredentials", + "bindings": bindings, + }) + + +def breg_runtime(bindings: dict, *, test: bool) -> dict: + package_root = "/config/breg/empty-package" if test else "/config/breg/build/package" + prefix = "test-" if test else "" + event_destinations = { + "openfn": { + "origin": "http://localhost:8081", + "path": "/events/breg", + "networkProfile": "loopbackDevelopmentHttp", + "dnsFamily": "ipv4Only", + "allowedPrivateCidrs": [], + "hmacSha256KeyRef": "secret:file/openfn-webhook-key", + "classificationCeiling": "restricted", + "deliveryCeilings": { + "attemptTimeoutMilliseconds": 5000, + "maximumAttempts": 5, + }, + }, + "casework": { + "origin": "http://localhost:8092", + "path": "/events/sources/agricultural-holdings", + "networkProfile": "loopbackDevelopmentHttp", + "dnsFamily": "ipv4Only", + "allowedPrivateCidrs": [], + "hmacSha256KeyRef": "secret:file/breg-casework-webhook", + "classificationCeiling": "restricted", + "deliveryCeilings": { + "attemptTimeoutMilliseconds": 5000, + "maximumAttempts": 5, + }, + }, + } + review_authorities = { + "casework": { + "endpoint": "http://127.0.0.1:8092", + "profile": "integration-requester", + "producerId": "registry-breg", + "recoveryDays": 7, + "privateKeyJwt": { + "tokenEndpoint": bindings["tokenEndpoint"], + "clientIdRef": "secret:file/casework-producer-client-id", + "clientAssertionKeyRef": "secret:file/casework-producer-private-jwk", + "assertionAudience": bindings["clientAssertionAudience"], + "resource": bindings["resource"], + "scopes": ["casework:reviews:request"], + "caBundleRef": "secret:file/pilot-ca.pem", + }, + }, + } + return { + "apiVersion": "registry.registrystack.org/breg-runtime/v1alpha1", + "kind": "BRegRuntimeConfig", + "listener": { + "bind": "127.0.0.1:8090", + "publicOrigin": "http://127.0.0.1:8090", + }, + "identity": { + "environment": "local", + "instanceId": "agriculture-openfn-pilot-038", + "databaseId": "agriculture-pilot-038-db", + "databaseInitializationEnvironment": "local", + }, + "secretProviders": {"file": {"root": "/config/breg/secrets"}}, + "database": { + "runtimeUrlRef": f"secret:file/{prefix}runtime-database-url", + "migrationUrlRef": f"secret:file/{prefix}migration-database-url", + "pool": {"maxSize": 4}, + "roles": { + "migration": "agriculture_migration", + "runtime": "agriculture_runtime", + }, + }, + "package": {"root": package_root}, + "authentication": { + "oidc": { + "issuer": bindings["issuer"], + "audience": bindings["resource"], + "allowedAlgorithm": "RS256", + "accessTokenType": "at+jwt", + "scopeClaim": "scope", + "scopeSeparator": " ", + "allowedClients": [ + bindings["clients"][logical_id]["clientId"] + for logical_id in ( + "reader", "editor", "reviewer", "openfn-service", + "evidence-source", "casework-reader", + ) + ], + "assertionIssuers": {}, + "deniedKids": [], + "maxTokenLifetimeSeconds": 300, + "leewayMilliseconds": 30000, + "jwksSource": { + "kind": "static", + "documentRef": "secret:file/issuer-jwks", + }, + }, + "authorityClaims": { + "principal": "registry_principal", + "purpose": "registry_purpose", + }, + }, + "audit": { + "hashKeyRef": "secret:file/audit-key", + "destination": "file", + "path": ( + "/var/lib/registry-breg/test-audit.jsonl" + if test else "/var/lib/registry-breg/audit.jsonl" + ), + }, + "cursor": {"secretRef": "secret:file/cursor-key"}, + "eventDestinations": event_destinations, + "eventDelivery": {"payloadRetentionDays": 1}, + "reviewAuthorities": review_authorities, + } + + +def prepare_casework(root: Path, bins: Path, bindings: dict) -> None: + casework = root / "casework" + shutil.copytree(HERE / "casework", casework / "project") + policy = casework / "project" / "casework.yaml" + body = policy.read_text(encoding="utf-8") + if body.count("__CASEWORK_PRODUCER_SUBJECT__") != 1: + raise RuntimeError("Casework producer subject placeholder is missing or repeated") + body = body.replace( + "__CASEWORK_PRODUCER_SUBJECT__", + bindings["clients"]["casework-producer"]["subject"], + ) + policy.write_text(body, encoding="utf-8") + policy.chmod(0o600) + write( + casework / "check-report.json", + run(bins / "caseworkctl", "check", casework / "project", "--format", "json"), + ) + write( + casework / "test-report.json", + run(bins / "caseworkctl", "test", casework / "project", "--format", "json"), + ) + package_report = json.loads(run( + bins / "caseworkctl", "package", casework / "project", + "--output", casework / "package", + "--revision", "agriculture-openfn-pilot-0.38.0", + "--format", "json", + )) + write(casework / "package-report.json", package_report) + write(casework / "secrets" / "casework-audit-key", secrets.token_hex(32)) + shutil.copyfile( + root / "breg" / "secrets" / "breg-casework-webhook", + casework / "secrets" / "breg-casework-webhook", + ) + (casework / "secrets" / "breg-casework-webhook").chmod(0o600) + reader = bindings["clients"]["casework-reader"] + write(casework / "secrets" / "breg-reader-client-id", reader["clientId"]) + shutil.copyfile(reader["privateJwk"], casework / "secrets" / "breg-reader-key") + (casework / "secrets" / "breg-reader-key").chmod(0o600) + write(casework / "runtime.yaml", { + "apiVersion": "registry.registrystack.org/casework-runtime/v1alpha1", + "kind": "CaseworkRuntimeConfig", + "identity": {"databaseId": "agriculture-casework-pilot-038"}, + "package": { + "root": "/config/casework/package", + "expectedDigest": package_report["packageDigest"], + }, + "listener": { + "bind": "127.0.0.1:8092", + "tlsTermination": "development-loopback", + "networkExposure": "private-address", + }, + "secretProviders": {"file": {"root": "/config/casework/secrets"}}, + "database": { + "runtimeUrlRef": "secret:file/runtime-database-url", + "migrationUrlRef": "secret:file/migration-database-url", + "trustedRootCertificateRef": "secret:file/database-ca.pem", + }, + "authentication": {"oidc": { + "issuer": bindings["issuer"], + "audience": bindings["resource"], + "scopeClaim": "scope", + "jwksSource": { + "kind": "static", + "documentRef": "secret:file/issuer-jwks", + }, + "allowedClients": [ + bindings["clients"][logical_id]["clientId"] + for logical_id in ( + "reviewer", "casework-admin", "casework-producer", + ) + ], + "humanIdentity": { + "claim": "registry_actor_kind", + "value": "human", + }, + }}, + "audit": { + "path": "/var/lib/registry-casework/audit.ndjson", + "hashKeyRef": "secret:file/casework-audit-key", + }, + "sources": {"agricultural-holdings": { + "baseUrl": "http://127.0.0.1:8090", + "readerProfile": "casework-reader", + "tokenEndpoint": bindings["tokenEndpoint"], + "clientAssertionAudience": bindings["clientAssertionAudience"], + "resource": bindings["resource"], + "scopes": ["casework:source-reader"], + "clientIdRef": "secret:file/breg-reader-client-id", + "clientAssertionKeyRef": "secret:file/breg-reader-key", + "trustedRootCertificatesRef": "secret:file/pilot-ca.pem", + "webhookSecretRef": "secret:file/breg-casework-webhook", + "eventSource": ( + "urn:registrystack:registry:agricultural-holdings:" + "instance:agriculture-openfn-pilot-038" + ), + }}, + }) + + +def prepare_evidence(root: Path, bins: Path, bindings: dict) -> int: + evidence = root / "evidence" + project = evidence / "project" + shutil.copytree(HERE / "evidence-starter", project) + write( + project / "evidence-project.yaml", + "version: 1\nproject: evidence-authoring\n", + ) + export = evidence / "source-export" + run( + bins / "bregctl", "generate", "evidence-source", + root / "breg" / "registry", + "--access-profile", "evidence-source", + "--entity", "farm", + "--selector", "by-local-identifier", + "--fields", "local-identifier", + "--source-id", "holding-register", + "--connection", "registry", + "--output", export, + ) + with tempfile.TemporaryDirectory( + prefix=".evidence-authoring-", dir=root + ) as temporary: + temporary_root = Path(temporary) + run( + bins / "evidencectl", "keygen", "signing", + "--output-dir", temporary_root / "key", + ) + run( + bins / "evidencectl", "target", "new", temporary_root / "target", + "--settings", project / "targets" / "production" / "settings.yaml", + "--signing-public-key", + temporary_root / "key" / "signing-p256-public.jwk.json", + ) + run( + bins / "evidencectl", "source", "import", export, + "--project", project, + "--target", temporary_root / "target", + ) + check = run( + bins / "evidencectl", "check", project, + "--target", temporary_root / "target", + "--production", "--format", "json", + ) + fixtures = run( + bins / "evidencectl", "test", project, + "--target", temporary_root / "target", + "--format", "json", + ) + write(evidence / "check-report.json", check) + write(evidence / "fixture-report.json", fixtures) + (evidence / "secrets").mkdir(mode=0o700) + for name in ("audit-hmac-key", "subject-binding-hmac-key"): + run( + bins / "evidencectl", "keygen", "secret", + "--output", evidence / "secrets" / name, + ) + source = bindings["clients"]["evidence-source"] + write(evidence / "secrets" / "registry-client-id", source["clientId"]) + shutil.copyfile( + source["privateJwk"], evidence / "secrets" / "registry-client-key" + ) + (evidence / "secrets" / "registry-client-key").chmod(0o600) + return int(json.loads(fixtures)["evaluatedCases"]) + + +def prepare(root: Path, bins: Path) -> None: if root.exists(): - raise SystemExit('Output exists. Reuse the retained runtime, or choose a new --output directory.') - for name in ['bregctl','evidencectl','evidence','mint']: - if run(bins/name,'--version').split()[-1] != '0.27.0': - raise SystemExit('This pilot requires matching Registry Stack 0.27.0 binaries.') + raise SystemExit("Output exists. Retain it, or choose a new --output directory.") + check_versions(bins) + os.environ["PATH"] = str(bins) + os.pathsep + os.environ.get("PATH", "") + os.environ["EVIDENCE_BIN"] = str(bins / "evidence") os.umask(0o077) root.mkdir(parents=True, mode=0o700) - bregctl, evidencectl = bins/'bregctl', bins/'evidencectl' - os.environ['EVIDENCE_BIN'] = str(bins/'evidence') - for name in ['breg', 'mint', 'bridge', 'evidence', 'evidence-client', 'openfn', 'breg-db']: - (root/name).mkdir(mode=0o700) - breg = root/'breg' - shutil.copytree(HERE/'registry', breg/'registry') - clients = json.loads((HERE/'registry/clients.yaml').read_text())['clients'] - mint_keys = root/'mint/keys' - run(evidencectl, 'keygen', 'signing', '--output-dir', mint_keys) - mint_public = json.loads((mint_keys/'signing-p256-public.jwk.json').read_text()) - mint_kid = mint_public['kid'] - run(evidencectl, 'keygen', 'secret', '--output', mint_keys/'audit-hmac-key') - write(root/f'mint/public-keys/{mint_kid}.jwk.json', mint_public) - for client in clients + [{'id':'openfn-evidence','scopes':['evidence:request'],'claims':{'evidence_tags':['holding-verifier'],'evidence_audience':'urn:example:audience:openfn-pilot'}}]: - ident = client['id'] - key_dir = breg/f'clients/{ident}' if ident != 'openfn-evidence' else root/'evidence-client/keys' - run(evidencectl, 'keygen', 'signing', '--output-dir', key_dir) - public = json.loads((key_dir/'signing-p256-public.jwk.json').read_text()) - write(root/f'mint/clients/{ident}.yaml', {'clientId':ident,'principal':f'urn:example:pilot:{ident}','authorization':{'scopes':client['scopes'],'claims':client['claims']},'keys':[public]}) - write(root/'mint/mint.json', {'version':1,'validationMode':'supervised-local-development','issuer':'http://127.0.0.1:8091','listener':{'address':'127.0.0.1','port':8091},'signing':{'algorithm':'ES256','activePublicJwkFile':f'public-keys/{mint_kid}.jwk.json','publishedPublicJwkFiles':[],'revokedKeyIds':[]},'signer':{'kind':'local-jwk','privateKeyRef':'secret:file/signing-p256-private-jwk'},'secretProviders':{'file':{'root':'/config/mint/keys'}},'audit':{'path':'/var/lib/mint/mint.jsonl','maximumFileBytes':10485760,'hashKeyRef':'secret:file/audit-hmac-key','hashKeyVersion':1},'accessTokens':{'audiences':['urn:example:audience:agriculture-pilot'],'lifetimeSeconds':300},'clientAssertion':{'audience':'http://127.0.0.1:8091/token','maximumLifetimeSeconds':120,'algorithms':['ES256']},'clients':{'directory':'clients'}}) - for name in ['audit-key','cursor-key','webhook-key']: - write(breg/f'secrets/{name}', secrets.token_hex(32)) - write(breg/'secrets/mint-jwks', {'keys':[mint_public]}) - password = secrets.token_hex(24) - write(root/'breg-db/password', password) - write(root/'breg-db/postgres.env', f'POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={password}\nPOSTGRES_DB=postgres\n') - for mode,database in [('', 'agriculture'),('test-', 'agriculture_test')]: - for role in ['runtime','migration']: - write(breg/f'secrets/{mode}{role}-database-url', f'postgresql://agriculture_{role}:{password}@breg-db:5432/{database}') - sql = ''.join(f"CREATE ROLE agriculture_{role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}';\n" for role in ['runtime','migration']) - for db in ['agriculture', 'agriculture_test']: - sql += f'CREATE DATABASE {db};\n\\connect {db}\nCREATE EXTENSION IF NOT EXISTS btree_gist;\nREVOKE ALL ON DATABASE {db} FROM PUBLIC;\nGRANT CONNECT ON DATABASE {db} TO agriculture_runtime, agriculture_migration;\n' - for schema in ['registry_internal','registry_data','registry_source','registry_derived','registry_context']: - sql += f'CREATE SCHEMA {schema} AUTHORIZATION agriculture_migration;\nREVOKE ALL ON SCHEMA {schema} FROM PUBLIC;\n' - write(root/'breg-db/bootstrap.sql', sql) - run('openssl','req','-x509','-new','-nodes','-newkey','rsa:2048','-sha256','-days','30','-subj','/CN=Synthetic agriculture pilot CA','-keyout',root/'breg-db/ca.key','-out',root/'breg-db/ca.pem') - run('openssl','req','-new','-nodes','-newkey','rsa:2048','-subj','/CN=breg-db','-keyout',root/'breg-db/server.key','-out',root/'breg-db/server.csr') - write(root/'breg-db/server.ext','subjectAltName=DNS:breg-db\n') - run('openssl','x509','-req','-sha256','-days','30','-in',root/'breg-db/server.csr','-CA',root/'breg-db/ca.pem','-CAkey',root/'breg-db/ca.key','-CAcreateserial','-extfile',root/'breg-db/server.ext','-out',root/'breg-db/server.crt') - shutil.copyfile(root/'breg-db/ca.pem',breg/'secrets/database-ca.pem') - runtime = {'apiVersion':'registry.registrystack.org/breg-runtime/v1alpha1','kind':'BRegRuntimeConfig','listener':{'bind':'0.0.0.0:8090'},'identity':{'environment':'local','instanceId':'agriculture-openfn-pilot','databaseId':'agriculture-pilot-db','databaseInitializationEnvironment':'local'},'secretProviders':{'file':{'root':'/config/breg/secrets'}},'database':{'runtimeUrlRef':'secret:file/runtime-database-url','migrationUrlRef':'secret:file/migration-database-url','pool':{'maxSize':4},'roles':{'migration':'agriculture_migration','runtime':'agriculture_runtime'}},'package':{'root':'/config/breg/build/package','trustAnchorPath':'/config/breg/trust-anchor.json','compilerSourceRevision':'agriculture-openfn-pilot-v1','activeRevision':'sha256:'+'1'*64,'activeSequence':1},'authentication':{'oidc':{'issuer':'http://127.0.0.1:8091','audience':'urn:example:audience:agriculture-pilot','allowedAlgorithm':'ES256','accessTokenType':'at+jwt','scopeClaim':'scope','scopeSeparator':' ','allowedClients':[c['id'] for c in clients],'deniedKids':[],'maxTokenLifetimeSeconds':300,'leewayMilliseconds':30000,'jwksSource':{'kind':'static','documentRef':'secret:file/mint-jwks'}},'authorityClaims':{'principal':'registry_principal','purpose':'registry_purpose'}},'audit':{'hashKeyRef':'secret:file/audit-key'},'cursor':{'secretRef':'secret:file/cursor-key'},'eventDestinations':{'openfn':{'origin':'http://127.0.0.1:8081','path':'/events/breg','networkProfile':'loopbackDevelopmentHttp','dnsFamily':'dualStackStrict','allowedPrivateCidrs':[],'hmacSha256KeyRef':'secret:file/webhook-key','classificationCeiling':'restricted','deliveryCeilings':{'attemptTimeoutMilliseconds':5000,'maximumAttempts':5}}},'eventDelivery':{'payloadRetentionDays':1}} - write(breg/'runtime-template.json', runtime) - test = json.loads(json.dumps(runtime)); test['database'].update(runtimeUrlRef='secret:file/test-runtime-database-url',migrationUrlRef='secret:file/test-migration-database-url'); test['package']['root']='/config/breg/empty-package' - (breg/'empty-package').mkdir() - write(breg/'test-runtime.json',test); write(breg/'trust-anchor.json',{}) - bindings=[] - journeys=json.loads((breg/'registry/tests/journeys.yaml').read_text()) - for journey in journeys['journeys']: - for step in journey['steps']: - bindings.append({'journeyId':journey['id'],'stepId':step['id'],'credential':{'type':'bearer','tokenRef':'secret:file/token-'+step['accessProfile']}}) - write(breg/'schema-test-credentials.json',{'apiVersion':'registry.registrystack.org/breg-schema-test-credentials/v1','kind':'SchemaTestCredentials','bindings':bindings}) - write(root/'bridge/breg-hmac-key',(breg/'secrets/webhook-key').read_text()) - events={} - for event in ['farm-created-v1','farm-patched-v1']: - sample=json.loads(run(bregctl,'webhook','sample',breg/'registry','--event',event,'--format','json')) - events[event]={'schema':sample['request']['headers']['ce-dataschema'],'trigger':sample['request']['body']['trigger']} - write(root/'bridge/expected-events.json',events); write(root/'bridge/allowed-value-fields.json',['local-identifier']) - write(root/'bridge/expected-source','urn:registrystack:registry:agricultural-holdings:instance:agriculture-openfn-pilot') - evidence=root/'evidence'; project=evidence/'project' - run(evidencectl,'new',project,'--starter',HERE/'evidence-starter','--profile','local') - settings=json.loads((HERE/'evidence-starter/targets/local/settings.yaml').read_text()) - settings['runtime']['bundleDirectory']=str(evidence/'candidate/bundle'); settings['runtime']['secretProviders']['file']['root']=str(project/'secrets'); settings['runtime']['auditStorage']['path']='/var/lib/evidence/evidence.jsonl' - write(evidence/'settings.json',settings) - run(evidencectl,'target','new',evidence/'target','--settings',evidence/'settings.json','--signing-public-key',project/'secrets/signing-p256-public.jwk.json') - run(bregctl,'generate','evidence-source',breg/'registry','--access-profile','evidence-source','--entity','farm','--selector','by-local-identifier','--fields','local-identifier','--source-id','holding-register','--connection','registry','--output',evidence/'source-export') - run(evidencectl,'source','import',evidence/'source-export','--project',project,'--target',evidence/'target') - fixture_report=run(evidencectl,'fixtures','run','--project',project,'--target',evidence/'target','--json'); write(evidence/'fixture-report.json',fixture_report) - run(evidencectl,'build','--project',project,'--target',evidence/'target','--output',evidence/'candidate') - runtime_path=evidence/'candidate/runtime.yaml'; ev_runtime=yaml.safe_load(runtime_path.read_text()); ev_runtime['bundleDirectory']='/config/evidence/candidate/bundle'; ev_runtime['secretProviders']['file']['root']='/config/evidence/project/secrets'; write(evidence/'runtime.yaml',ev_runtime) - (evidence/'runtime.yaml').chmod(0o400) - write(project/'secrets/registry-client-id','evidence-source') - write(project/'secrets/registry-client-key',(breg/'clients/evidence-source/signing-p256-private-jwk').read_text()) - run(evidencectl,'client','profile','create','--base-url','http://127.0.0.1:8080','--client-id','openfn-evidence','--private-key-file','keys/signing-p256-private-jwk','--local-loopback-discovery','--expected-audience','urn:example:audience:openfn-pilot','--output',root/'evidence-client/profile.json') - key=json.loads((breg/'clients/openfn-service/signing-p256-private-jwk').read_text()) - breg_credential={'breg':{'baseUrl':'http://127.0.0.1:8090','authorization':{'privateKeyJwt':{'tokenEndpoint':'http://127.0.0.1:8091/token','clientId':'openfn-service','clientKey':key}}}} - write(root/'openfn/breg-credential.json',breg_credential) - write(root/'openfn/evidence-credential.json',{'evidence':{'profilePath':'/config/evidence-client/profile.json'}}) - write(root/'workflow-bindings.json',{'breg':{'accessProfile':'openfn-service','farmEntity':'farm','farmCreateOperation':'records.farm.create','selector':'by-local-identifier','correctionEntity':'name-correction','correctionCreateOperation':'records.name-correction.create','correctionSubmitOperation':'records.name-correction.request.submit'},'evidence':{'requirement':'holding-registered','requirementId':REQUIREMENT,'selectorProfile':PROFILE,'purpose':'holding-verification','registeredConcept':'urn:example:concept:holding-registered:registered'}}) - write(root/'prepared.json',{'schema':'synthetic-agriculture-pilot/v1','version':'0.27.0','fixtures':8}) - print('Prepared isolated synthetic configuration, source export and 8 passing Evidence fixtures.') - - -if __name__ == '__main__': - parser=argparse.ArgumentParser(description=__doc__) - parser.add_argument('--bin-dir',type=Path,required=True) - parser.add_argument('--output',type=Path,default=HERE/'.runtime') - args=parser.parse_args() - prepare(args.output.resolve(),args.bin_dir.resolve()) + for name in ( + "breg", "casework", "evidence", "evidence-client", "openfn", "bridge", + "breg-db", "casework-db", "openbao", "evidence/transit-public", + ): + (root / name).mkdir(parents=True, exist_ok=True, mode=0o700) + + bindings = prepare_issuer(root, client_descriptors(root)) + write(root / "issuer-bindings.json", bindings) + ca_bundle = Path(bindings["caBundle"]) + for destination in ( + root / "breg" / "secrets" / "pilot-ca.pem", + root / "casework" / "secrets" / "pilot-ca.pem", + root / "evidence" / "tls" / "pilot-ca.pem", + root / "evidence-client" / "pilot-ca.pem", + ): + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + shutil.copyfile(ca_bundle, destination) + destination.chmod(0o600) + + breg = root / "breg" + shutil.copytree(HERE / "registry", breg / "registry") + write( + breg / "check-report.json", + run(bins / "bregctl", "check", breg / "registry", "--format", "json"), + ) + for name in ( + "audit-key", "cursor-key", "openfn-webhook-key", "breg-casework-webhook" + ): + write(breg / "secrets" / name, secrets.token_hex(32)) + producer = bindings["clients"]["casework-producer"] + write( + breg / "secrets" / "casework-producer-client-id", + producer["clientId"], + ) + shutil.copyfile( + producer["privateJwk"], + breg / "secrets" / "casework-producer-private-jwk", + ) + (breg / "secrets" / "casework-producer-private-jwk").chmod(0o600) + write(breg / "runtime-template.yaml", breg_runtime(bindings, test=False)) + write(breg / "test-runtime.yaml", breg_runtime(bindings, test=True)) + (breg / "empty-package").mkdir(mode=0o700) + schema_credentials( + breg / "registry", breg / "schema-test-credentials.json" + ) + + database_files( + root, "breg", "agriculture", + ("agriculture_migration", "agriculture_runtime"), + ) + database_files( + root, "casework", "agriculture_casework", + ("casework_migration", "casework_runtime"), + ) + write( + breg / "secrets" / "trust-bundle.pem", + (breg / "secrets" / "database-ca.pem").read_text(encoding="utf-8") + + ca_bundle.read_text(encoding="utf-8"), + ) + prepare_casework(root, bins, bindings) + fixture_count = prepare_evidence(root, bins, bindings) + + evidence_client = bindings["clients"]["openfn-evidence"] + client_key = root / "evidence-client" / "keys" / "signing-p256-private-jwk" + client_key.parent.mkdir(mode=0o700) + shutil.copyfile(evidence_client["privateJwk"], client_key) + client_key.chmod(0o600) + + write( + root / "bridge" / "breg-hmac-key", + (breg / "secrets" / "openfn-webhook-key").read_text(), + ) + events = {} + for event in ("farm-created-v1", "farm-patched-v1"): + sample = json.loads(run( + bins / "bregctl", "webhook", "sample", breg / "registry", + "--event", event, "--format", "json", + )) + events[event] = { + "schema": sample["request"]["headers"]["ce-dataschema"], + "trigger": sample["request"]["body"]["data"]["trigger"], + } + write(root / "bridge" / "expected-events.json", events) + write(root / "bridge" / "allowed-value-fields.json", ["local-identifier"]) + write( + root / "bridge" / "expected-source", + "urn:registrystack:registry:agricultural-holdings:" + "instance:agriculture-openfn-pilot-038", + ) + + openfn = bindings["clients"]["openfn-service"] + write(root / "openfn" / "breg-credential.json", {"breg": { + "baseUrl": "http://127.0.0.1:8090", + "authorization": {"privateKeyJwt": { + "tokenEndpoint": bindings["tokenEndpoint"], + "clientId": openfn["clientId"], + "clientKey": json.loads( + Path(openfn["privateJwk"]).read_text(encoding="utf-8") + ), + "audience": bindings["clientAssertionAudience"], + "resource": bindings["resource"], + "scopes": openfn["scopes"], + "trustedRootCertificates": ca_bundle.read_text(encoding="utf-8"), + }}, + }}) + write(root / "workflow-bindings.json", { + "breg": { + "accessProfile": "openfn-service", + "farmEntity": "farm", + "farmCreateOperation": "records.farm.create", + "selector": "by-local-identifier", + "correctionEntity": "name-correction", + "correctionCreateOperation": "records.name-correction.create", + "correctionSubmitOperation": "records.name-correction.request.submit", + }, + "evidence": { + "requirement": "holding-registered", + "requirementId": REQUIREMENT, + "selectorProfile": PROFILE, + "purpose": "holding-verification", + "registeredConcept": ( + "urn:example:concept:holding-registered:registered" + ), + }, + }) + write(root / "prepared.json", { + "schema": "synthetic-agriculture-pilot/v1", + "version": VERSION, + "fixtures": fixture_count, + "evidencePackage": "pending-transit-public-key", + }) + secure_prepared_tree(root) + print( + "Prepared isolated Registry Stack 0.38 BReg, Casework, " + "and Evidence authoring inputs." + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bin-dir", type=Path, required=True) + parser.add_argument( + "--output", type=Path, default=HERE / ".runtime-0.38" + ) + arguments = parser.parse_args() + prepare(arguments.output.resolve(), arguments.bin_dir.resolve()) diff --git a/pilot/agriculture/registry/ATTRIBUTION.md b/pilot/agriculture/registry/ATTRIBUTION.md index 14038f7..9ba9963 100644 --- a/pilot/agriculture/registry/ATTRIBUTION.md +++ b/pilot/agriculture/registry/ATTRIBUTION.md @@ -5,7 +5,7 @@ by the PublicSchema contributors, licensed under [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/). The exact draft source is commit -[`1ea9ce333918693b29aec31068fac412e02cb8dc`](https://github.com/PublicSchema/publicschema.org/tree/1ea9ce333918693b29aec31068fac412e02cb8dc). +[`e5cfbbc0161e4d2c802d1acd752ec193253f7820`](https://github.com/PublicSchema/publicschema.org/tree/e5cfbbc0161e4d2c802d1acd752ec193253f7820). The version string is 0.3.0; the commit, rather than that unchanged version string, identifies the draft vocabulary used here. The distribution includes `PUBLICSCHEMA-LICENSE.txt`. diff --git a/pilot/agriculture/registry/MODEL.md b/pilot/agriculture/registry/MODEL.md index 519d131..acf2228 100644 --- a/pilot/agriculture/registry/MODEL.md +++ b/pilot/agriculture/registry/MODEL.md @@ -2,7 +2,7 @@ This is a synthetic local learning profile. All access is authenticated and restricted to the declared profiles. Browser previews never authorize local -requests. The registry-wide grants and historical reads are explicit policy: +requests. The registry-wide permissions and historical reads are explicit policy: there are no hidden jurisdiction or ownership row filters. An institution must review these boundaries before production deployment. @@ -12,13 +12,13 @@ review these boundaries before production deployment. | --- | --- | --- | | Reader | Get and list Farm records only; no person, organization, informal-group or holder-role access | No access | | Editor | Create, get, list and inspect history; PATCH only where the entity is not controlled | Create and edit own drafts; submit, revise or cancel own requests | -| Reviewer | Get, list and inspect history | Read proposed fields and target fields, approve, reject, request revision and apply | +| Maintainer | Get, list and inspect history | Read proposed fields and external review status; apply an exactly approved proposal | -Every operation absent from the authored grants is denied, including deletion. +Every operation absent from the authored permissions is denied, including deletion. The full PATCH operation on Farm is controlled. The correction changes -only `name`; it cannot change identity or holder/link endpoints. A reviewer is -also the authorized applier. Review excludes the submitting principal, even -if that person acquires a reviewer role. Approval leaves the target unchanged. +only `name`; it cannot change identity or holder/link endpoints. A maintainer is +the authorized source applier. Registry Casework independently owns reviewer +eligibility and separation from the submitter. External approval leaves the target unchanged. Application uses the native frozen effect and expected target revision. A stale target requires the native revision/rebase and review path. @@ -52,7 +52,7 @@ state binds exact input bytes and captured UUIDs to each attempt. Farm is a production unit, not a Group. Person, Organization and InformalGroup are separate minimal identities. The three concrete holder roles target Farm -and exactly Person, Organization or InformalGroup. HoldingOperatorRole is +and exactly Person, Organization or InformalGroup. AgriculturalHolderRole is abstract and has no route. The draft permits broader Group types, while this fixed profile deliberately restricts group holders to InformalGroup. @@ -74,7 +74,7 @@ are outside this package. ## Attribution and adoption The model maps selected fields and native references to PublicSchema draft -concepts at commit `1ea9ce333918693b29aec31068fac412e02cb8dc`, under CC BY 4.0. +concepts at commit `e5cfbbc0161e4d2c802d1acd752ec193253f7820`, under CC BY 4.0. See `PUBLICSCHEMA-LICENSE.txt` and the pinned source in `starter-template.json`. The local key representation, permissions, review policy, reference target narrowing and validation rules are Registry Stack starter decisions. Local diff --git a/pilot/agriculture/registry/clients.yaml b/pilot/agriculture/registry/clients.yaml deleted file mode 100644 index be59602..0000000 --- a/pilot/agriculture/registry/clients.yaml +++ /dev/null @@ -1,70 +0,0 @@ -{ - "version": 1, - "clients": [ - { - "id": "reader", - "accessProfiles": [ - "reader" - ], - "scopes": [ - "starter:reader" - ], - "claims": { - "registry_principal": "synthetic-reader", - "registry_purpose": "starter-learning" - } - }, - { - "id": "editor", - "accessProfiles": [ - "editor" - ], - "scopes": [ - "starter:editor" - ], - "claims": { - "registry_principal": "synthetic-editor", - "registry_purpose": "starter-learning" - } - }, - { - "id": "reviewer", - "accessProfiles": [ - "reviewer" - ], - "scopes": [ - "starter:reviewer" - ], - "claims": { - "registry_principal": "synthetic-reviewer", - "registry_purpose": "starter-learning" - } - }, - { - "id": "openfn-service", - "accessProfiles": [ - "openfn-service" - ], - "scopes": [ - "pilot:submit" - ], - "claims": { - "registry_principal": "synthetic-openfn-service", - "registry_purpose": "agriculture-registration" - } - }, - { - "id": "evidence-source", - "accessProfiles": [ - "evidence-source" - ], - "scopes": [ - "pilot:evidence-source" - ], - "claims": { - "registry_principal": "synthetic-evidence-source", - "registry_purpose": "evidence-source-read" - } - } - ] -} diff --git a/pilot/agriculture/registry/dev-clients.yaml b/pilot/agriculture/registry/dev-clients.yaml new file mode 100644 index 0000000..3a054c0 --- /dev/null +++ b/pilot/agriculture/registry/dev-clients.yaml @@ -0,0 +1,78 @@ +version: 1 +clients: + - id: reader + accessProfiles: + - reader + scopes: + - starter:reader + claims: + registry_actor_kind: service + registry_principal: synthetic-reader + registry_purpose: starter-learning + - id: editor + accessProfiles: + - editor + allowHumanFixture: true + scopes: + - starter:editor + claims: + registry_actor_kind: human + registry_principal: synthetic-editor + registry_purpose: starter-learning + - id: reviewer + accessProfiles: [] + allowBregAccess: true + allowHumanFixture: true + scopes: + - casework:staff + - starter:reviewer + claims: + registry_principal: synthetic-reviewer + registry_purpose: starter-learning + registry_actor_kind: human + - id: openfn-service + accessProfiles: + - openfn-service + allowBregAccess: true + allowHumanFixture: true + scopes: + - pilot:submit + claims: + registry_principal: synthetic-openfn-service + registry_purpose: agriculture-registration + registry_actor_kind: human + - id: evidence-source + accessProfiles: + - evidence-source + scopes: + - pilot:evidence-source + claims: + registry_principal: synthetic-evidence-source + registry_purpose: evidence-source-read + registry_actor_kind: service + - id: "casework-reader" + accessProfiles: ["casework-reader"] + scopes: ["casework:source-reader"] + claims: + "registry_actor_kind": "service" + "registry_principal": "casework-reader" + "registry_purpose": "casework-sync" + - id: "casework-admin" + accessProfiles: [] + scopes: ["casework:admin"] + allowHumanFixture: true + claims: + "registry_actor_kind": "human" + "registry_principal": "synthetic-casework-admin" + - id: "casework-producer" + accessProfiles: [] + scopes: ["casework:reviews:request"] + claims: + "registry_actor_kind": "service" +reviewAuthorities: + "casework": + endpoint: "http://127.0.0.1:8092" + profile: "integration-requester" + producerId: "registry-breg" + recoveryDays: 7 + client: "casework-producer" diff --git a/pilot/agriculture/registry/examples/inputs/starter-data.json b/pilot/agriculture/registry/examples/inputs/starter-data.json index a3b89ad..525b36b 100644 --- a/pilot/agriculture/registry/examples/inputs/starter-data.json +++ b/pilot/agriculture/registry/examples/inputs/starter-data.json @@ -28,37 +28,37 @@ "name": "Hill Growers Group" }, "ana-role": { - "operatedHolding": { + "holderFarm": { "recordRef": "river" }, - "holdingOperatorPerson": { + "holderPerson": { "recordRef": "ana" }, "startDate": "2026-01-01" }, "bo-role": { - "operatedHolding": { + "holderFarm": { "recordRef": "river" }, - "holdingOperatorPerson": { + "holderPerson": { "recordRef": "bo" }, "startDate": "2026-01-01" }, "cooperative-role": { - "operatedHolding": { + "holderFarm": { "recordRef": "landless" }, - "holdingOperatorOrganization": { + "holderOrganization": { "recordRef": "cooperative" }, "startDate": "2026-01-01" }, "group-role": { - "operatedHolding": { + "holderFarm": { "recordRef": "collective" }, - "holdingOperatorGroup": { + "holderGroup": { "recordRef": "group" }, "startDate": "2026-01-01" diff --git a/pilot/agriculture/registry/examples/scenarios.json b/pilot/agriculture/registry/examples/scenarios.json index e1d772d..fe28c35 100644 --- a/pilot/agriculture/registry/examples/scenarios.json +++ b/pilot/agriculture/registry/examples/scenarios.json @@ -72,7 +72,7 @@ { "id": "ana-role", "operation": "create", - "entity": "person-holding-operator-role", + "entity": "person-agricultural-holder-role", "client": "editor", "accessProfile": "editor", "input": "ana-role", @@ -81,7 +81,7 @@ { "id": "bo-role", "operation": "create", - "entity": "person-holding-operator-role", + "entity": "person-agricultural-holder-role", "client": "editor", "accessProfile": "editor", "input": "bo-role", @@ -90,7 +90,7 @@ { "id": "cooperative-role", "operation": "create", - "entity": "organization-holding-operator-role", + "entity": "organization-agricultural-holder-role", "client": "editor", "accessProfile": "editor", "input": "cooperative-role", @@ -99,7 +99,7 @@ { "id": "group-role", "operation": "create", - "entity": "group-holding-operator-role", + "entity": "group-agricultural-holder-role", "client": "editor", "accessProfile": "editor", "input": "group-role", @@ -133,7 +133,7 @@ }, { "id": "reviewed-change", - "description": "Submit, inspect, approve or reject, apply, and inspect history. Approval alone changes no record.", + "description": "Submit and inspect a proposal, then apply it after its external review is approved, and inspect history.", "input": "examples/inputs/reviewed-change.json", "steps": [ { @@ -161,22 +161,6 @@ "accessProfile": "reviewer", "record": "request" }, - { - "id": "approve", - "operation": "approve", - "entity": "name-correction", - "client": "reviewer", - "accessProfile": "reviewer", - "record": "request" - }, - { - "id": "reject", - "operation": "reject", - "entity": "name-correction", - "client": "reviewer", - "accessProfile": "reviewer", - "record": "request" - }, { "id": "apply", "operation": "apply", diff --git a/pilot/agriculture/registry/registry.yaml b/pilot/agriculture/registry/registry.yaml index d7bcfa3..b95510b 100644 --- a/pilot/agriculture/registry/registry.yaml +++ b/pilot/agriculture/registry/registry.yaml @@ -1,1067 +1,747 @@ -{ - "apiVersion": "registry.registrystack.org/v1alpha1", - "kind": "RegistryProject", - "registry": { - "id": "agricultural-holdings", - "canonicalBaseIri": "https://agricultural-holdings.example.org", - "version": "0.1.0", - "defaultLanguage": "en" - }, - "package": { - "environment": "local", - "instanceId": "agriculture-openfn-pilot", - "sequence": 1, - "sourceRevision": "agriculture-openfn-pilot-v1" - }, - "manifestProjection": { - "accessProfile": "editor", - "classificationCeiling": "restricted", - "catalog": { - "baseUrl": "https://agricultural-holdings.example.org", - "title": "Agricultural holding register", - "description": "Maintain agricultural production units and separately asserted holder responsibilities.", - "publisher": { - "id": "synthetic-custodian", - "name": "Synthetic example custodian" - } - }, - "publicService": { - "id": "directory-service", - "title": "Agricultural holding register" - }, - "datasets": [ - { - "id": "directory", - "title": "Agricultural holding register", - "description": "Maintain agricultural production units and separately asserted holder responsibilities.", - "owner": "Synthetic example custodian", - "status": "active" - } - ], - "dataServices": [ - { - "id": "directory-api", - "title": "Agricultural holding register", - "endpointUrl": "https://agricultural-holdings.example.org", - "servesDatasets": [ - "directory" - ] - } - ], - "distributions": [], - "entities": [ - { - "id": "farm", - "title": "Farm", - "conceptUri": "https://publicschema.org/agri/Farm", - "identifiers": [ - { - "field": "local-identifier", - "kind": "local" - } - ], - "fields": [ - { - "id": "name", - "concepts": [ - "https://publicschema.org/name" - ] - } - ] - }, - { - "id": "person", - "title": "Person", - "conceptUri": "https://publicschema.org/Person", - "identifiers": [ - { - "field": "local-identifier", - "kind": "local" - } - ], - "fields": [ - { - "id": "name", - "concepts": [ - "https://publicschema.org/name" - ] - } - ] - }, - { - "id": "organization", - "title": "Organization", - "conceptUri": "https://publicschema.org/Organization", - "identifiers": [ - { - "field": "local-identifier", - "kind": "local" - } - ], - "fields": [ - { - "id": "name", - "concepts": [ - "https://publicschema.org/name" - ] - } - ] - }, - { - "id": "informal-group", - "title": "Informal Group", - "conceptUri": "https://publicschema.org/InformalGroup", - "identifiers": [ - { - "field": "local-identifier", - "kind": "local" - } - ], - "fields": [ - { - "id": "name", - "concepts": [ - "https://publicschema.org/name" - ] - } - ] - }, - { - "id": "person-holding-operator-role", - "title": "Person Holding Operator Role", - "conceptUri": "https://publicschema.org/agri/PersonHoldingOperatorRole", - "identifiers": [], - "fields": [ - { - "id": "operated-holding", - "relationshipRole": "operated-holding", - "relationshipConceptUri": "https://publicschema.org/agri/operated_holding" - }, - { - "id": "holding-operator-person", - "relationshipRole": "holding-operator-person", - "relationshipConceptUri": "https://publicschema.org/agri/holding_operator_person" - }, - { - "id": "start-date", - "concepts": [ - "https://publicschema.org/start_date" - ] - }, - { - "id": "end-date", - "concepts": [ - "https://publicschema.org/end_date" - ] - } - ] - }, - { - "id": "organization-holding-operator-role", - "title": "Organization Holding Operator Role", - "conceptUri": "https://publicschema.org/agri/OrganizationHoldingOperatorRole", - "identifiers": [], - "fields": [ - { - "id": "operated-holding", - "relationshipRole": "operated-holding", - "relationshipConceptUri": "https://publicschema.org/agri/operated_holding" - }, - { - "id": "holding-operator-organization", - "relationshipRole": "holding-operator-organization", - "relationshipConceptUri": "https://publicschema.org/agri/holding_operator_organization" - }, - { - "id": "start-date", - "concepts": [ - "https://publicschema.org/start_date" - ] - }, - { - "id": "end-date", - "concepts": [ - "https://publicschema.org/end_date" - ] - } - ] - }, - { - "id": "group-holding-operator-role", - "title": "Group Holding Operator Role", - "conceptUri": "https://publicschema.org/agri/GroupHoldingOperatorRole", - "identifiers": [], - "fields": [ - { - "id": "operated-holding", - "relationshipRole": "operated-holding", - "relationshipConceptUri": "https://publicschema.org/agri/operated_holding" - }, - { - "id": "holding-operator-group", - "relationshipRole": "holding-operator-group", - "relationshipConceptUri": "https://publicschema.org/agri/holding_operator_group" - }, - { - "id": "start-date", - "concepts": [ - "https://publicschema.org/start_date" - ] - }, - { - "id": "end-date", - "concepts": [ - "https://publicschema.org/end_date" - ] - } - ] - } - ] - }, - "entities": [ - { - "id": "farm", - "primaryDataset": "directory", - "route": "farms", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "local-identifier", - "type": "string", - "required": true, - "classification": "restricted", - "apiName": "localIdentifier", - "maxLength": 160, - "minLength": 1 - }, - { - "id": "name", - "type": "string", - "required": true, - "classification": "restricted", - "maxLength": 160, - "minLength": 1 - } - ], - "changeControl": { - "requiredFor": [ - "patch" - ] - }, - "constraints": [ - { - "kind": "unique", - "fields": [ - "local-identifier" - ] - } - ], - "selectorProfiles": [ - { - "id": "by-local-identifier", - "fields": [ - "local-identifier" - ] - } - ], - "events": [ - { - "id": "farm-created-v1", - "trigger": "created", - "projection": [ - "local-identifier" - ], - "webhook": { - "destinationId": "openfn" - } - }, - { - "id": "farm-patched-v1", - "trigger": "patched", - "projection": [ - "local-identifier" - ], - "webhook": { - "destinationId": "openfn" - } - } - ] - }, - { - "id": "person", - "primaryDataset": "directory", - "route": "persons", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "local-identifier", - "type": "string", - "required": true, - "classification": "restricted", - "apiName": "localIdentifier", - "maxLength": 160, - "minLength": 1 - }, - { - "id": "name", - "type": "string", - "required": true, - "classification": "restricted", - "maxLength": 160, - "minLength": 1 - } - ], - "constraints": [ - { - "kind": "unique", - "fields": [ - "local-identifier" - ] - } - ] - }, - { - "id": "organization", - "primaryDataset": "directory", - "route": "organizations", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "local-identifier", - "type": "string", - "required": true, - "classification": "restricted", - "apiName": "localIdentifier", - "maxLength": 160, - "minLength": 1 - }, - { - "id": "name", - "type": "string", - "required": true, - "classification": "restricted", - "maxLength": 160, - "minLength": 1 - } - ], - "constraints": [ - { - "kind": "unique", - "fields": [ - "local-identifier" - ] - } - ] - }, - { - "id": "informal-group", - "primaryDataset": "directory", - "route": "informal-groups", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "local-identifier", - "type": "string", - "required": true, - "classification": "restricted", - "apiName": "localIdentifier", - "maxLength": 160, - "minLength": 1 - }, - { - "id": "name", - "type": "string", - "required": true, - "classification": "restricted", - "maxLength": 160, - "minLength": 1 - } - ], - "constraints": [ - { - "kind": "unique", - "fields": [ - "local-identifier" - ] - } - ] - }, - { - "id": "person-holding-operator-role", - "primaryDataset": "directory", - "route": "person-holding-operator-roles", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "operated-holding", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "farm", - "apiName": "operatedHolding" - }, - { - "id": "holding-operator-person", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "person", - "apiName": "holdingOperatorPerson" - }, - { - "id": "start-date", - "type": "date", - "required": false, - "classification": "restricted", - "apiName": "startDate" - }, - { - "id": "end-date", - "type": "date", - "required": false, - "classification": "restricted", - "apiName": "endDate" - } - ], - "constraints": [ - { - "kind": "compare", - "left": "start-date", - "operator": "less_than_or_equal", - "right": "end-date" - } - ] - }, - { - "id": "organization-holding-operator-role", - "primaryDataset": "directory", - "route": "organization-holding-operator-roles", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "operated-holding", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "farm", - "apiName": "operatedHolding" - }, - { - "id": "holding-operator-organization", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "organization", - "apiName": "holdingOperatorOrganization" - }, - { - "id": "start-date", - "type": "date", - "required": false, - "classification": "restricted", - "apiName": "startDate" - }, - { - "id": "end-date", - "type": "date", - "required": false, - "classification": "restricted", - "apiName": "endDate" - } - ], - "constraints": [ - { - "kind": "compare", - "left": "start-date", - "operator": "less_than_or_equal", - "right": "end-date" - } - ] - }, - { - "id": "group-holding-operator-role", - "primaryDataset": "directory", - "route": "group-holding-operator-roles", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "operated-holding", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "farm", - "apiName": "operatedHolding" - }, - { - "id": "holding-operator-group", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "informal-group", - "apiName": "holdingOperatorGroup" - }, - { - "id": "start-date", - "type": "date", - "required": false, - "classification": "restricted", - "apiName": "startDate" - }, - { - "id": "end-date", - "type": "date", - "required": false, - "classification": "restricted", - "apiName": "endDate" - } - ], - "constraints": [ - { - "kind": "compare", - "left": "start-date", - "operator": "less_than_or_equal", - "right": "end-date" - } - ] - }, - { - "id": "name-correction", - "primaryDataset": "directory", - "route": "name-corrections", - "mutationMode": "mutable", - "classification": "restricted", - "fields": [ - { - "id": "record", - "type": "reference", - "required": true, - "classification": "restricted", - "target": "farm" - }, - { - "id": "name", - "type": "string", - "required": true, - "classification": "restricted", - "maxLength": 160, - "minLength": 1 - }, - { - "id": "reason", - "type": "string", - "required": true, - "classification": "restricted", - "maxLength": 500, - "minLength": 1 - }, - { - "id": "supporting-reference", - "type": "string", - "required": true, - "classification": "restricted", - "apiName": "supportingReference", - "maxLength": 500, - "minLength": 1 - } - ], - "changeRequest": { - "effects": [ - { - "target": { - "fromField": "record" - }, - "operation": "patch", - "set": { - "name": { - "fromField": "name" - } - } - } - ], - "review": { - "stages": [ - { - "id": "review", - "approvals": 1, - "excludeSubmitter": true - } - ] - }, - "application": { - "mode": "manual" - }, - "retention": { - "mode": "operator_erase" - } - } - } - ], - "accessProfiles": [ - { - "id": "reader", - "default": false, - "principalClaim": "registry_principal", - "requiredScopes": [ - "starter:reader" - ], - "requiredPurposes": [ - "starter-learning" - ], - "grants": [ - { - "entity": "farm", - "operations": [ - "get", - "list" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [] - } - ] - }, - { - "id": "editor", - "default": true, - "principalClaim": "registry_principal", - "requiredScopes": [ - "starter:editor" - ], - "requiredPurposes": [ - "starter-learning" - ], - "grants": [ - { - "entity": "farm", - "operations": [ - "get", - "list", - "revisions", - "create" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "local-identifier", - "name" - ] - }, - { - "entity": "person", - "operations": [ - "get", - "list", - "revisions", - "create", - "patch" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "local-identifier", - "name" - ] - }, - { - "entity": "organization", - "operations": [ - "get", - "list", - "revisions", - "create", - "patch" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "local-identifier", - "name" - ] - }, - { - "entity": "informal-group", - "operations": [ - "get", - "list", - "revisions", - "create", - "patch" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "local-identifier", - "name" - ] - }, - { - "entity": "person-holding-operator-role", - "operations": [ - "get", - "list", - "revisions", - "create", - "patch" - ], - "readableFields": [ - "operated-holding", - "holding-operator-person", - "start-date", - "end-date" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "operated-holding", - "holding-operator-person", - "start-date", - "end-date" - ] - }, - { - "entity": "organization-holding-operator-role", - "operations": [ - "get", - "list", - "revisions", - "create", - "patch" - ], - "readableFields": [ - "operated-holding", - "holding-operator-organization", - "start-date", - "end-date" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "operated-holding", - "holding-operator-organization", - "start-date", - "end-date" - ] - }, - { - "entity": "group-holding-operator-role", - "operations": [ - "get", - "list", - "revisions", - "create", - "patch" - ], - "readableFields": [ - "operated-holding", - "holding-operator-group", - "start-date", - "end-date" - ], - "rowBoundaries": [], - "revisionAccess": true, - "writableFields": [ - "operated-holding", - "holding-operator-group", - "start-date", - "end-date" - ] - }, - { - "entity": "name-correction", - "operations": [ - "create", - "get", - "patch", - "submit_request", - "revise_request", - "cancel_request" - ], - "readableFields": [ - "record", - "name", - "reason", - "supporting-reference" - ], - "writableFields": [ - "record", - "name", - "reason", - "supporting-reference" - ], - "requestVisibility": "owner", - "rowBoundaries": [] - } - ] - }, - { - "id": "reviewer", - "default": false, - "principalClaim": "registry_principal", - "requiredScopes": [ - "starter:reviewer" - ], - "requiredPurposes": [ - "starter-learning" - ], - "grants": [ - { - "entity": "farm", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "person", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "organization", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "informal-group", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "person-holding-operator-role", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "operated-holding", - "holding-operator-person", - "start-date", - "end-date" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "organization-holding-operator-role", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "operated-holding", - "holding-operator-organization", - "start-date", - "end-date" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "group-holding-operator-role", - "operations": [ - "get", - "list", - "revisions" - ], - "readableFields": [ - "operated-holding", - "holding-operator-group", - "start-date", - "end-date" - ], - "rowBoundaries": [], - "revisionAccess": true - }, - { - "entity": "name-correction", - "operations": [ - "get", - "approve_request", - "reject_request", - "request_revision", - "apply_request" - ], - "readableFields": [ - "record", - "name", - "reason", - "supporting-reference" - ], - "rowBoundaries": [], - "reviewStages": [ - { - "stage": "review", - "targets": [ - { - "entity": "farm", - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [] - } - ] - } - ], - "applyTargets": [ - { - "entity": "farm", - "rowBoundaries": [] - } - ] - } - ] - }, - { - "id": "openfn-service", - "default": false, - "principalClaim": "registry_principal", - "requiredScopes": [ - "pilot:submit" - ], - "requiredPurposes": [ - "agriculture-registration" - ], - "grants": [ - { - "entity": "farm", - "operations": [ - "get", - "create", - "lookup" - ], - "readableFields": [ - "local-identifier", - "name" - ], - "rowBoundaries": [], - "writableFields": [ - "local-identifier", - "name" - ], - "lookups": [ - { - "selector": "by-local-identifier", - "valueOrigin": "request" - } - ] - }, - { - "entity": "name-correction", - "operations": [ - "create", - "get", - "patch", - "submit_request", - "revise_request", - "cancel_request" - ], - "readableFields": [ - "record", - "name", - "reason", - "supporting-reference" - ], - "writableFields": [ - "record", - "name", - "reason", - "supporting-reference" - ], - "requestVisibility": "owner", - "rowBoundaries": [] - } - ] - }, - { - "id": "evidence-source", - "principalClaim": "registry_principal", - "requiredScopes": [ - "pilot:evidence-source" - ], - "requiredPurposes": [ - "evidence-source-read" - ], - "grants": [ - { - "entity": "farm", - "operations": [ - "lookup" - ], - "readableFields": [ - "local-identifier" - ], - "lookups": [ - { - "selector": "by-local-identifier", - "valueOrigin": "request" - } - ], - "rowBoundaries": [] - } - ], - "default": true - } - ] -} +# Registry project for the agricultural-holdings starter: the entities, +# access profiles, and catalogue description that `bregctl check` +# compiles into a running registry. Read MODEL.md alongside it for +# the reasoning behind these choices. canonicalBaseIri below is a +# reserved example.org name standing in for your registry's real +# identity; replace it before you build a production package. +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: agricultural-holdings + canonicalBaseIri: https://agricultural-holdings.example.org + version: 0.1.0 + defaultLanguage: en +package: + sourceRevision: agriculture-openfn-pilot-0.38.0 +manifestProjection: + accessProfile: editor + classificationCeiling: restricted + catalog: + baseUrl: https://agricultural-holdings.example.org + title: Agricultural holding register + description: Maintain agricultural production units and separately asserted holder responsibilities. + publisher: + id: synthetic-custodian + name: Synthetic example custodian + publicService: + id: directory-service + title: Agricultural holding register + datasets: + - id: directory + title: Agricultural holding register + description: Maintain agricultural production units and separately asserted holder responsibilities. + owner: Synthetic example custodian + status: active + dataServices: + - id: directory-api + title: Agricultural holding register + endpointUrl: https://agricultural-holdings.example.org + servesDatasets: + - directory + distributions: [] + entities: + - id: farm + title: Farm + conceptUri: https://publicschema.org/agri/Farm + identifiers: + - field: local-identifier + kind: local + fields: + - id: name + concepts: + - https://publicschema.org/name + - id: person + title: Person + conceptUri: https://publicschema.org/Person + identifiers: + - field: local-identifier + kind: local + fields: + - id: name + concepts: + - https://publicschema.org/name + - id: organization + title: Organization + conceptUri: https://publicschema.org/Organization + identifiers: + - field: local-identifier + kind: local + fields: + - id: name + concepts: + - https://publicschema.org/name + - id: informal-group + title: Informal Group + conceptUri: https://publicschema.org/InformalGroup + identifiers: + - field: local-identifier + kind: local + fields: + - id: name + concepts: + - https://publicschema.org/name + - id: person-agricultural-holder-role + title: Person Agricultural Holder Role + conceptUri: https://publicschema.org/agri/PersonAgriculturalHolderRole + identifiers: [] + fields: + - id: holder-farm + relationshipRole: holder-farm + relationshipConceptUri: https://publicschema.org/agri/holder_farm + - id: holder-person + relationshipRole: holder-person + relationshipConceptUri: https://publicschema.org/agri/holder_person + - id: start-date + concepts: + - https://publicschema.org/start_date + - id: end-date + concepts: + - https://publicschema.org/end_date + - id: organization-agricultural-holder-role + title: Organization Agricultural Holder Role + conceptUri: https://publicschema.org/agri/OrganizationAgriculturalHolderRole + identifiers: [] + fields: + - id: holder-farm + relationshipRole: holder-farm + relationshipConceptUri: https://publicschema.org/agri/holder_farm + - id: holder-organization + relationshipRole: holder-organization + relationshipConceptUri: https://publicschema.org/agri/holder_organization + - id: start-date + concepts: + - https://publicschema.org/start_date + - id: end-date + concepts: + - https://publicschema.org/end_date + - id: group-agricultural-holder-role + title: Group Agricultural Holder Role + conceptUri: https://publicschema.org/agri/GroupAgriculturalHolderRole + identifiers: [] + fields: + - id: holder-farm + relationshipRole: holder-farm + relationshipConceptUri: https://publicschema.org/agri/holder_farm + - id: holder-group + relationshipRole: holder-group + relationshipConceptUri: https://publicschema.org/agri/holder_group + - id: start-date + concepts: + - https://publicschema.org/start_date + - id: end-date + concepts: + - https://publicschema.org/end_date +entities: + - id: farm + primaryDataset: directory + route: farms + mutationMode: mutable + classification: restricted + fields: + - id: local-identifier + type: string + required: true + classification: restricted + apiName: localIdentifier + maxLength: 160 + minLength: 1 + - id: name + type: string + required: true + classification: restricted + maxLength: 160 + minLength: 1 + changeControl: + requiredFor: + - patch + constraints: + - kind: unique + fields: + - local-identifier + selectorProfiles: + - id: by-local-identifier + fields: + - local-identifier + hooks: + - id: farm-created-v1 + phase: after + trigger: created + projection: + - local-identifier + handler: + kind: url + destinationId: openfn + - id: farm-patched-v1 + phase: after + trigger: patched + projection: + - local-identifier + handler: + kind: url + destinationId: openfn + - id: person + primaryDataset: directory + route: persons + mutationMode: mutable + classification: restricted + fields: + - id: local-identifier + type: string + required: true + classification: restricted + apiName: localIdentifier + maxLength: 160 + minLength: 1 + - id: name + type: string + required: true + classification: restricted + maxLength: 160 + minLength: 1 + constraints: + - kind: unique + fields: + - local-identifier + - id: organization + primaryDataset: directory + route: organizations + mutationMode: mutable + classification: restricted + fields: + - id: local-identifier + type: string + required: true + classification: restricted + apiName: localIdentifier + maxLength: 160 + minLength: 1 + - id: name + type: string + required: true + classification: restricted + maxLength: 160 + minLength: 1 + constraints: + - kind: unique + fields: + - local-identifier + - id: informal-group + primaryDataset: directory + route: informal-groups + mutationMode: mutable + classification: restricted + fields: + - id: local-identifier + type: string + required: true + classification: restricted + apiName: localIdentifier + maxLength: 160 + minLength: 1 + - id: name + type: string + required: true + classification: restricted + maxLength: 160 + minLength: 1 + constraints: + - kind: unique + fields: + - local-identifier + - id: person-agricultural-holder-role + primaryDataset: directory + route: person-agricultural-holder-roles + mutationMode: mutable + classification: restricted + fields: + - id: holder-farm + type: reference + required: true + classification: restricted + target: farm + apiName: holderFarm + - id: holder-person + type: reference + required: true + classification: restricted + target: person + apiName: holderPerson + - id: start-date + type: date + required: false + classification: restricted + apiName: startDate + - id: end-date + type: date + required: false + classification: restricted + apiName: endDate + constraints: + - kind: compare + left: start-date + operator: less_than_or_equal + right: end-date + - id: organization-agricultural-holder-role + primaryDataset: directory + route: organization-agricultural-holder-roles + mutationMode: mutable + classification: restricted + fields: + - id: holder-farm + type: reference + required: true + classification: restricted + target: farm + apiName: holderFarm + - id: holder-organization + type: reference + required: true + classification: restricted + target: organization + apiName: holderOrganization + - id: start-date + type: date + required: false + classification: restricted + apiName: startDate + - id: end-date + type: date + required: false + classification: restricted + apiName: endDate + constraints: + - kind: compare + left: start-date + operator: less_than_or_equal + right: end-date + - id: group-agricultural-holder-role + primaryDataset: directory + route: group-agricultural-holder-roles + mutationMode: mutable + classification: restricted + fields: + - id: holder-farm + type: reference + required: true + classification: restricted + target: farm + apiName: holderFarm + - id: holder-group + type: reference + required: true + classification: restricted + target: informal-group + apiName: holderGroup + - id: start-date + type: date + required: false + classification: restricted + apiName: startDate + - id: end-date + type: date + required: false + classification: restricted + apiName: endDate + constraints: + - kind: compare + left: start-date + operator: less_than_or_equal + right: end-date + - id: name-correction + primaryDataset: directory + route: name-corrections + mutationMode: mutable + classification: restricted + fields: + - id: record + type: reference + required: true + classification: restricted + target: farm + - id: name + type: string + required: true + classification: restricted + maxLength: 160 + minLength: 1 + - id: reason + type: string + required: true + classification: restricted + maxLength: 500 + minLength: 1 + - id: supporting-reference + type: string + required: true + classification: restricted + apiName: supportingReference + maxLength: 500 + minLength: 1 + changeRequest: + effects: + - target: + fromField: record + operation: patch + set: + name: + fromField: name + review: + authority: casework + policyId: name-correction + onApproved: + mode: manual + retention: + mode: operator_erase + hooks: + - id: casework-lifecycle-v1-name-correction + phase: after + trigger: request_lifecycle + projection: ["record"] + handler: {kind: url, destinationId: casework} +accessProfiles: + - id: reader + default: false + principalClaim: registry_principal + requiredScopes: + - starter:reader + requiredPurposes: + - starter-learning + permissions: + - entity: farm + operations: + - get + - list + readableFields: + - local-identifier + - name + rowBoundaries: [] + - id: editor + default: true + principalClaim: registry_principal + requiredScopes: + - starter:editor + requiredPurposes: + - starter-learning + permissions: + - entity: farm + operations: + - get + - list + - revisions + - create + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + writableFields: + - local-identifier + - name + - entity: person + operations: + - get + - list + - revisions + - create + - patch + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + writableFields: + - local-identifier + - name + - entity: organization + operations: + - get + - list + - revisions + - create + - patch + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + writableFields: + - local-identifier + - name + - entity: informal-group + operations: + - get + - list + - revisions + - create + - patch + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + writableFields: + - local-identifier + - name + - entity: person-agricultural-holder-role + operations: + - get + - list + - revisions + - create + - patch + readableFields: + - holder-farm + - holder-person + - start-date + - end-date + rowBoundaries: [] + revisionAccess: true + writableFields: + - holder-farm + - holder-person + - start-date + - end-date + - entity: organization-agricultural-holder-role + operations: + - get + - list + - revisions + - create + - patch + readableFields: + - holder-farm + - holder-organization + - start-date + - end-date + rowBoundaries: [] + revisionAccess: true + writableFields: + - holder-farm + - holder-organization + - start-date + - end-date + - entity: group-agricultural-holder-role + operations: + - get + - list + - revisions + - create + - patch + readableFields: + - holder-farm + - holder-group + - start-date + - end-date + rowBoundaries: [] + revisionAccess: true + writableFields: + - holder-farm + - holder-group + - start-date + - end-date + - entity: name-correction + operations: + - create + - get + - patch + - submit_request + - revise_request + - cancel_request + readableFields: + - record + - name + - reason + - supporting-reference + writableFields: + - record + - name + - reason + - supporting-reference + requestVisibility: owner + rowBoundaries: [] + readableRequestFields: + - reason + - review_state + actorKind: human + requesterClients: + - editor + - id: reviewer + default: false + principalClaim: registry_principal + requiredScopes: + - starter:reviewer + requiredPurposes: + - starter-learning + permissions: + - entity: farm + operations: + - get + - list + - revisions + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + - entity: person + operations: + - get + - list + - revisions + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + - entity: organization + operations: + - get + - list + - revisions + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + - entity: informal-group + operations: + - get + - list + - revisions + readableFields: + - local-identifier + - name + rowBoundaries: [] + revisionAccess: true + - entity: person-agricultural-holder-role + operations: + - get + - list + - revisions + readableFields: + - holder-farm + - holder-person + - start-date + - end-date + rowBoundaries: [] + revisionAccess: true + - entity: organization-agricultural-holder-role + operations: + - get + - list + - revisions + readableFields: + - holder-farm + - holder-organization + - start-date + - end-date + rowBoundaries: [] + revisionAccess: true + - entity: group-agricultural-holder-role + operations: + - get + - list + - revisions + readableFields: + - holder-farm + - holder-group + - start-date + - end-date + rowBoundaries: [] + revisionAccess: true + - entity: name-correction + operations: + - get + - apply_request + readableFields: + - record + - name + - reason + - supporting-reference + rowBoundaries: [] + applyTargets: + - entity: farm + rowBoundaries: [] + readableRequestFields: + - reason + - review_state + actorKind: human + requesterClients: + - reviewer + - id: openfn-service + default: false + principalClaim: registry_principal + requiredScopes: + - pilot:submit + requiredPurposes: + - agriculture-registration + permissions: + - entity: farm + operations: + - get + - create + - lookup + readableFields: + - local-identifier + - name + rowBoundaries: [] + writableFields: + - local-identifier + - name + lookups: + - selector: by-local-identifier + valueOrigin: request + - entity: name-correction + operations: + - create + - get + - patch + - submit_request + - revise_request + - cancel_request + readableFields: + - record + - name + - reason + - supporting-reference + writableFields: + - record + - name + - reason + - supporting-reference + requestVisibility: owner + rowBoundaries: [] + readableRequestFields: + - reason + - review_state + actorKind: human + requesterClients: + - openfn-service + - id: evidence-source + default: false + principalClaim: registry_principal + requiredScopes: + - pilot:evidence-source + requiredPurposes: + - evidence-source-read + permissions: + - entity: farm + operations: + - lookup + readableFields: + - local-identifier + rowBoundaries: [] + lookups: + - selector: by-local-identifier + valueOrigin: request + - id: casework-reader + default: false + principalClaim: registry_principal + requiredScopes: [casework:source-reader] + requiredPurposes: [casework-sync] + permissions: + - entity: "name-correction" + operations: [get, list] + readableFields: ["record"] + readableRequestFields: [review_state] + rowBoundaries: [] diff --git a/pilot/agriculture/registry/starter-template.json b/pilot/agriculture/registry/starter-template.json index 05d4756..3354815 100644 --- a/pilot/agriculture/registry/starter-template.json +++ b/pilot/agriculture/registry/starter-template.json @@ -8,7 +8,7 @@ "firstRecordEntity": "farm", "publicSchema": { "repository": "https://github.com/PublicSchema/publicschema.org", - "commit": "1ea9ce333918693b29aec31068fac412e02cb8dc", + "commit": "e5cfbbc0161e4d2c802d1acd752ec193253f7820", "status": "draft", "license": "CC-BY-4.0" }, diff --git a/pilot/agriculture/registry/tests/journeys.yaml b/pilot/agriculture/registry/tests/journeys.yaml index 3cbaab5..a49710f 100644 --- a/pilot/agriculture/registry/tests/journeys.yaml +++ b/pilot/agriculture/registry/tests/journeys.yaml @@ -1,1192 +1,319 @@ -{ - "apiVersion": "registry.registrystack.org/breg-journeys/v1", - "journeys": [ - { - "id": "starter-model-policy", - "steps": [ - { - "id": "seed-river", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-RIVER", - "name": "River Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "river" - }, - { - "id": "seed-landless", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-LANDLESS", - "name": "Landless Cooperative Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "landless" - }, - { - "id": "seed-collective", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-COLLECTIVE", - "name": "Hill Collective Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "collective" - }, - { - "id": "seed-ana", - "entity": "person", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-ANA", - "name": "Ana Example" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "ana" - }, - { - "id": "seed-bo", - "entity": "person", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-BO", - "name": "Bo Example" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "bo" - }, - { - "id": "seed-cooperative", - "entity": "organization", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-COOPERATIVE", - "name": "Example Agricultural Cooperative" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "cooperative" - }, - { - "id": "seed-group", - "entity": "informal-group", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-GROUP", - "name": "Hill Growers Group" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group" - }, - { - "id": "seed-ana-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "ana-role" - }, - { - "id": "seed-bo-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "bo" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "bo-role" - }, - { - "id": "seed-cooperative-role", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "landless" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "cooperative-role" - }, - { - "id": "seed-group-role", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "collective" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group-role" - }, - { - "id": "first-record", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "JOURNEY-FIRST-001", - "name": "Frist Record" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "first-record" - }, - { - "id": "draft-correction", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "record": { - "recordRef": "first-record" - }, - "name": "First Record", - "reason": "Synthetic spelling correction", - "supportingReference": "SYNTHETIC-SUPPORT-001" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "draft" - }, - { - "id": "before-submit", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "before-submit" - }, - { - "id": "submit-correction", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "submit_request", - "etagRef": "before-submit", - "recordRef": "before-submit" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "review", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "review" - }, - { - "id": "approve-correction", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "approve_request", - "etagRef": "review", - "proposalVersionRef": "review", - "effectDigestRef": "review", - "stage": "review", - "recordRef": "review" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "approval-does-not-apply", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [ - "starter:reader" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "name": "Frist Record", - "localIdentifier": "JOURNEY-FIRST-001" - } - }, - "capture": "approval-does-not-apply" - }, - { - "id": "before-apply", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "before-apply" - }, - { - "id": "apply-correction", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "apply_request", - "etagRef": "before-apply", - "proposalVersionRef": "before-apply", - "effectDigestRef": "before-apply", - "recordRef": "before-apply" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "corrected-record", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [ - "starter:reader" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "name": "First Record", - "localIdentifier": "JOURNEY-FIRST-001" - } - }, - "capture": "corrected-record" - }, - { - "id": "unchanged-ana-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "ana-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-ana-role" - }, - { - "id": "unchanged-bo-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "bo-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-bo-role" - }, - { - "id": "unchanged-cooperative-role", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "cooperative-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-cooperative-role" - }, - { - "id": "unchanged-group-role", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "group-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-group-role" - }, - { - "id": "rejected-draft", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "record": { - "recordRef": "first-record" - }, - "name": "Rejected Record", - "reason": "Synthetic rejection exercise", - "supportingReference": "SYNTHETIC-SUPPORT-002" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "reject-draft" - }, - { - "id": "reject-before-submit", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "reject-draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "reject-before-submit" - }, - { - "id": "submit-rejected", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "submit_request", - "etagRef": "reject-before-submit", - "recordRef": "reject-before-submit" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "reject-before-decision", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "reject-draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "reject-before-decision" - }, - { - "id": "reject-correction", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "reject_request", - "etagRef": "reject-before-decision", - "proposalVersionRef": "reject-before-decision", - "effectDigestRef": "reject-before-decision", - "stage": "review", - "recordRef": "reject-before-decision" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "rejection-preserves-record", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [ - "starter:reader" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "name": "First Record", - "localIdentifier": "JOURNEY-FIRST-001" - } - }, - "capture": "rejection-preserves-record" - }, - { - "id": "person-unknown-dates", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - } - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "person-unknown" - }, - { - "id": "person-equal-dates", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-01-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "person-equal" - }, - { - "id": "person-reversed-dates-refused", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-02-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "refusal", - "status": 409, - "problemCode": "mutation.conflict" - } - }, - { - "id": "person-malformed-date-refused", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-02-30" - } - }, - "expect": { - "outcome": "refusal", - "status": 400, - "problemCode": "request.invalid" - } - }, - { - "id": "organization-unknown-dates", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - } - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "organization-unknown" - }, - { - "id": "organization-equal-dates", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-01-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "organization-equal" - }, - { - "id": "organization-reversed-dates-refused", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-02-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "refusal", - "status": 409, - "problemCode": "mutation.conflict" - } - }, - { - "id": "organization-malformed-date-refused", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-02-30" - } - }, - "expect": { - "outcome": "refusal", - "status": 400, - "problemCode": "request.invalid" - } - }, - { - "id": "group-unknown-dates", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - } - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group-unknown" - }, - { - "id": "group-equal-dates", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-01-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group-equal" - }, - { - "id": "group-reversed-dates-refused", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-02-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "refusal", - "status": 409, - "problemCode": "mutation.conflict" - } - }, - { - "id": "group-malformed-date-refused", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-02-30" - } - }, - "expect": { - "outcome": "refusal", - "status": 400, - "problemCode": "request.invalid" - } - } - ] - }, - { - "id": "integration-permission-ceiling", - "steps": [ - { - "id": "service-create", - "entity": "farm", - "accessProfile": "openfn-service", - "claims": { - "principal": "synthetic-openfn-service", - "scopes": [ - "pilot:submit" - ], - "purpose": "agriculture-registration" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-SERVICE-001", - "name": "Synthetic Service Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "service-farm" - }, - { - "id": "service-lookup", - "entity": "farm", - "accessProfile": "openfn-service", - "claims": { - "principal": "synthetic-openfn-service", - "scopes": [ - "pilot:submit" - ], - "purpose": "agriculture-registration" - }, - "request": { - "operation": "lookup", - "selector": "by-local-identifier", - "values": { - "localIdentifier": "SYNTHETIC-SERVICE-001" - } - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "source-lookup", - "entity": "farm", - "accessProfile": "evidence-source", - "claims": { - "principal": "synthetic-evidence-source", - "scopes": [ - "pilot:evidence-source" - ], - "purpose": "evidence-source-read" - }, - "request": { - "operation": "lookup", - "selector": "by-local-identifier", - "values": { - "localIdentifier": "SYNTHETIC-SERVICE-001" - } - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "localIdentifier": "SYNTHETIC-SERVICE-001" - } - } - } - ] - } - ] -} +apiVersion: registry.registrystack.org/breg-journeys/v1 +journeys: + - id: starter-model-policy + steps: + - id: seed-river + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-RIVER + name: River Holding + expect: + outcome: success + status: 201 + capture: river + - id: seed-landless + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-LANDLESS + name: Landless Cooperative Holding + expect: + outcome: success + status: 201 + capture: landless + - id: seed-collective + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-COLLECTIVE + name: Hill Collective Holding + expect: + outcome: success + status: 201 + capture: collective + - id: seed-ana + entity: person + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-ANA + name: Ana Example + expect: + outcome: success + status: 201 + capture: ana + - id: seed-bo + entity: person + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-BO + name: Bo Example + expect: + outcome: success + status: 201 + capture: bo + - id: seed-cooperative + entity: organization + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-COOPERATIVE + name: Example Agricultural Cooperative + expect: + outcome: success + status: 201 + capture: cooperative + - id: seed-group + entity: informal-group + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-GROUP + name: Hill Growers Group + expect: + outcome: success + status: 201 + capture: group + - id: seed-ana-role + entity: person-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: river + holderPerson: + recordRef: ana + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: ana-role + - id: seed-bo-role + entity: person-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: river + holderPerson: + recordRef: bo + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: bo-role + - id: seed-cooperative-role + entity: organization-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: landless + holderOrganization: + recordRef: cooperative + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: cooperative-role + - id: seed-group-role + entity: group-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: collective + holderGroup: + recordRef: group + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: group-role + - id: first-record + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: JOURNEY-FIRST-001 + name: Frist Record + expect: + outcome: success + status: 201 + capture: first-record + - id: draft-correction + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + record: + recordRef: first-record + name: First Record + reason: Synthetic spelling correction + supportingReference: SYNTHETIC-SUPPORT-001 + expect: + outcome: success + status: 201 + capture: draft + - id: before-submit + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: get + recordRef: draft + expect: + outcome: success + status: 200 + capture: before-submit + - id: submit-correction + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: submit_request + etagRef: before-submit + recordRef: before-submit + expect: + outcome: success + status: 200 + - id: review + entity: name-correction + accessProfile: reviewer + claims: + actorKind: human + principal: synthetic-reviewer + scopes: + - starter:reviewer + purpose: starter-learning + request: + operation: get + recordRef: draft + expect: + outcome: success + status: 200 + capture: review + - id: approval-does-not-apply + entity: farm + accessProfile: reader + claims: + principal: synthetic-reader + scopes: + - starter:reader + purpose: starter-learning + request: + operation: get + recordRef: first-record + expect: + outcome: success + status: 200 + fields: + name: Frist Record + localIdentifier: JOURNEY-FIRST-001 + capture: approval-does-not-apply diff --git a/pilot/agriculture/registry/tests/security-journeys.yaml b/pilot/agriculture/registry/tests/security-journeys.yaml index 37503fd..3ec2e26 100644 --- a/pilot/agriculture/registry/tests/security-journeys.yaml +++ b/pilot/agriculture/registry/tests/security-journeys.yaml @@ -1,1218 +1,365 @@ -{ - "apiVersion": "registry.registrystack.org/breg-journeys/v1", - "journeys": [ - { - "id": "starter-model-policy", - "steps": [ - { - "id": "seed-river", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-RIVER", - "name": "River Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "river" - }, - { - "id": "seed-landless", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-LANDLESS", - "name": "Landless Cooperative Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "landless" - }, - { - "id": "seed-collective", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-COLLECTIVE", - "name": "Hill Collective Holding" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "collective" - }, - { - "id": "seed-ana", - "entity": "person", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-ANA", - "name": "Ana Example" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "ana" - }, - { - "id": "seed-bo", - "entity": "person", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-BO", - "name": "Bo Example" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "bo" - }, - { - "id": "seed-cooperative", - "entity": "organization", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-COOPERATIVE", - "name": "Example Agricultural Cooperative" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "cooperative" - }, - { - "id": "seed-group", - "entity": "informal-group", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "SYNTHETIC-GROUP", - "name": "Hill Growers Group" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group" - }, - { - "id": "seed-ana-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "ana-role" - }, - { - "id": "seed-bo-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "bo" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "bo-role" - }, - { - "id": "seed-cooperative-role", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "landless" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "cooperative-role" - }, - { - "id": "seed-group-role", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "collective" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group-role" - }, - { - "id": "first-record", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "JOURNEY-FIRST-001", - "name": "Frist Record" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "first-record" - }, - { - "id": "unscoped-caller-cannot-create", - "entity": "farm", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-reader", - "scopes": [], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "localIdentifier": "DENIED-001", - "name": "Denied" - } - }, - "expect": { - "outcome": "refusal", - "status": 404, - "problemCode": "resource.not_found" - } - }, - { - "id": "missing-scope-cannot-read", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "refusal", - "status": 404, - "problemCode": "resource.not_found" - } - }, - { - "id": "draft-correction", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "record": { - "recordRef": "first-record" - }, - "name": "First Record", - "reason": "Synthetic spelling correction", - "supportingReference": "SYNTHETIC-SUPPORT-001" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "draft" - }, - { - "id": "unscoped-caller-cannot-read-review-material", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-reader", - "scopes": [], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "refusal", - "status": 404, - "problemCode": "resource.not_found" - } - }, - { - "id": "before-submit", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "before-submit" - }, - { - "id": "submit-correction", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "submit_request", - "etagRef": "before-submit", - "recordRef": "before-submit" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "review", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "review" - }, - { - "id": "foreign-reviewer-precondition-refused", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "approve_request", - "etagRef": "review", - "proposalVersionRef": "review", - "effectDigestRef": "review", - "stage": "review", - "recordRef": "review" - }, - "expect": { - "outcome": "refusal", - "status": 412, - "problemCode": "precondition.failed" - } - }, - { - "id": "approve-correction", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "approve_request", - "etagRef": "review", - "proposalVersionRef": "review", - "effectDigestRef": "review", - "stage": "review", - "recordRef": "review" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "approval-does-not-apply", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [ - "starter:reader" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "name": "Frist Record", - "localIdentifier": "JOURNEY-FIRST-001" - } - }, - "capture": "approval-does-not-apply" - }, - { - "id": "before-apply", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "before-apply" - }, - { - "id": "apply-correction", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "apply_request", - "etagRef": "before-apply", - "proposalVersionRef": "before-apply", - "effectDigestRef": "before-apply", - "recordRef": "before-apply" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "corrected-record", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [ - "starter:reader" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "name": "First Record", - "localIdentifier": "JOURNEY-FIRST-001" - } - }, - "capture": "corrected-record" - }, - { - "id": "unchanged-ana-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "ana-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-ana-role" - }, - { - "id": "unchanged-bo-role", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "bo-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-bo-role" - }, - { - "id": "unchanged-cooperative-role", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "cooperative-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-cooperative-role" - }, - { - "id": "unchanged-group-role", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "group-role" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "startDate": "2026-01-01" - } - }, - "capture": "unchanged-group-role" - }, - { - "id": "rejected-draft", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "record": { - "recordRef": "first-record" - }, - "name": "Rejected Record", - "reason": "Synthetic rejection exercise", - "supportingReference": "SYNTHETIC-SUPPORT-002" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "reject-draft" - }, - { - "id": "reject-before-submit", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "reject-draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "reject-before-submit" - }, - { - "id": "submit-rejected", - "entity": "name-correction", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "submit_request", - "etagRef": "reject-before-submit", - "recordRef": "reject-before-submit" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "reject-before-decision", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "reject-draft" - }, - "expect": { - "outcome": "success", - "status": 200 - }, - "capture": "reject-before-decision" - }, - { - "id": "reject-correction", - "entity": "name-correction", - "accessProfile": "reviewer", - "claims": { - "principal": "synthetic-reviewer", - "scopes": [ - "starter:reviewer" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "reject_request", - "etagRef": "reject-before-decision", - "proposalVersionRef": "reject-before-decision", - "effectDigestRef": "reject-before-decision", - "stage": "review", - "recordRef": "reject-before-decision" - }, - "expect": { - "outcome": "success", - "status": 200 - } - }, - { - "id": "rejection-preserves-record", - "entity": "farm", - "accessProfile": "reader", - "claims": { - "principal": "synthetic-reader", - "scopes": [ - "starter:reader" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "first-record" - }, - "expect": { - "outcome": "success", - "status": 200, - "fields": { - "name": "First Record", - "localIdentifier": "JOURNEY-FIRST-001" - } - }, - "capture": "rejection-preserves-record" - }, - { - "id": "person-unknown-dates", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - } - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "person-unknown" - }, - { - "id": "person-equal-dates", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-01-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "person-equal" - }, - { - "id": "person-reversed-dates-refused", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-02-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "refusal", - "status": 409, - "problemCode": "mutation.conflict" - } - }, - { - "id": "person-malformed-date-refused", - "entity": "person-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorPerson": { - "recordRef": "ana" - }, - "startDate": "2026-02-30" - } - }, - "expect": { - "outcome": "refusal", - "status": 400, - "problemCode": "request.invalid" - } - }, - { - "id": "organization-unknown-dates", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - } - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "organization-unknown" - }, - { - "id": "organization-equal-dates", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-01-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "organization-equal" - }, - { - "id": "organization-reversed-dates-refused", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-02-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "refusal", - "status": 409, - "problemCode": "mutation.conflict" - } - }, - { - "id": "organization-malformed-date-refused", - "entity": "organization-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorOrganization": { - "recordRef": "cooperative" - }, - "startDate": "2026-02-30" - } - }, - "expect": { - "outcome": "refusal", - "status": 400, - "problemCode": "request.invalid" - } - }, - { - "id": "group-unknown-dates", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - } - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group-unknown" - }, - { - "id": "group-equal-dates", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-01-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "success", - "status": 201 - }, - "capture": "group-equal" - }, - { - "id": "group-reversed-dates-refused", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-02-01", - "endDate": "2026-01-01" - } - }, - "expect": { - "outcome": "refusal", - "status": 409, - "problemCode": "mutation.conflict" - } - }, - { - "id": "group-malformed-date-refused", - "entity": "group-holding-operator-role", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-editor", - "scopes": [ - "starter:editor" - ], - "purpose": "starter-learning" - }, - "request": { - "operation": "create", - "data": { - "operatedHolding": { - "recordRef": "river" - }, - "holdingOperatorGroup": { - "recordRef": "group" - }, - "startDate": "2026-02-30" - } - }, - "expect": { - "outcome": "refusal", - "status": 400, - "problemCode": "request.invalid" - } - }, - { - "id": "unscoped-caller-cannot-read-person", - "entity": "person", - "accessProfile": "editor", - "claims": { - "principal": "synthetic-reader", - "scopes": [], - "purpose": "starter-learning" - }, - "request": { - "operation": "get", - "recordRef": "ana" - }, - "expect": { - "outcome": "refusal", - "status": 404, - "problemCode": "resource.not_found" - } - } - ] - } - ] -} +apiVersion: registry.registrystack.org/breg-journeys/v1 +journeys: + - id: starter-model-policy + steps: + - id: seed-river + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-RIVER + name: River Holding + expect: + outcome: success + status: 201 + capture: river + - id: seed-landless + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-LANDLESS + name: Landless Cooperative Holding + expect: + outcome: success + status: 201 + capture: landless + - id: seed-collective + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-COLLECTIVE + name: Hill Collective Holding + expect: + outcome: success + status: 201 + capture: collective + - id: seed-ana + entity: person + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-ANA + name: Ana Example + expect: + outcome: success + status: 201 + capture: ana + - id: seed-bo + entity: person + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-BO + name: Bo Example + expect: + outcome: success + status: 201 + capture: bo + - id: seed-cooperative + entity: organization + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-COOPERATIVE + name: Example Agricultural Cooperative + expect: + outcome: success + status: 201 + capture: cooperative + - id: seed-group + entity: informal-group + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: SYNTHETIC-GROUP + name: Hill Growers Group + expect: + outcome: success + status: 201 + capture: group + - id: seed-ana-role + entity: person-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: river + holderPerson: + recordRef: ana + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: ana-role + - id: seed-bo-role + entity: person-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: river + holderPerson: + recordRef: bo + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: bo-role + - id: seed-cooperative-role + entity: organization-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: landless + holderOrganization: + recordRef: cooperative + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: cooperative-role + - id: seed-group-role + entity: group-agricultural-holder-role + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + holderFarm: + recordRef: collective + holderGroup: + recordRef: group + startDate: '2026-01-01' + expect: + outcome: success + status: 201 + capture: group-role + - id: first-record + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + localIdentifier: JOURNEY-FIRST-001 + name: Frist Record + expect: + outcome: success + status: 201 + capture: first-record + - id: unscoped-caller-cannot-create + entity: farm + accessProfile: editor + claims: + actorKind: human + principal: synthetic-reader + scopes: [] + purpose: starter-learning + request: + operation: create + data: + localIdentifier: DENIED-001 + name: Denied + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found + - id: missing-scope-cannot-read + entity: farm + accessProfile: reader + claims: + principal: synthetic-reader + scopes: [] + purpose: starter-learning + request: + operation: get + recordRef: first-record + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found + - id: draft-correction + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: create + data: + record: + recordRef: first-record + name: First Record + reason: Synthetic spelling correction + supportingReference: SYNTHETIC-SUPPORT-001 + expect: + outcome: success + status: 201 + capture: draft + - id: unscoped-caller-cannot-read-review-material + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-reader + scopes: [] + purpose: starter-learning + request: + operation: get + recordRef: draft + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found + - id: before-submit + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: get + recordRef: draft + expect: + outcome: success + status: 200 + capture: before-submit + - id: submit-correction + entity: name-correction + accessProfile: editor + claims: + actorKind: human + principal: synthetic-editor + scopes: + - starter:editor + purpose: starter-learning + request: + operation: submit_request + etagRef: before-submit + recordRef: before-submit + expect: + outcome: success + status: 200 + - id: review + entity: name-correction + accessProfile: reviewer + claims: + actorKind: human + principal: synthetic-reviewer + scopes: + - starter:reviewer + purpose: starter-learning + request: + operation: get + recordRef: draft + expect: + outcome: success + status: 200 + capture: review + - id: approval-does-not-apply + entity: farm + accessProfile: reader + claims: + principal: synthetic-reader + scopes: + - starter:reader + purpose: starter-learning + request: + operation: get + recordRef: first-record + expect: + outcome: success + status: 200 + fields: + name: Frist Record + localIdentifier: JOURNEY-FIRST-001 + capture: approval-does-not-apply diff --git a/pilot/agriculture/review.py b/pilot/agriculture/review.py index 951d747..d935bba 100644 --- a/pilot/agriculture/review.py +++ b/pilot/agriculture/review.py @@ -1,70 +1,207 @@ #!/usr/bin/env python3 -"""Inspect a synthetic correction, then explicitly approve or apply that snapshot.""" +"""Inspect a synthetic correction, approve its Casework review, then explicitly apply.""" import argparse import json import os from pathlib import Path -import subprocess +import time import urllib.request import urllib.error +import urllib.parse import uuid -ROOT=Path('/config/breg') -ORIGIN='http://127.0.0.1:8090' +ROOT = Path('/config/breg') +RUNTIME = Path('/workspace/pilot/agriculture/.runtime-0.38') +BREG = 'http://127.0.0.1:8090' +CASEWORK = 'http://127.0.0.1:8092' +CASEWORK_HEADERS = {'Registry-Casework-Profile': 'reviewer', 'Registry-Source-Profile': 'reviewer'} def token(): - result=subprocess.run(['mint','token','--url','http://127.0.0.1:8091/token','--client-id','reviewer','--key',str(ROOT/'clients/reviewer/signing-p256-private-jwk')],capture_output=True,text=True) - if result.returncode: - raise RuntimeError('Reviewer authentication failed') - return result.stdout.strip() + from issuer import token as issue_token + return issue_token(RUNTIME, 'reviewer') -def request(path,bearer,method='GET',body=None,headers=None): - req=urllib.request.Request(ORIGIN+path,method=method,data=None if body is None else json.dumps(body).encode(),headers={'Authorization':'Bearer '+bearer,'Accept':'application/json',**({'Content-Type':'application/json'} if body is not None else {}),**(headers or {})}) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def request(origin, path, bearer, method='GET', body=None, headers=None): + if origin not in [BREG, CASEWORK] or not path.startswith('/v1/') or path.startswith('//'): + raise RuntimeError('Unexpected service location; credential was not forwarded') + req = urllib.request.Request(origin + path, method=method, + data=None if body is None else json.dumps(body).encode(), + headers={'Authorization': 'Bearer ' + bearer, 'Accept': 'application/json', + **({'Content-Type': 'application/json'} if body is not None else {}), **(headers or {})}) try: - with urllib.request.urlopen(req,timeout=10) as response: - return json.load(response) + with urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()).open(req, timeout=10) as response: + content = response.read() + return json.loads(content) if content else None except urllib.error.HTTPError as error: - problem=json.load(error) - raise RuntimeError(f"Registry refused ({error.code}): {problem.get('code','unknown')}") from None + raise RuntimeError(f'Service refused the reviewed action (HTTP {error.code})') from None + except (OSError, urllib.error.URLError, ValueError): + raise RuntimeError('Reviewed service exchange did not complete') from None + + +def record(request_id, bearer): + return request(BREG, f'/v1/records/name-corrections/{request_id}?accessProfile=reviewer', bearer) + + +def casework(path, bearer, method='GET', body=None, headers=None): + return request(CASEWORK, path, bearer, method, body, {**CASEWORK_HEADERS, **(headers or {})}) + + +def save(path, value): + path.parent.mkdir(exist_ok=True, mode=0o700) + temporary = path.with_suffix('.tmp') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as handle: + json.dump(value, handle, indent=2) + handle.write('\n') + temporary.chmod(0o600) + temporary.replace(path) + path.chmod(0o600) + + +def frozen(value): + details = value['data']['request'] + return details['proposalVersion'], details['effectDigest'], details.get('review', {}).get('submission') + + +def validate_context(request_id, value, context): + details = value['data']['request'] + submission = details['review']['submission'] + subject = context.get('subject', {}) + if (submission.get('state') != 'accepted' or submission.get('authority') != 'casework' + or context.get('requestId') != submission.get('requestId') + or subject != {'source': 'agricultural-holdings', 'type': 'name-correction', 'id': request_id, + 'version': str(details['proposalVersion']), 'digest': details['effectDigest']} + or context.get('policy') != submission.get('policy') + or context.get('context', {}).get('strategy') != 'source' + or context['context'].get('bindingStatus') != 'current'): + raise RuntimeError('Casework context does not match the inspected current proposal') + + +def task_for(review_id, bearer): + cursor = None + seen = set() + for _ in range(10): + query = {'queue': 'corrections', 'limit': 100} + if cursor: + query['cursor'] = str(uuid.UUID(cursor)) + page = casework('/v1/review-tasks?' + urllib.parse.urlencode(query), bearer) + matches = [item for item in page['items'] if item['requestId'] == review_id] + if len(matches) > 1: + raise RuntimeError('Review task selection is ambiguous') + if matches: + return matches[0] + cursor = page.get('nextCursor') + if not cursor: + return None + if cursor in seen: + raise RuntimeError('Casework repeated its task cursor') + seen.add(cursor) + raise RuntimeError('Review task selection exceeded its bounded page budget') + + +def inspect(request_id, bearer, timeout=90): + end = time.monotonic() + timeout + while True: + value = record(request_id, bearer) + details = value['data']['request'] + result = details.get('review', {}).get('result', {}).get('state') + if details['bregState'] == 'applied' or result in ['approved', 'rejected', 'cancelled']: + return {'breg': value} + submission = details.get('review', {}).get('submission', {}) + if submission.get('state') == 'accepted': + task = task_for(submission['requestId'], bearer) + if task and task['state'] != 'decided': + task_id = str(uuid.UUID(task['taskId'])) + context = casework(f'/v1/review-tasks/{task_id}/context', bearer) + validate_context(request_id, value, context) + return {'breg': value, 'casework': {'task': task, 'context': context}} + if time.monotonic() >= end: + raise RuntimeError('The submitted correction has no available Casework review task') + time.sleep(1) + + +def approve(request_id, snapshot, bearer): + value = record(request_id, bearer) + if frozen(value) != frozen(snapshot['breg']): + raise RuntimeError('The proposal changed after inspection; inspect current state first') + if value['data']['request'].get('review', {}).get('result', {}).get('state') == 'approved': + return + reviewed = snapshot.get('casework') + if not reviewed: + raise RuntimeError('Inspect this Casework review before approving') + task = reviewed['task'] + task_id = str(uuid.UUID(task['taskId'])) + path = f'/v1/review-tasks/{task_id}' + context = casework(path + '/context', bearer) + validate_context(request_id, value, context) + if context != reviewed['context']: + raise RuntimeError('The review context changed after inspection; inspect current state first') + if task['state'] == 'open': + claimed = casework(path + '/claim', bearer, 'POST', headers={ + 'If-Match': f'"{task["revision"]}"', 'Idempotency-Key': f'reviewer:{task_id}:claim:{task["revision"]}'}) + task = claimed + snapshot['casework']['task'] = task + save(ROOT / 'review' / f'{request_id}.json', snapshot) + casework(path + '/decisions', bearer, 'POST', {'decision': {'type': 'approve'}}, { + 'If-Match': f'"{task["revision"]}"', 'Idempotency-Key': f'reviewer:{task_id}:approve:{task["revision"]}'}) + + +def apply(request_id, snapshot, bearer): + value = snapshot['breg'] + details = value['data']['request'] + if details['bregState'] == 'applied': + return + if details.get('review', {}).get('result', {}).get('state') != 'approved': + raise RuntimeError('BReg has not reconciled the approved review; inspect current state first') + actions = [action for action in details['actions'] if action['operation'] == 'apply_request'] + if len(actions) != 1: + raise RuntimeError('The inspected snapshot has no unique permitted apply action') + action = actions[0] + parsed = urllib.parse.urlsplit(action['href']) + expected = f'/v1/records/name-corrections/{request_id}/actions/apply' + if (parsed.scheme or parsed.netloc or parsed.fragment or parsed.path != expected + or urllib.parse.parse_qs(parsed.query) != {'accessProfile': ['reviewer']} + or action.get('method', 'POST') != 'POST'): + raise RuntimeError('Unexpected action location; credential was not forwarded') + body = {key: action[key] for key in ['proposalVersion', 'effectDigest']} + request(BREG, action['href'], bearer, 'POST', body, { + 'If-Match': action['ifMatch'], + 'Idempotency-Key': f'reviewer:{request_id}:apply:{body["proposalVersion"]}:{body["effectDigest"]}'}) def main(): - parser=argparse.ArgumentParser(description=__doc__) - parser.add_argument('action',choices=['inspect','approve','apply']) - parser.add_argument('request_id',type=uuid.UUID) - args=parser.parse_args() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['inspect', 'approve', 'apply']) + parser.add_argument('request_id', type=uuid.UUID) + args = parser.parse_args() os.umask(0o077) - request_id=str(args.request_id) - snapshot=ROOT/'review'/f'{request_id}.json' - bearer=token() - if args.action=='inspect': - value=request(f'/v1/records/name-corrections/{request_id}?accessProfile=reviewer',bearer) - snapshot.parent.mkdir(exist_ok=True,mode=0o700) - snapshot.write_text(json.dumps(value,indent=2)+'\n'); snapshot.chmod(0o600) - print(json.dumps(value,indent=2)) - print('Review the proposed values above. Approval and application are separate commands.') + request_id = str(args.request_id) + path = ROOT / 'review' / f'{request_id}.json' + bearer = token() + if args.action == 'inspect': + snapshot = inspect(request_id, bearer) + save(path, snapshot) + print(json.dumps(snapshot, indent=2)) + print('Review the proposal above. Casework approval and BReg application are separate commands.') return - if not snapshot.exists(): + if not path.exists(): raise RuntimeError('Inspect this correction first; no reviewed snapshot exists') - value=json.loads(snapshot.read_text()) - actions=value['data']['request']['actions'] - operation={'approve':'approve_request','apply':'apply_request'}[args.action] - selected=[action for action in actions if action['operation']==operation] - if len(selected)!=1: - raise RuntimeError('The inspected snapshot has no unique permitted action. Inspect current state first.') - action=selected[0] - suffix='/actions/stages/review/approve' if args.action=='approve' else '/actions/apply' - expected=f'/v1/records/name-corrections/{request_id}'+suffix - if action['href'].split('?')[0]!=expected or action['method']!='POST': - raise RuntimeError('Unexpected action location; refusing to forward a reviewer credential') - body={key:action[key] for key in ['proposalVersion','effectDigest']} - key=f"reviewer:{request_id}:{operation}:{body['proposalVersion']}:{body['effectDigest']}" - result=request(expected+'?accessProfile=reviewer',bearer,'POST',body,{'If-Match':action['ifMatch'],'Idempotency-Key':key}) - print(json.dumps(result,indent=2)) - print('Approval recorded; the farm is unchanged. Inspect again before applying.' if args.action=='approve' else 'The reviewed correction was applied.') - - -if __name__=='__main__': + snapshot = json.loads(path.read_text()) + if snapshot['breg']['data']['recordIdentifier'] != request_id: + raise RuntimeError('The saved snapshot belongs to another correction') + if args.action == 'approve': + approve(request_id, snapshot, bearer) + print('Casework approval recorded; the farm is unchanged. Wait for BReg reconciliation and inspect again before applying.') + else: + apply(request_id, snapshot, bearer) + print('The reviewed correction was applied.') + + +if __name__ == '__main__': main() diff --git a/pilot/agriculture/smoke.py b/pilot/agriculture/smoke.py index 6b73346..93bf419 100644 --- a/pilot/agriculture/smoke.py +++ b/pilot/agriculture/smoke.py @@ -12,24 +12,33 @@ WORK=ROOT/'smoke' +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +OPENER=urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + + def command(*args,cwd=None): result=subprocess.run(list(map(str,args)),capture_output=True,text=True,cwd=cwd) if result.returncode: - raise RuntimeError(f'{args[0]} failed: {result.stderr[-2000:]}') + raise RuntimeError(f'{Path(str(args[0])).name} failed; subprocess output was withheld') return result.stdout def token(client): - return command('mint','token','--url','http://127.0.0.1:8091/token','--client-id',client,'--key',ROOT/f'clients/{client}/signing-p256-private-jwk').strip() + from issuer import token as issue_token + return issue_token(Path('/workspace/pilot/agriculture/.runtime-0.38'), client) def request(method,path,bearer,body=None,headers=None): req=urllib.request.Request('http://127.0.0.1:8090'+path,method=method,data=None if body is None else json.dumps(body).encode(),headers={'Authorization':'Bearer '+bearer,'Accept':'application/json',**({'Content-Type':'application/json'} if body is not None else {}),**(headers or {})}) try: - with urllib.request.urlopen(req,timeout=15) as response: - return response.status,dict(response.headers),json.load(response) + with OPENER.open(req,timeout=15) as response: + return response.status,{key.lower():value for key,value in response.headers.items()},json.load(response) except urllib.error.HTTPError as error: - return error.code,dict(error.headers),json.load(error) + return error.code,{key.lower():value for key,value in error.headers.items()},json.load(error) def save(path,body): @@ -38,6 +47,7 @@ def save(path,body): def main(): os.umask(0o077) + os.environ['SSL_CERT_FILE']='/config/evidence-client/pilot-ca.pem' WORK.mkdir(exist_ok=True,mode=0o700) service=token('openfn-service'); source=token('evidence-source') identifier='SYNTHETIC-DIRECT-SMOKE-001' @@ -51,7 +61,7 @@ def main(): record=found['data']['recordIdentifier'] status,headers,_=request('GET',f'/v1/records/farms/{record}?accessProfile=openfn-service',service) assert status==200 - status,_,problem=request('PATCH',f'/v1/records/farms/{record}?accessProfile=openfn-service',service,{'name':'NEVER'}, {'Idempotency-Key':'synthetic-denied-patch-v1','If-Match':headers.get('ETag',headers.get('Etag',''))}) + status,_,problem=request('PATCH',f'/v1/records/farms/{record}?accessProfile=openfn-service',service,{'name':'NEVER'}, {'Idempotency-Key':'synthetic-denied-patch-v1','If-Match':headers['etag']}) assert status==404 and problem.get('code')=='resource.not_found',(status,problem.get('code')) status,_,problem=request('POST','/v1/records/farms?accessProfile=evidence-source',source,{'data':{'localIdentifier':'SYNTHETIC-DENIED','name':'NEVER'}}, {'Idempotency-Key':'synthetic-denied-create-v1'}) assert status==404 and problem.get('code')=='resource.not_found',(status,problem.get('code')) @@ -71,9 +81,17 @@ def main(): assert status==200,(status,submitted.get('code')) command('python3','/opt/pilot/agriculture/review.py','inspect',correction) reviewed=json.loads((ROOT/'review'/f'{correction}.json').read_text()) - approval=next(a for a in reviewed['data']['request']['actions'] if a['operation']=='approve_request') - status,_,denied=request('POST',f'/v1/records/name-corrections/{correction}/actions/stages/review/approve?accessProfile=openfn-service',service,{key:approval[key] for key in ['proposalVersion','effectDigest']},{'If-Match':approval['ifMatch'],'Idempotency-Key':'smoke-denied-approve-'+correction}) - assert status==404 and denied.get('code')=='resource.not_found',(status,denied.get('code')) + task=reviewed['casework']['task'] + req=urllib.request.Request(f'http://127.0.0.1:8092/v1/review-tasks/{task["taskId"]}/decisions', + method='POST',data=json.dumps({'decision':{'type':'approve'}}).encode(),headers={ + 'Authorization':'Bearer '+service,'Content-Type':'application/json', + 'Registry-Casework-Profile':'reviewer','Registry-Source-Profile':'openfn-service', + 'If-Match':f'"{task["revision"]}"','Idempotency-Key':'smoke-denied-approve-'+correction}) + try: + OPENER.open(req,timeout=15).close() + raise AssertionError('The intake service unexpectedly gained human review authority') + except urllib.error.HTTPError as error: + assert error.code==401,error.code command('python3','/opt/pilot/agriculture/review.py','approve',correction) status,_,after_approval=request('GET',f'/v1/records/farms/{record}?accessProfile=openfn-service',service) assert after_approval['data']['domainData']['name']==before_name @@ -81,25 +99,8 @@ def main(): command('python3','/opt/pilot/agriculture/review.py','apply',correction) status,_,after_apply=request('GET',f'/v1/records/farms/{record}?accessProfile=openfn-service',service) assert after_apply['data']['domainData']['name']==correction_name - for suffix,value in [('registered',identifier),('missing','SYNTHETIC-DIRECT-SMOKE-MISSING')]: - name=suffix+'-'+uuid.uuid4().hex[:12] - inputs=WORK/(name+'-subjects.json') - save(inputs,{'subjects':[{'role':'subject','field':'local-identifier','value':value}]}) - command('evidencectl','request','prepare','--profile','/config/evidence-client/profile.json','--requirement','holding-registered','--subjects-file',inputs,'--name',name,cwd=WORK) - retained=WORK/'.evidence/requests'/name - response=retained/'response.json' - status=command('curl','--silent','--show-error','--config',retained/'curl.config','--output',response,'--write-out','%{http_code}',cwd=WORK) - if suffix=='registered': - assert status=='200',status - command('evidencectl','verify',response,'--context',retained/'verification.json','--output',retained/'verified.json') - verified=(retained/'verified.json').read_text() - assert 'SYNTHETIC-PRIVATE-NAME-CANARY' not in verified and identifier not in verified - assert 'urn:example:concept:holding-registered:registered' in verified - else: - problem=json.loads(response.read_text()) - assert problem.get('code')=='evidence.unavailable',(status,problem.get('code')) - save(WORK/'report.json',{'serviceApprovalRefused':True,'directPatchRefused':True,'sourceCreateRefused':True,'sourceNameExcluded':True,'signedRegisteredVerified':True,'missingUnavailable':True,'approvalLeavesFarmUnchanged':True,'manualApplyChangesFarm':True}) - print('PASS: source/read-write ceilings, explicit review/application split, verified registered assertion, and missing unavailable.') + save(WORK/'report.json',{'serviceApprovalRefused':True,'directPatchRefused':True,'sourceCreateRefused':True,'sourceNameExcluded':True,'approvalLeavesFarmUnchanged':True,'manualApplyChangesFarm':True}) + print('PASS: source/read-write ceilings and explicit Casework review/BREG application split.') if __name__=='__main__': diff --git a/pilot/lightning/project.mjs b/pilot/lightning/project.mjs index 38ba93d..31d7a33 100644 --- a/pilot/lightning/project.mjs +++ b/pilot/lightning/project.mjs @@ -26,6 +26,10 @@ fn(state => { export function projectDocument(bindings, credentials = {}, enabled = false) { const literal = value => JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); const profile = literal(bindings.accessProfile); + const evidenceRequest = bindings.evidenceRequest ?? { + requirement: bindings.requirement, + selectors: {'local-identifier': {valueFrom: 'data.values.local-identifier'}}, + }; const validateSubmission = `fn(state => { const value = state.data.submissionId; if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(value)) throw new Error('A stable submissionId is required'); @@ -67,7 +71,7 @@ fn(async state => { idempotencyKey: 'correction-submit:' + state.data.submissionId, as: 'result' })(state); if (state.data.result.branch !== 'succeeded') throw new Error('Correction submission did not complete: ' + state.data.result.branch); - } else if (!['submitted', 'approved', 'applied', 'rejected'].includes(request?.bregState)) { + } else if (!['submitted', 'applied'].includes(request?.bregState)) { throw new Error('Correction is not ready for submission'); } return {data: {status: action ? 'submitted' : 'already_submitted', requestId: state.data.requestId}}; @@ -89,8 +93,7 @@ fn(state => { return state; }); requestEvidence({ - requirement: ${literal(bindings.requirement)}, - selectors: {'local-identifier': {valueFrom: 'data.values.local-identifier'}}, as: 'result' + ...${literal(evidenceRequest)}, as: 'result' }); fn(state => { const result = state.data.result; diff --git a/pilot/lightning/provision.mjs b/pilot/lightning/provision.mjs index 1124024..8caeb80 100644 --- a/pilot/lightning/provision.mjs +++ b/pilot/lightning/provision.mjs @@ -85,7 +85,7 @@ async function prepareEnvironment(files, secrets) { `PRIMARY_ENCRYPTION_KEY=${randomBytes(32).toString('base64')}`, `WORKER_RUNS_PRIVATE_KEY=${Buffer.from(keys.privateKey).toString('base64')}`, `WORKER_SECRET=${shared}`, 'PHX_SERVER=true', 'LISTEN_ADDRESS=0.0.0.0', 'MAX_DATACLIP_SIZE_MB=1', 'DISABLE_DB_SSL=true', - 'ORIGINS=http://localhost:4000,http://127.0.0.1:4000,http://lightning:4000', + 'ORIGINS=http://localhost:4010,http://127.0.0.1:4010,http://lightning:4000', 'WORKER_MAX_RUN_DURATION_SECONDS=60', 'USAGE_TRACKING_ENABLED=false', ].join('\n') + '\n', 'worker.env': worker ?? [ @@ -112,8 +112,8 @@ async function prepareEnvironment(files, secrets) { } } -export async function run(command = 'prepare', runtime = 'pilot/agriculture/.runtime', { - base = process.env.OPENFN_URL ?? 'http://127.0.0.1:4000', fetchImpl = fetch, +export async function run(command = 'prepare', runtime = 'pilot/agriculture/.runtime-0.38', { + base = process.env.OPENFN_URL ?? 'http://127.0.0.1:4010', fetchImpl = fetch, } = {}) { runtime = resolve(runtime); const secrets = join(runtime, 'openfn', 'secrets'); @@ -154,6 +154,7 @@ export async function run(command = 'prepare', runtime = 'pilot/agriculture/.run createCorrectionOperation: generated.breg.correctionCreateOperation, requirement: generated.evidence.requirement, registeredConcept: generated.evidence.registeredConcept, + evidenceRequest: generated.evidence.request, }; if (command === 'provision') { const empty = projectDocument(bindings); @@ -171,10 +172,10 @@ export async function run(command = 'prepare', runtime = 'pilot/agriculture/.run destination: {baseUrl: 'http://destination:8082', apiKey: (await readFile(join(secrets, 'destination-api-key'), 'utf8')).trim()}, }; const statePath = join(secrets, 'credentials.json'); - if (state.credentialFormat !== 2) { + if (state.credentialFormat !== 3) { // Legacy body layouts are not inspectable through the metadata API. Keep // their associations untouched and select a fresh, checkpointed namespace. - state = {credentialFormat: 2, previous: state, credentialNames: {}}; + state = {credentialFormat: 3, previous: state, credentialNames: {}}; await privateFile(statePath, JSON.stringify(state)); } state.credentialNames ??= {}; diff --git a/pilot/recovery.py b/pilot/recovery.py index 2b8c74a..3b226ef 100644 --- a/pilot/recovery.py +++ b/pilot/recovery.py @@ -58,7 +58,7 @@ def event_for_record(record_id): require(bool(SAFE_ID.fullmatch(record_id)), 'Invalid recovery record identity') return database("SELECT coalesce(json_agg(event_id), '[]'::json) FROM registry_internal.registry_outbox " "WHERE entity_id='farm' AND payload IS NOT NULL AND " - "convert_from(payload,'UTF8')::jsonb->>'recordId'='" + record_id + "';") + "convert_from(payload,'UTF8')::jsonb->'data'->>'recordId'='" + record_id + "';") def delivery(event_id): @@ -109,7 +109,7 @@ def service(self, command, *services): def ready(self): def probe(): try: - return http_json('http://127.0.0.1:4000/health_check', timeout=5, parse_json=False)[0] + return http_json('http://127.0.0.1:4010/health_check', timeout=5, parse_json=False)[0] except SmokeFailure: return None self.poll(probe, lambda status: status == 200, 'OpenFn readiness') @@ -212,7 +212,7 @@ def replay_smoke(self): require(observed['deliveryId'] in ['events.farm.farm-created-v1.webhook', 'events.farm.farm-patched-v1.webhook'], 'Unexpected compiled smoke delivery') captured(COMPOSE + ['run', '--rm', '--no-deps', 'tools', 'bregctl', '--format', 'json', 'webhook', 'replay', - '--runtime-config', '/config/breg/runtime.json', '--event-id', event_id, + '--runtime-config', '/config/breg/runtime.yaml', '--event-id', event_id, '--delivery-id', observed['deliveryId'], '--expected-generation', str(observed['generation'])], 'Explicit retained smoke BREG replay') settled = self.poll(lambda: delivery(event_id), lambda value: value['state'] == 'delivered', @@ -254,7 +254,7 @@ def before_accept(self): require(observed['payloadAvailable'], 'Dead letter payload expired before replay') require(observed['deliveryId'] == 'events.farm.farm-created-v1.webhook', 'Unexpected compiled recovery delivery') captured(COMPOSE + ['run', '--rm', '--no-deps', 'tools', 'bregctl', '--format', 'json', 'webhook', 'replay', - '--runtime-config', '/config/breg/runtime.json', '--event-id', scenario['eventId'], + '--runtime-config', '/config/breg/runtime.yaml', '--event-id', scenario['eventId'], '--delivery-id', observed['deliveryId'], '--expected-generation', str(observed['generation'])], 'Explicit BREG dead-letter replay') self.accepted(scenario) require(delivery(scenario['eventId'])['generation'] > scenario.get('deadLetterGeneration', 0), 'Replay did not advance generation') diff --git a/pilot/smoke.py b/pilot/smoke.py index bccd59b..6070a8e 100644 --- a/pilot/smoke.py +++ b/pilot/smoke.py @@ -20,7 +20,7 @@ import uuid REPO = Path(__file__).resolve().parent.parent -RUNTIME = REPO / 'pilot/agriculture/.runtime' +RUNTIME = REPO / 'pilot/agriculture/.runtime-0.38' COMPOSE = [str(REPO / 'deployment/compose.sh')] PROFILE = 'openfn-service' SAFE_ID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') @@ -83,7 +83,7 @@ def http_json(url, *, method='GET', body=None, headers=None, timeout=20, parse_j data=None if body is None else json.dumps(body).encode(), headers={'Accept': 'application/json', **({'Content-Type': 'application/json'} if body is not None else {}), **(headers or {})}) try: - with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response: + with urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()).open(request, timeout=timeout) as response: return response.status, json_output(response.read().decode(), 'HTTP service') if parse_json else None except urllib.error.HTTPError as error: # Bodies may contain subjects, workflow inputs, or diagnostic details. @@ -131,15 +131,14 @@ def save(self): private_write(self.state_path, self.state) def token(self): - value = captured(COMPOSE + ['run', '--rm', '--no-deps', 'tools', 'mint', 'token', - '--url', 'http://127.0.0.1:8091/token', '--client-id', PROFILE, - '--key', '/config/breg/clients/openfn-service/signing-p256-private-jwk'], 'Mint authentication').strip() - require(value.count('.') == 2 and not any(character.isspace() for character in value), 'Mint did not return one access token') + value = captured(COMPOSE + ['run', '--rm', '--no-deps', 'tools', 'python3', + '/opt/pilot/agriculture/issuer.py', 'token', PROFILE], 'ThunderID authentication').strip() + require(value.count('.') == 2 and not any(character.isspace() for character in value), 'ThunderID did not return one access token') return value def breg(self, path, method='GET', body=None, headers=None): separator = '&' if '?' in path else '?' - url = 'http://127.0.0.1:4002' + path + separator + urllib.parse.urlencode({'accessProfile': PROFILE}) + url = 'http://127.0.0.1:4012' + path + separator + urllib.parse.urlencode({'accessProfile': PROFILE}) status, value = http_json(url, method=method, body=body, headers={'Authorization': 'Bearer ' + self.token(), **(headers or {})}) require(200 <= status < 300, 'Registry acceptance request was refused (HTTP ' + str(status) + ')') @@ -170,7 +169,7 @@ def poll_work_order(self, work_order_id, expect_success): require(isinstance(work_order_id, str) and bool(SAFE_ID.fullmatch(work_order_id)), 'OpenFn returned an invalid work-order identifier') end = time.monotonic() + self.timeout while time.monotonic() < end: - status, value = http_json('http://127.0.0.1:4000/api/work_orders/' + work_order_id, + status, value = http_json('http://127.0.0.1:4010/api/work_orders/' + work_order_id, headers={'Authorization': 'Bearer ' + self.admin_token}) require(status == 200, 'OpenFn work-order inspection failed') state = value.get('data', {}).get('attributes', {}).get('state') @@ -186,7 +185,7 @@ def poll_work_order(self, work_order_id, expect_success): def webhook(self, name, data, expect_success=True): trigger = self.project.get('triggerIds', {}).get(name) require(isinstance(trigger, str) and bool(SAFE_ID.fullmatch(trigger)), 'Private project configuration lacks a trigger') - status, value = http_json('http://127.0.0.1:4000/i/' + trigger, method='POST', body=data, + status, value = http_json('http://127.0.0.1:4010/i/' + trigger, method='POST', body=data, headers={'x-api-key': self.webhook_key}, timeout=self.timeout) if not expect_success and status == 422: return None @@ -303,16 +302,16 @@ def run(self): current = self.current_request() self.inspect_content(current) state = current['data'].get('request', {}).get('bregState') - require(state in ['submitted', 'approved', 'applied'], 'Correction is not in an expected review state') - if state == 'submitted': + require(state in ['submitted', 'applied'], 'Correction is not in an expected source state') + approved = current['data']['request'].get('review', {}).get('result', {}).get('state') == 'approved' + if state == 'submitted' and not approved: require(self.farm()['data']['domainData'].get('name') == self.state['registration']['name'], 'Submission changed the governed farm before approval') self.review('inspect') self.review('approve') require(self.farm()['data']['domainData'].get('name') == self.state['registration']['name'], 'Approval unexpectedly applied the correction') self.state['phase'] = 'approved' self.save() - state = 'approved' - if state == 'approved': + if state == 'submitted': require(self.farm()['data']['domainData'].get('name') == self.state['registration']['name'], 'Farm changed before explicit application') self.review('inspect') self.review('apply') @@ -325,13 +324,13 @@ def run(self): def restart_recovery(self): require(self.state.get('phase') == 'complete', 'Complete the main acceptance smoke before restart recovery') - # Only restart these existing pilot services. Keep databases, Mint's + # Only restart these current pilot services. Keep databases, the issuer's # shared network namespace, and every named volume intact. - captured(COMPOSE + ['restart', 'breg', 'worker', 'bridge', 'destination', 'lightning'], 'Retained-state pilot restart', timeout=180) + captured(COMPOSE + ['restart', 'breg', 'casework', 'worker', 'bridge', 'destination', 'lightning'], 'Retained-state pilot restart', timeout=180) end = time.monotonic() + self.timeout while time.monotonic() < end: try: - status, _ = http_json('http://127.0.0.1:4000/health_check', timeout=5, parse_json=False) + status, _ = http_json('http://127.0.0.1:4010/health_check', timeout=5, parse_json=False) if status == 200: break except SmokeFailure: diff --git a/pilot/test/deployment_test.py b/pilot/test/deployment_test.py new file mode 100644 index 0000000..7635778 --- /dev/null +++ b/pilot/test/deployment_test.py @@ -0,0 +1,65 @@ +"""Prove that lifecycle commands select only the marked 0.38 pilot.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] + + +class DeploymentIsolationTests(unittest.TestCase): + def test_compose_uses_separate_project_and_operator_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + deployment = root / 'deployment' + deployment.mkdir() + shutil.copy2(REPO / 'deployment/compose.sh', deployment / 'compose.sh') + legacy = deployment / '.env' + legacy.write_text('legacy operator configuration\n') + binary = root / 'bin' + binary.mkdir() + capture = root / 'docker-arguments' + (binary / 'docker').write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$PILOT_TEST_CAPTURE"\n') + (binary / 'docker').chmod(0o755) + result = subprocess.run([str(deployment / 'compose.sh'), 'ps'], capture_output=True, text=True, + env={**os.environ, 'PATH': str(binary) + os.pathsep + os.environ['PATH'], + 'PILOT_TEST_CAPTURE': str(capture)}) + self.assertEqual(result.returncode, 0, result.stderr) + arguments = capture.read_text().splitlines() + self.assertEqual(arguments[:3], ['compose', '--project-name', 'registry-openfn-pilot-038']) + self.assertEqual(arguments[arguments.index('--env-file') + 1], str(deployment / '.env-0.38')) + self.assertEqual(legacy.read_text(), 'legacy operator configuration\n') + self.assertEqual((deployment / '.env-0.38').stat().st_mode & 0o777, 0o600) + + def test_start_and_reset_refuse_legacy_marker_before_any_docker_action(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + deployment = root / 'deployment' + deployment.mkdir() + shutil.copy2(REPO / 'deployment/pilot.sh', deployment / 'pilot.sh') + capture = root / 'compose-was-invoked' + (deployment / 'compose.sh').write_text('#!/bin/sh\ntouch "$PILOT_TEST_CAPTURE"\n') + (deployment / 'compose.sh').chmod(0o755) + runtime = root / 'pilot/agriculture/.runtime-0.38' + runtime.mkdir(parents=True) + marker = runtime / 'prepared.json' + marker.write_text(json.dumps({'schema': 'synthetic-agriculture-pilot/v1', 'version': '0.27.0'})) + legacy = root / 'pilot/agriculture/.runtime' + legacy.mkdir() + sentinel = legacy / 'retained-data' + sentinel.write_text('keep') + for arguments in [['start'], ['reset', '--confirm-delete-synthetic-data']]: + result = subprocess.run([str(deployment / 'pilot.sh'), *arguments], capture_output=True, text=True, + env={**os.environ, 'PILOT_TEST_CAPTURE': str(capture)}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('not the isolated 0.38', result.stderr) + self.assertFalse(capture.exists()) + self.assertTrue(marker.exists()) + self.assertEqual(sentinel.read_text(), 'keep') + + +if __name__ == '__main__': + unittest.main() diff --git a/pilot/test/evidence-workflow.test.js b/pilot/test/evidence-workflow.test.js index 69ba5cc..104efd6 100644 --- a/pilot/test/evidence-workflow.test.js +++ b/pilot/test/evidence-workflow.test.js @@ -1,8 +1,6 @@ import assert from 'node:assert/strict'; import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import http from 'node:http'; -import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; @@ -23,6 +21,17 @@ const spec = { audience: 'urn:example:openfn', evidenceType: 'urn:example:evidence-type:registered', issuedBy: 'urn:example:agricultural-registry', providedBy: 'urn:example:evidence', }; +bindings.evidenceRequest = { + ...spec, + configurationRevision: revision, + expectedAssuranceProfile: 'local', + subjects: [{role: 'farm', selectorProfile: 'farm-id', + selectorValues: {'local-identifier': {valueFrom: 'data.values.local-identifier'}}}], + expectedOutputs: [{concept: bindings.registeredConcept, form: 'boolean'}], + maximumAssertionLifetimeSeconds: 300, + clockSkewSeconds: 30, + subjectExpectations: 'acceptFirstUse', +}; const envelope = () => ({ event: { specversion: '1.0', source: 'urn:registrystack:registry:agricultural-holdings:instance:pilot', id: '22222222-2222-4222-8222-222222222222', type: 'registry.record.committed', @@ -44,7 +53,6 @@ function keyPair() { async function fixture(outcome = 'true') { const key = keyPair(); const clientKey = keyPair(); - const directory = mkdtempSync(resolve(tmpdir(), 'openfn-pilot-evidence-')); const requests = []; let origin; const json = (res, value, status = 200, mediaType = 'application/json') => { @@ -57,23 +65,7 @@ async function fixture(outcome = 'true') { req.on('end', () => { const body = Buffer.concat(chunks).toString('utf8'); requests.push({ path: req.url, body, authorization: req.headers.authorization }); - if (req.url === '/.well-known/oauth-protected-resource') return json(res, { - resource: origin, authorization_servers: [origin], jwks_uri: `${origin}/.well-known/evidence/jwks.json`, bearer_methods_supported: ['header'], - }); - if (req.url === '/.well-known/oauth-authorization-server') return json(res, { - issuer: origin, token_endpoint: `${origin}/token`, grant_types_supported: ['client_credentials'], token_endpoint_auth_methods_supported: ['private_key_jwt'], - }); - if (req.url === '/.well-known/evidence/jwks.json') return json(res, key.jwks, 200, 'application/jwk-set+json'); if (req.url === '/token') return json(res, { access_token: 'synthetic-evidence-token', token_type: 'Bearer', expires_in: 300 }); - if (req.url === '/v1/evidence-definitions') return json(res, { - schema: 'registry.evidence-definitions/v1', assuranceProfile: 'local', audience: spec.audience, issuedBy: spec.issuedBy, providedBy: spec.providedBy, - definitions: [{ handle: bindings.requirement, requirement: spec.requirement, configurationRevision: revision, - kind: 'criterion', evidenceType: spec.evidenceType, purpose: spec.purpose, responseFormats: ['signed-jws'], - referenceFrameworks: ['urn:example:framework:registration'], - subjects: [{ role: 'farm', cardinality: 'one', selector: { profile: 'farm-id', valueOrigin: 'request', - fields: [{ type: 'string', name: 'local-identifier', minimumBytes: 1, maximumBytes: 200 }] } }], - concepts: [{ handle: 'registered', concept: bindings.registeredConcept, required: true, form: 'boolean' }] }], - }); if (req.url === '/updates') return json(res, { status: 'applied', revision: JSON.parse(body).revision }); if (req.url === '/v1/evidence') { if (outcome === 'unavailable') return json(res, { @@ -100,12 +92,11 @@ async function fixture(outcome = 'true') { }); await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); origin = `http://127.0.0.1:${server.address().port}`; - const profilePath = resolve(directory, 'client.json'); - writeFileSync(profilePath, JSON.stringify({ schema: 'registry.evidence-client-profile/v1', baseUrl: origin, - clientId: 'openfn-pilot', privateKey: { source: 'file', path: 'unused.jwk' }, - trust: { type: 'local-loopback-discovery' }, contracts: { type: 'published' } }), { mode: 0o600 }); - return { requests, destinationConfiguration: { baseUrl: origin, apiKey: 'synthetic-destination-key' }, configuration: { evidence: { profilePath, privateKeyJwk: clientKey.privateJwk } }, - close: async () => { await new Promise(done => server.close(done)); rmSync(directory, { recursive: true, force: true }); } }; + return { requests, destinationConfiguration: { baseUrl: origin, apiKey: 'synthetic-destination-key' }, configuration: { evidence: { + baseUrl: origin, trustedJwks: key.jwks, revokedKeyIds: [], + token: {privateKeyJwt: {tokenEndpoint: `${origin}/token`, clientId: 'openfn-pilot', clientKey: clientKey.privateJwk}}, + } }, + close: async () => { await new Promise(done => server.close(done)); } }; } async function executeEvidence(configuration, data, destinationConfiguration) { @@ -160,6 +151,7 @@ test('committed workflow emits only event identity/revision and verified current assert.equal(JSON.stringify(result).includes(revision), false); assert.equal('configuration' in result, false); const requests = service.requests.filter(request => request.path === '/v1/evidence'); + assert.deepEqual(service.requests.map(request => request.path), ['/token', '/v1/evidence']); assert.equal(requests.length, 1); assert.deepEqual(JSON.parse(requests[0].body).subjects, [{ role: 'farm', selector: { profile: 'farm-id', values: { 'local-identifier': 'synthetic-farm-selector' } } }]); } finally { await service.close(); } diff --git a/pilot/test/openbao_test.py b/pilot/test/openbao_test.py new file mode 100644 index 0000000..a58fd9f --- /dev/null +++ b/pilot/test/openbao_test.py @@ -0,0 +1,83 @@ +"""Focused guards for the retained OpenBao Evidence signing key.""" +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +REPO = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('openbao_state', REPO / 'deployment/openbao-state.py') +openbao_state = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(openbao_state) + +PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nsynthetic-public-key\n-----END PUBLIC KEY-----' + + +def metadata(): + return { + 'name': 'evidence-signing', + 'type': 'ecdsa-p256', + 'derived': False, + 'deletion_allowed': False, + 'exportable': False, + 'allow_plaintext_backup': False, + 'supports_signing': True, + 'latest_version': 1, + 'auto_rotate_period': 0, + 'imported_key': False, + 'soft_deleted': False, + 'keys': {'1': {'public_key': PUBLIC_KEY}}, + } + + +class OpenBaoKeyGuardTests(unittest.TestCase): + def runtime(self, root, provider_metadata, retained_key='retained-public-key\n'): + openbao = root / 'openbao' + output = root / 'evidence/transit-public' + openbao.mkdir(parents=True) + output.mkdir(parents=True) + (openbao / 'evidence-signing-metadata.json.tmp').write_text( + json.dumps({'data': provider_metadata}), encoding='utf-8') + (output / 'evidence-signing.pem').write_text(retained_key, encoding='utf-8') + (output / 'evidence-signing.jwk.json').write_text('{"kid":"retained"}\n', encoding='utf-8') + return output + + def assert_refused_without_replacing_retained_key(self, provider_metadata): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = self.runtime(root, provider_metadata) + before = {path.name: path.read_bytes() for path in output.iterdir()} + with self.assertRaisesRegex(RuntimeError, 'single-version signing key'): + openbao_state.record_public(root, Path('/unused/evidencectl')) + self.assertEqual({path.name: path.read_bytes() for path in output.iterdir()}, before) + + def test_refuses_a_rotated_multi_version_key(self): + provider = metadata() + provider['latest_version'] = 2 + provider['keys']['2'] = {'public_key': 'rotated'} + self.assert_refused_without_replacing_retained_key(provider) + + def test_refuses_exportable_or_plaintext_backup_key(self): + for field in ('exportable', 'allow_plaintext_backup'): + with self.subTest(field=field): + provider = metadata() + provider[field] = True + self.assert_refused_without_replacing_retained_key(provider) + + def test_refuses_enabled_automatic_rotation(self): + provider = metadata() + provider['auto_rotate_period'] = 3600 + self.assert_refused_without_replacing_retained_key(provider) + + def test_accepts_the_retained_single_version_key(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.runtime(root, copy.deepcopy(metadata()), PUBLIC_KEY + '\n') + openbao_state.record_public(root, Path('/unused/evidencectl')) + self.assertFalse((root / 'openbao/evidence-signing-metadata.json.tmp').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/pilot/test/provision.test.js b/pilot/test/provision.test.js index 3bf29e9..0592bb6 100644 --- a/pilot/test/provision.test.js +++ b/pilot/test/provision.test.js @@ -63,7 +63,7 @@ test('credential creation survives lost POST response and resumes without duplic const api = remote({loseFirstCreate: true}); await assert.rejects(run('provision', root, api), /response lost/); const partial = await readJson(join(secrets, 'credentials.json')); - assert.equal(partial.credentialFormat, 2, 'format must be durable before the first POST'); + assert.equal(partial.credentialFormat, 3, 'format must be durable before the first POST'); assert.ok(partial.credentialNames.breg); assert.equal(partial.breg, undefined); await run('provision', root, api); @@ -75,9 +75,9 @@ test('credential creation survives lost POST response and resumes without duplic assert.equal(api.creations.length, 3, 'repeat must preserve selected remote credentials'); }); -test('legacy associations remain untouched while replacement format uses a fresh namespace', async t => { +test('prior credential format remains untouched while replacement uses a fresh namespace', async t => { const {root, secrets} = await fixture(t); - const legacy = {breg: 'old-association'}; + const legacy = {credentialFormat: 2, breg: 'old-association'}; await privateFile(join(secrets, 'credentials.json'), JSON.stringify(legacy)); const old = {id: 'old', name: 'Agriculture breg main', schema: 'raw', project_credentials: [{project_id: projectId, id: 'old-association'}]}; const api = remote({credentials: [old]}); @@ -129,7 +129,7 @@ test('failed checkpoint write preserves prior bytes and removes staging file', a assert.ok(!(await readdir(secrets)).some(name => name.endsWith('.tmp'))); }); -test('partial format-two checkpoint reuses the credential saved before the next request failed', async t => { +test('partial format-three checkpoint reuses the credential saved before the next request failed', async t => { const {root, secrets} = await fixture(t); const api = remote(); let failSecond = true; @@ -142,7 +142,7 @@ test('partial format-two checkpoint reuses the credential saved before the next }}; await assert.rejects(run('provision', root, interrupted), /second credential unavailable/); const before = await readJson(join(secrets, 'credentials.json')); - assert.equal(before.credentialFormat, 2); + assert.equal(before.credentialFormat, 3); assert.equal(before.breg, 'association-1'); await run('provision', root, api); assert.equal(api.creations.length, 3); @@ -168,7 +168,7 @@ async function enableFixture(t, unsafeTrigger) { const local = await fixture(t); await privateFile(join(local.secrets, 'webhook-api-key'), 'synthetic-intake-key'); await privateFile(join(local.secrets, 'committed-api-key'), 'synthetic-committed-key'); - await privateFile(join(local.secrets, 'credentials.json'), JSON.stringify({credentialFormat: 2})); + await privateFile(join(local.secrets, 'credentials.json'), JSON.stringify({credentialFormat: 3})); const probes = []; let enabled = false; const fetchImpl = async (input, options) => { diff --git a/pilot/test/recovery_test.py b/pilot/test/recovery_test.py index c8178ff..73e0150 100644 --- a/pilot/test/recovery_test.py +++ b/pilot/test/recovery_test.py @@ -36,6 +36,7 @@ def test_database_read_only_and_failure_output_withheld(self): sql = command.call_args.kwargs['input'] self.assertTrue(sql.startswith('BEGIN READ ONLY;')) self.assertTrue(sql.endswith('ROLLBACK;\n')) + self.assertIn("::jsonb->'data'->>'recordId'", sql) self.assertIn('-d agriculture', command.call_args.args[0][-1]) result = subprocess.CompletedProcess([], 1, stdout='SYNTHETIC-SECRET-CANARY', stderr='SYNTHETIC-PAYLOAD-CANARY') with patch.object(recovery.subprocess, 'run', return_value=result): @@ -46,7 +47,7 @@ def test_database_read_only_and_failure_output_withheld(self): def test_service_scope_disallows_database_or_volume_operations(self): driver = recovery.Recovery.__new__(recovery.Recovery) with patch.object(recovery, 'captured') as command: - for operation, services in [('down', ['worker']), ('stop', ['breg-db']), ('restart', ['mint'])]: + for operation, services in [('down', ['worker']), ('stop', ['breg-db']), ('restart', ['issuer'])]: with self.assertRaises(recovery.SmokeFailure): driver.service(operation, *services) command.assert_not_called() @@ -111,6 +112,7 @@ def test_smoke_replay_waits_for_success_and_preserves_existing_effect(self): self.assertEqual(driver.replay_smoke(), 1) args = command.call_args.args[0] self.assertIn(EVENT, args) + self.assertEqual(args[args.index('--runtime-config') + 1], '/config/breg/runtime.yaml') self.assertEqual(args[-2:], ['--expected-generation', '1']) with patch.object(driver, 'orders', return_value=[{'state': 'pending'}]), patch.object(recovery, 'captured') as command: with self.assertRaises(recovery.SmokeFailure): diff --git a/pilot/test/review_test.py b/pilot/test/review_test.py new file mode 100644 index 0000000..af8695d --- /dev/null +++ b/pilot/test/review_test.py @@ -0,0 +1,113 @@ +"""Focused proofs for the Casework-to-BReg reviewer authority boundary.""" +import copy +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('review', Path(__file__).resolve().parents[1] / 'agriculture/review.py') +review = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(review) +REQUEST = '00000000-0000-4000-8000-000000000001' +REVIEW = '00000000-0000-4000-8000-000000000002' +TASK = '00000000-0000-4000-8000-000000000003' +DIGEST = 'sha256:' + 'a' * 64 +POLICY = {'id': 'name-correction', 'version': '1', 'digest': 'sha256:' + 'b' * 64} + + +def snapshot(): + return {'breg': {'data': {'recordIdentifier': REQUEST, 'request': { + 'bregState': 'submitted', 'proposalVersion': 1, 'effectDigest': DIGEST, + 'review': {'submission': {'state': 'accepted', 'authority': 'casework', 'requestId': REVIEW, + 'policy': POLICY, 'submissionDigest': 'sha256:' + 'c' * 64}}, 'actions': []}}}, + 'casework': {'task': {'taskId': TASK, 'requestId': REVIEW, 'revision': 1, 'state': 'open'}, + 'context': {'taskId': TASK, 'requestId': REVIEW, + 'subject': {'source': 'agricultural-holdings', 'type': 'name-correction', 'id': REQUEST, + 'version': '1', 'digest': DIGEST}, 'policy': POLICY, + 'context': {'strategy': 'source', 'bindingStatus': 'current', + 'projection': {'display': {'name': 'Synthetic proposed name'}}}}}} + + +class ReviewTests(unittest.TestCase): + def test_approval_claims_exact_review_revision_then_decides_without_registry_mutation(self): + value = snapshot() + claimed = {**value['casework']['task'], 'revision': 2, 'state': {'held': {'holder': {'issuer': 'local', 'subject': 'reviewer'}}}} + with tempfile.TemporaryDirectory() as directory, patch.object(review, 'ROOT', Path(directory)), \ + patch.object(review, 'record', return_value=value['breg']), \ + patch.object(review, 'casework', side_effect=[value['casework']['context'], claimed, None]) as casework, \ + patch.object(review, 'request') as registry_mutation: + review.approve(REQUEST, value, 'synthetic-token') + calls = casework.call_args_list + self.assertEqual(calls[1].args[:3], (f'/v1/review-tasks/{TASK}/claim', 'synthetic-token', 'POST')) + self.assertEqual(calls[1].kwargs['headers']['If-Match'], '"1"') + self.assertEqual(calls[2].args[3], {'decision': {'type': 'approve'}}) + self.assertEqual(calls[2].args[4]['If-Match'], '"2"') + self.assertEqual((Path(directory) / 'review' / f'{REQUEST}.json').stat().st_mode & 0o777, 0o600) + registry_mutation.assert_not_called() + + def test_changed_source_or_policy_binding_cannot_be_approved(self): + for field in ['subject', 'policy', 'context']: + value = snapshot() + changed = copy.deepcopy(value['casework']['context']) + if field == 'context': + changed[field]['bindingStatus'] = 'binding_changed' + else: + changed[field]['digest'] = 'sha256:' + 'd' * 64 + with patch.object(review, 'record', return_value=value['breg']), \ + patch.object(review, 'casework', return_value=changed) as casework: + with self.assertRaises(RuntimeError): + review.approve(REQUEST, value, 'synthetic-token') + self.assertEqual(len(casework.call_args_list), 1) + self.assertEqual(len(casework.call_args.args), 2) + + def test_changed_proposal_refuses_before_review_authority_exchange(self): + value = snapshot() + changed = copy.deepcopy(value['breg']) + changed['data']['request']['proposalVersion'] = 2 + with patch.object(review, 'record', return_value=changed), patch.object(review, 'casework') as casework: + with self.assertRaises(RuntimeError): + review.approve(REQUEST, value, 'synthetic-token') + casework.assert_not_called() + + def test_application_requires_reconciled_approval_and_exact_inspected_action(self): + value = snapshot() + with patch.object(review, 'request') as mutation: + with self.assertRaises(RuntimeError): + review.apply(REQUEST, value, 'synthetic-token') + mutation.assert_not_called() + details = value['breg']['data']['request'] + details['review']['result'] = {'state': 'approved'} + details['actions'] = [{'operation': 'apply_request', 'href': f'/v1/records/name-corrections/{REQUEST}/actions/apply?accessProfile=reviewer', + 'ifMatch': '"synthetic-lifecycle-etag"', 'proposalVersion': 1, 'effectDigest': DIGEST}] + with patch.object(review, 'request') as mutation: + review.apply(REQUEST, value, 'synthetic-token') + self.assertEqual(mutation.call_args.args[4], {'proposalVersion': 1, 'effectDigest': DIGEST}) + self.assertEqual(mutation.call_args.args[5]['If-Match'], '"synthetic-lifecycle-etag"') + for href in ['https://attacker.invalid/v1/records', f'/v1/records/name-corrections/{REQUEST}/actions/apply?accessProfile=other']: + details['actions'][0]['href'] = href + with patch.object(review, 'request') as mutation: + with self.assertRaises(RuntimeError): + review.apply(REQUEST, value, 'synthetic-token') + mutation.assert_not_called() + + def test_inspection_uses_exact_accepted_review_request(self): + value = snapshot() + with patch.object(review, 'record', return_value=value['breg']), \ + patch.object(review, 'casework', side_effect=[{'items': [value['casework']['task']]}, value['casework']['context']]): + self.assertEqual(review.inspect(REQUEST, 'synthetic-token'), value) + + def test_inspection_waits_for_registry_reconciliation_after_casework_decision(self): + value = snapshot() + reconciled = copy.deepcopy(value['breg']) + reconciled['data']['request']['review']['result'] = {'state': 'approved'} + decided = {**value['casework']['task'], 'state': 'decided'} + with patch.object(review, 'record', side_effect=[value['breg'], reconciled]), \ + patch.object(review, 'casework', return_value={'items': [decided]}) as casework, \ + patch.object(review.time, 'sleep'): + self.assertEqual(review.inspect(REQUEST, 'synthetic-token'), {'breg': reconciled}) + self.assertEqual(len(casework.call_args_list), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/pilot/test/smoke_test.py b/pilot/test/smoke_test.py index e80f5ec..d7e791f 100644 --- a/pilot/test/smoke_test.py +++ b/pilot/test/smoke_test.py @@ -25,6 +25,7 @@ def __init__(self, path): self.calls = [] self.current_name = None self.request_state = None + self.review_result = None def destination(self): return {'acceptedEvents': self.events + 100, 'appliedEffects': self.events + 100, 'records': int(self.events > 0) + 25} @@ -47,7 +48,8 @@ def farm(self): return {'data': {'recordIdentifier': RECORD, 'domainData': {'name': self.current_name}}} def current_request(self): - return {'data': {'recordIdentifier': REQUEST, 'request': {'bregState': self.request_state}, + return {'data': {'recordIdentifier': REQUEST, 'request': {'bregState': self.request_state, + 'review': {'result': {'state': self.review_result}}}, 'domainData': {'record': RECORD, 'name': self.state['correctedName'], 'reason': self.state['reason'], 'supportingReference': self.state['supportingReference']}}} def review(self, action): @@ -55,10 +57,11 @@ def review(self, action): if action == 'approve': assert self.calls[-2] == ('review', 'inspect') assert self.current_name == self.state['registration']['name'] - self.request_state = 'approved' + self.review_result = 'approved' if action == 'apply': assert self.calls[-2] == ('review', 'inspect') - assert self.request_state == 'approved' + assert self.request_state == 'submitted' + assert self.review_result == 'approved' self.request_state = 'applied' self.current_name = self.state['correctedName'] self.events += 1 @@ -87,7 +90,8 @@ def test_partial_approval_recovers_without_approving_again(self): pilot = FakePilot(Path(directory) / 'state.json') pilot.state.update(recordId=RECORD, requestId=REQUEST, baseline={'acceptedEvents': 0}, phase='approved') pilot.current_name = 'Old Name' - pilot.request_state = 'approved' + pilot.request_state = 'submitted' + pilot.review_result = 'approved' pilot.events = 1 pilot.run() self.assertEqual(pilot.events, 2) @@ -104,7 +108,7 @@ def test_restart_requires_completed_main_and_only_restarts_retained_services(sel pilot.run() with patch.object(smoke, 'captured', return_value='') as command, patch.object(smoke, 'http_json', return_value=(200, None)): pilot.restart_recovery() - self.assertEqual(command.call_args.args[0][-6:], ['restart', 'breg', 'worker', 'bridge', 'destination', 'lightning']) + self.assertEqual(command.call_args.args[0][-7:], ['restart', 'breg', 'casework', 'worker', 'bridge', 'destination', 'lightning']) self.assertEqual(pilot.state['restartRecovery'], 'passed') self.assertEqual(pilot.events, 2) diff --git a/services/event-bridge/README.md b/services/event-bridge/README.md index 5e2e93d..c34f937 100644 --- a/services/event-bridge/README.md +++ b/services/event-bridge/README.md @@ -16,14 +16,25 @@ decoded. Signatures are compared in constant time. Duplicate signed headers, content encoding, wrong source/type/schema, stale delivery times, and invalid payloads are refused before forwarding. -The receiver accepts exactly the common BREG payload members `entity`, `recordId`, -`revision`, `trigger`, `packageRevision`, and `values`. Entity, event schema, -trigger, and projected field names must match configuration. Record/event ids -must be canonical lowercase UUIDs, revision a positive safe integer, package -revision a SHA-256 binding, and projected values strings of at most 1024 UTF-8 -bytes. Reviewed `request_lifecycle` events also carry closed request transition -metadata. Per-event `entity` and `valueFields` override the common defaults. -This is a bounded string projection receiver, not a general JSON Schema validator. +The default `direct-data-v0` body format accepts the legacy BREG projection as +the request body. Set `BREG_EVENT_BODY_FORMAT=hook-envelope-v1` for Registry +Stack 0.38. That mode requires the exact canonical RFC 8785 `HookEnvelope` +bytes emitted by BREG. Its closed members are `id`, `type`, `source`, `time`, +`subject`, `dataschema`, `data`, and `causation`. The five CloudEvent identity +members must equal their already verified `ce-*` headers. The subject must +contain BREG's HMAC record reference and the same positive revision as `data`. +Causation must be a valid root or child chain at or below hop 8. The mode is +explicit so a malformed envelope cannot be reinterpreted as a legacy body. + +In either mode, the validated data accepts exactly the common BREG members +`entity`, `recordId`, `revision`, `trigger`, `packageRevision`, and `values`. +Entity, event schema, trigger, and projected field names must match +configuration. Record/event ids must be canonical lowercase UUIDs, revision a +positive safe integer, package revision a SHA-256 binding, and projected values +strings of at most 1024 UTF-8 bytes. Reviewed `request_lifecycle` events also +carry closed request transition metadata. Per-event `entity` and `valueFields` +override the common defaults. This is a bounded string projection receiver, +not a general JSON Schema validator. Configuration is required unless a default is listed: @@ -32,6 +43,7 @@ Configuration is required unless a default is listed: | `BREG_HMAC_KEY_FILE` | File containing exact HMAC key bytes, at least 32 bytes. | | `BREG_EXPECTED_SOURCE` | Exact CloudEvent source for the registry instance. | | `BREG_EVENT_PATH` | Optional exact signed ingress path, default `/events/breg`; one lowercase name under `/events/`, with letters, digits or hyphens. | +| `BREG_EVENT_BODY_FORMAT` | Exact body contract. Default `direct-data-v0` preserves legacy projection bodies. Set `hook-envelope-v1` for canonical Registry Stack 0.38 `HookEnvelope` bodies. No other value is accepted. | | `BREG_BIND_HOST` | Listener address, default `0.0.0.0` for compatibility. Set `127.0.0.1` for a host-local receiver; no other addresses are accepted. | | `BREG_EXPECTED_ENTITY` | Exact payload entity, `farm` for this pilot. | | `BREG_EXPECTED_EVENTS_FILE` | JSON object mapping each exact type to `{ "schema": "exact-dataschema", "trigger": "created" }`; `patched` and `request_lifecycle` are also supported. Optional per-event `entity` and `valueFields` override defaults; CLI mode requires `effect`. | @@ -46,7 +58,10 @@ Configuration is required unless a default is listed: | `MAX_DELIVERY_SKEW_SECONDS` | Default 300; maximum 3600. Applies to delivery time, not original event time. | | `OPENFN_TIMEOUT_MS` | Default 3000; maximum 30000. Set below BREG's configured attempt timeout. | -The authenticated OpenFn webhook receives this JSON envelope: +The authenticated OpenFn webhook receives this normalized JSON envelope in +both body modes. In `hook-envelope-v1`, the bridge validates the inbound +subject and causation but forwards only the closed `data` projection so +existing OpenFn jobs keep the same input contract: ```json { @@ -83,6 +98,12 @@ malformed/generic success responses, redirects, transport failures, or oversized responses produce 502; deadline expiry produces 504. There are no internal retries, redirect following, or persistent bridge inbox in webhook mode. +That legacy receipt applies to `direct-data-v0`. Registry Stack 0.38 treats a +successful URL handler response as a canonical HookMessage. In +`hook-envelope-v1`, the bridge therefore returns the notification answer +`202 {"answer":"none"}` after the same durable handoff. This acknowledges the +event without proposing a follow-up registry change. + If acceptance occurs but the response is lost, BREG can submit another work order. The workflow and destination must tolerate this, including replay under a new generation. Destination deduplication uses `(source, eventId, effect)`, diff --git a/services/event-bridge/src/server.js b/services/event-bridge/src/server.js index 2d97297..8f4392e 100644 --- a/services/event-bridge/src/server.js +++ b/services/event-bridge/src/server.js @@ -12,7 +12,9 @@ const SIGNED_HEADERS = [ ]; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const DIGEST = /^sha256:[0-9a-f]{64}$/; +const RECORD_REFERENCE = /^hmac-sha256:[0-9a-f]{64}$/; const DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +const EVENT_BODY_FORMATS = ['direct-data-v0', 'hook-envelope-v1']; const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); class Refusal extends Error { @@ -30,8 +32,11 @@ export function loadConfig(env = process.env) { const hmacKey = readFileSync(env.BREG_HMAC_KEY_FILE); const eventPath = env.BREG_EVENT_PATH ?? EVENT_PATH; const bindHost = env.BREG_BIND_HOST ?? '0.0.0.0'; + const eventBodyFormat = env.BREG_EVENT_BODY_FORMAT ?? 'direct-data-v0'; const deliveryMode = env.OPENFN_DELIVERY_MODE ?? 'webhook'; - if (!['webhook', 'cli'].includes(deliveryMode)) throw new Error('invalid delivery mode'); + if (!['webhook', 'cli'].includes(deliveryMode) || !EVENT_BODY_FORMATS.includes(eventBodyFormat)) { + throw new Error('invalid delivery mode or event body format'); + } const apiKey = deliveryMode === 'webhook' ? readFileSync(env.OPENFN_API_KEY_FILE, 'utf8') : undefined; const expectedEvents = JSON.parse(readFileSync(env.BREG_EXPECTED_EVENTS_FILE, 'utf8')); const allowedValueFields = JSON.parse(readFileSync(env.BREG_ALLOWED_VALUE_FIELDS_FILE, 'utf8')); @@ -60,7 +65,7 @@ export function loadConfig(env = process.env) { hmacKey, apiKey, expectedEvents, allowedValueFields, deliveryMode, inboxPath: env.OPENFN_INBOX_PATH, expectedSource: env.BREG_EXPECTED_SOURCE, expectedEntity: env.BREG_EXPECTED_ENTITY, - eventPath, bindHost, + eventPath, bindHost, eventBodyFormat, openfnUrl: url, port: positive(env.PORT, 8081, 65535), maxBodyBytes: positive(env.MAX_BODY_BYTES, 65536, 1048576), maxDeliverySkewSeconds: positive(env.MAX_DELIVERY_SKEW_SECONDS, 300, 3600), @@ -134,10 +139,7 @@ function readBody(request, limit) { }); } -function verifyPayload(body, config, binding) { - let data; - try { data = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); } - catch { throw new Refusal(400, 'invalid_event'); } +function verifyData(data, config, binding) { const fields = binding.valueFields ?? config.allowedValueFields; const keys = ['entity', 'recordId', 'revision', 'trigger', 'packageRevision', 'values']; if (binding.trigger === 'request_lifecycle') keys.push('request'); @@ -156,6 +158,69 @@ function verifyPayload(body, config, binding) { return data; } +function canonicalJson(value) { + if (value === null || typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value); + if (typeof value === 'string') { + if (!value.isWellFormed()) throw new Refusal(400, 'invalid_event'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + return `{${Object.keys(value).sort().map(key => `${canonicalJson(key)}:${canonicalJson(value[key])}`).join(',')}}`; +} + +function normalizedEventTime(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return false; + const instant = Date.parse(value); + if (!Number.isFinite(instant)) return false; + const normalized = new Date(instant).toISOString() + .replace(/\.000Z$/, 'Z').replace(/(\.\d*?[1-9])0+Z$/, '$1Z'); + return normalized === value; +} + +function verifyHookEnvelope(envelope, body, headers, config, binding) { + const keys = ['id', 'type', 'source', 'time', 'subject', 'dataschema', 'data', 'causation']; + const subjectKeys = ['recordReference', 'recordRevision']; + if (!object(envelope) || Object.keys(envelope).length !== keys.length || keys.some(key => !Object.hasOwn(envelope, key)) || + envelope.id !== headers['ce-id'] || envelope.type !== headers['ce-type'] || + envelope.source !== headers['ce-source'] || envelope.time !== headers['ce-time'] || + envelope.dataschema !== headers['ce-dataschema'] || !normalizedEventTime(envelope.time) || + !object(envelope.subject) || Object.keys(envelope.subject).length !== subjectKeys.length || + subjectKeys.some(key => !Object.hasOwn(envelope.subject, key)) || + typeof envelope.subject.recordReference !== 'string' || !RECORD_REFERENCE.test(envelope.subject.recordReference) || + !Number.isSafeInteger(envelope.subject.recordRevision) || envelope.subject.recordRevision < 1) { + throw new Refusal(400, 'invalid_event'); + } + const data = verifyData(envelope.data, config, binding); + if (envelope.subject.recordRevision !== data.revision) throw new Refusal(400, 'invalid_event'); + const causationKeys = ['root', 'hop']; + if (!object(envelope.causation) || Object.hasOwn(envelope.causation, 'parent') && envelope.causation.parent === null || + Object.keys(envelope.causation).some(key => ![...causationKeys, 'parent'].includes(key)) || + causationKeys.some(key => !Object.hasOwn(envelope.causation, key)) || + typeof envelope.causation.root !== 'string' || !UUID.test(envelope.causation.root) || + !Number.isSafeInteger(envelope.causation.hop) || envelope.causation.hop < 0 || envelope.causation.hop > 8 || + (Object.hasOwn(envelope.causation, 'parent') && + (typeof envelope.causation.parent !== 'string' || !UUID.test(envelope.causation.parent))) || + (envelope.causation.hop === 0 && + (Object.hasOwn(envelope.causation, 'parent') || envelope.causation.root !== envelope.id)) || + (envelope.causation.hop > 0 && + (!Object.hasOwn(envelope.causation, 'parent') || envelope.causation.parent === envelope.id || + envelope.causation.root === envelope.id))) { + throw new Refusal(400, 'invalid_event'); + } + if (!body.equals(Buffer.from(canonicalJson(envelope)))) throw new Refusal(400, 'invalid_event'); + return data; +} + +function verifyPayload(body, headers, config, binding) { + let payload; + try { payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); } + catch { throw new Refusal(400, 'invalid_event'); } + const format = config.eventBodyFormat ?? 'direct-data-v0'; + if (format === 'direct-data-v0') return verifyData(payload, config, binding); + if (format === 'hook-envelope-v1') return verifyHookEnvelope(payload, body, headers, config, binding); + throw new Refusal(503, 'unavailable'); +} + function verifyLifecycle(value) { const keys = ['proposalVersion', 'workflowRevision', 'transition', 'fromState', 'toState', 'stage', 'reasonPresent', 'effectDigest', 'deduplicationKey']; @@ -213,6 +278,15 @@ function respond(response, status, code) { } } +function respondAccepted(response, eventBodyFormat) { + if (eventBodyFormat === 'hook-envelope-v1') { + response.writeHead(202, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + response.end('{"answer":"none"}'); + } else { + respond(response, 202, 'accepted'); + } +} + export function createBridge(config) { const inbox = config.deliveryMode === 'cli' ? new DurableInbox(config.inboxPath) : undefined; const eventPath = config.eventPath ?? EVENT_PATH; @@ -226,7 +300,7 @@ export function createBridge(config) { const expected = webhookSignature(config.hmacKey, request.headers, body, 'POST', eventPath); if (!/^v1=[A-Za-z0-9_-]{43}$/.test(provided) || !timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) throw new Refusal(401, 'invalid_signature'); - const data = verifyPayload(body, config, binding); + const data = verifyPayload(body, request.headers, config, binding); const h = request.headers; const envelope = { event: Object.fromEntries(['specversion', 'id', 'source', 'type', 'time', 'dataschema'].map(key => [key, h[`ce-${key}`]])), @@ -236,7 +310,7 @@ export function createBridge(config) { }; if (inbox) inbox.accept(envelope, binding.effect); else await forward(config, envelope); - respond(response, 202, 'accepted'); + respondAccepted(response, config.eventBodyFormat ?? 'direct-data-v0'); } catch (error) { respond(response, error instanceof Refusal ? error.status : 503, error instanceof Refusal ? error.message : 'unavailable'); } }); if (inbox) server.on('close', () => inbox.close()); diff --git a/services/event-bridge/test/bridge.test.js b/services/event-bridge/test/bridge.test.js index a77ed23..acf4491 100644 --- a/services/event-bridge/test/bridge.test.js +++ b/services/event-bridge/test/bridge.test.js @@ -13,6 +13,19 @@ const source = 'urn:registrystack:registry:agricultural-holdings:instance:pilot' const schema = `urn:breg:event-schema:agricultural-holdings:farm:farm-created-v1:sha256:${'a'.repeat(64)}`; const data = { entity: 'farm', recordId: '11111111-1111-4111-8111-111111111111', revision: 1, trigger: 'created', packageRevision: `sha256:${'b'.repeat(64)}`, values: { 'local-identifier': 'SYNTHETIC-FARM-001' } }; +const eventId = '22222222-2222-4222-8222-222222222222'; +// Independent fixture matching the published 0.38 bregctl webhook sample: +// the shared envelope repeats the five CloudEvent identity members, carries a +// closed subject and root causation, and wraps the governed BREG projection. +const hookEnvelope = { id: eventId, type: 'farm-created-v1', source, time: '2020-01-01T00:00:00Z', + subject: { recordReference: `hmac-sha256:${'d'.repeat(64)}`, recordRevision: 1 }, dataschema: schema, + data, causation: { root: eventId, hop: 0 } }; + +function canonicalFixture(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalFixture).join(',')}]`; + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalFixture(value[key])}`).join(',')}}`; +} // Independent implementation of the ordered Rust signing contract, deliberately // not the production verifier helper. Any body or signed-header change is bound. @@ -30,10 +43,10 @@ function sign(headers, body, path = EVENT_PATH, method = 'POST') { return `v1=${createHmac('sha256', key).update(Buffer.concat(parts)).digest('base64url')}`; } -function delivery(changes = {}, value = data, path = EVENT_PATH) { - const body = Buffer.from(JSON.stringify(value)); +function delivery(changes = {}, value = data, path = EVENT_PATH, canonical = false) { + const body = Buffer.from(canonical ? canonicalFixture(value) : JSON.stringify(value)); const headers = { 'content-type': 'application/json', 'ce-specversion': '1.0', - 'ce-id': '22222222-2222-4222-8222-222222222222', 'ce-source': source, 'ce-type': 'farm-created-v1', + 'ce-id': eventId, 'ce-source': source, 'ce-type': 'farm-created-v1', 'ce-time': '2020-01-01T00:00:00Z', 'ce-dataschema': schema, 'x-registry-event-generation': '1', 'x-registry-delivery-attempt': '1', 'x-registry-delivery-time': new Date().toISOString(), 'idempotency-key': `sha256:${'c'.repeat(64)}`, @@ -42,6 +55,10 @@ function delivery(changes = {}, value = data, path = EVENT_PATH) { return { headers, body }; } +function envelopeDelivery(changes = {}, value = hookEnvelope, path = EVENT_PATH) { + return delivery(changes, value, path, true); +} + async function listen(server) { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); return `http://127.0.0.1:${server.address().port}`; @@ -87,6 +104,73 @@ test('forwards verified envelope only after real work-order acceptance; old even assert.equal(JSON.stringify(calls[0]).includes(key.toString()), false); }); +test('accepts the canonical 0.38 HookEnvelope and preserves the normalized OpenFn shape', async t => { + const { url, calls } = await fixture(t, undefined, { eventBodyFormat: 'hook-envelope-v1' }); + const request = envelopeDelivery(); + const response = await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...request }); + assert.equal(response.status, 202); + assert.equal(await response.text(), '{"answer":"none"}'); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].data.data, data); + assert.deepEqual(calls[0].data.event, { + specversion: '1.0', id: eventId, source, type: 'farm-created-v1', + time: '2020-01-01T00:00:00Z', dataschema: schema, + }); + assert.equal(calls[0].data.subject, undefined); + assert.equal(calls[0].data.causation, undefined); +}); + +test('HookEnvelope identity is bound exactly to verified CloudEvent headers', async t => { + const { url, calls } = await fixture(t, undefined, { eventBodyFormat: 'hook-envelope-v1' }); + for (const value of [ + { ...hookEnvelope, id: '44444444-4444-4444-8444-444444444444' }, + { ...hookEnvelope, type: 'farm-patched-v1' }, + { ...hookEnvelope, source: `${source}-other` }, + { ...hookEnvelope, time: '2020-01-01T00:00:01Z' }, + { ...hookEnvelope, dataschema: `${schema}f` }, + ]) { + assert.equal((await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...envelopeDelivery({}, value) })).status, 400); + } + assert.equal(calls.length, 0); +}); + +test('HookEnvelope refuses invalid subject, causation and unknown members after valid signing', async t => { + const { url, calls } = await fixture(t, undefined, { eventBodyFormat: 'hook-envelope-v1' }); + const otherId = '44444444-4444-4444-8444-444444444444'; + const thirdId = '55555555-5555-4555-8555-555555555555'; + const values = [ + { ...hookEnvelope, subject: { ...hookEnvelope.subject, recordRevision: 2 } }, + { ...hookEnvelope, subject: { ...hookEnvelope.subject, recordReference: data.recordId } }, + { ...hookEnvelope, subject: { ...hookEnvelope.subject, unexpected: 'SYNTHETIC-PRIVATE-CANARY' } }, + { ...hookEnvelope, causation: { root: otherId, hop: 0 } }, + { ...hookEnvelope, causation: { root: eventId, parent: otherId, hop: 0 } }, + { ...hookEnvelope, causation: { root: otherId, hop: 1 } }, + { ...hookEnvelope, causation: { root: otherId, parent: eventId, hop: 1 } }, + { ...hookEnvelope, causation: { root: eventId, parent: thirdId, hop: 1 } }, + { ...hookEnvelope, causation: { root: otherId, parent: thirdId, hop: 9 } }, + { ...hookEnvelope, causation: { root: eventId, hop: 0, unexpected: 'SYNTHETIC-PRIVATE-CANARY' } }, + { ...hookEnvelope, unexpected: 'SYNTHETIC-PRIVATE-CANARY' }, + { ...hookEnvelope, data: { ...data, unexpected: 'SYNTHETIC-PRIVATE-CANARY' } }, + ]; + for (const value of values) { + const response = await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...envelopeDelivery({}, value) }); + assert.equal(response.status, 400); + assert.equal((await response.text()).includes('SYNTHETIC-PRIVATE-CANARY'), false); + } + assert.equal(calls.length, 0); +}); + +test('HookEnvelope requires canonical JSON and normalized millisecond UTC time', async t => { + const { url, calls } = await fixture(t, undefined, { eventBodyFormat: 'hook-envelope-v1' }); + const noncanonical = delivery({}, hookEnvelope); + assert.equal((await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...noncanonical })).status, 400); + for (const time of ['2020-01-01T00:00:00.000Z', '2019-12-31T19:00:00-05:00', '2020-01-01T00:00:00.1234Z']) { + const value = { ...hookEnvelope, time }; + assert.equal((await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...envelopeDelivery({ 'ce-time': time }, value) })).status, 400); + } + assert.equal(calls.length, 0); +}); + test('configured event path is exact and bound into the HMAC', async t => { const eventPath = '/events/laboratory'; const { url, calls } = await fixture(t, undefined, { eventPath }); @@ -197,6 +281,11 @@ test('configuration uses exact secret bytes, explicit HTTP trust and safe errors BREG_EXPECTED_SOURCE: source, BREG_EXPECTED_ENTITY: 'farm', OPENFN_WEBHOOK_URL: 'http://openfn:4000/i/pilot' }; assert.throws(() => loadConfig(env), /^Error: invalid bridge configuration$/); assert.equal(loadConfig({ ...env, ALLOW_HTTP: 'true' }).hmacKey.at(-1), 10); + assert.equal(loadConfig({ ...env, ALLOW_HTTP: 'true' }).eventBodyFormat, 'direct-data-v0'); + assert.equal(loadConfig({ ...env, ALLOW_HTTP: 'true', BREG_EVENT_BODY_FORMAT: 'hook-envelope-v1' }).eventBodyFormat, + 'hook-envelope-v1'); + assert.throws(() => loadConfig({ ...env, ALLOW_HTTP: 'true', BREG_EVENT_BODY_FORMAT: 'guess' }), + /^Error: invalid bridge configuration$/); assert.equal(loadConfig({ ...env, ALLOW_HTTP: 'true', BREG_EVENT_PATH: '/events/laboratory' }).eventPath, '/events/laboratory'); assert.equal(loadConfig({ ...env, ALLOW_HTTP: 'true' }).bindHost, '0.0.0.0'); assert.equal(loadConfig({ ...env, ALLOW_HTTP: 'true', BREG_BIND_HOST: '127.0.0.1' }).bindHost, '127.0.0.1'); @@ -214,7 +303,7 @@ test('CLI delivery authenticates before durable acceptance and handles lifecycle const directory = await mkdtemp(join(tmpdir(), 'bridge-durable-')); const path = join(directory, 'inbox.sqlite'); const { url, calls } = await fixture(t, () => { throw new Error('CLI mode must not forward'); }, { - deliveryMode: 'cli', inboxPath: path, + deliveryMode: 'cli', inboxPath: path, eventBodyFormat: 'hook-envelope-v1', expectedEvents: { 'farm-created-v1': { schema, trigger: 'request_lifecycle', effect: 'notify', entity: 'correction-request', valueFields: ['record-reference'] } }, }); @@ -224,15 +313,18 @@ test('CLI delivery authenticates before durable acceptance and handles lifecycle request: { proposalVersion: 1, workflowRevision: 2, transition: 'request_revision', fromState: 'submitted', toState: 'needs_changes', stage: 'review', reasonPresent: false, effectDigest: `sha256:${'d'.repeat(64)}`, deduplicationKey: `sha256:${'e'.repeat(64)}` } }; - const invalid = delivery({}, payload); + const envelope = { ...hookEnvelope, data: payload }; + const invalid = envelopeDelivery({}, envelope); invalid.headers['x-registry-signature'] = `v1=${'x'.repeat(43)}`; assert.equal((await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...invalid })).status, 401); const persisted = new DurableInbox(path); try { assert.equal(persisted.status().length, 0); for (const generation of ['1', '2']) { - const request = delivery({ 'x-registry-event-generation': generation }, payload); - assert.equal((await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...request })).status, 202); + const request = envelopeDelivery({ 'x-registry-event-generation': generation }, envelope); + const response = await fetch(`${url}${EVENT_PATH}`, { method: 'POST', ...request }); + assert.equal(response.status, 202); + assert.equal(await response.text(), '{"answer":"none"}'); assert.equal(persisted.status().length, 1, '202 means durable acceptance, even before worker starts'); } assert.equal(calls.length, 0);