diff --git a/.dockerignore b/.dockerignore index b717487..620c754 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,21 @@ .git .env .env.* +.cache +**/.cache +config/evidence/local +runtime +**/runtime +output +**/output +!generator/output +!generator/output/** +!output output/* -!output/smoke/ -!output/smoke/** +!output/smoke +output/smoke/* +!output/smoke/.gitkeep +relays/*/package node_modules **/node_modules .svelte-kit @@ -22,5 +34,28 @@ __pycache__ **/__pycache__ .pytest_cache **/.pytest_cache +.ruff_cache +**/.ruff_cache *.pyc *.log +**/*.log +**/*.jsonl +**/*.sqlite +**/*.sqlite-journal +**/*.sqlite-shm +**/*.sqlite-wal +**/secrets +**/secrets/** +**/audit +**/audit/** +**/audits +**/audits/** +**/extracts +**/extracts/** +**/*private*.jwk +**/*private*.jwk.json +**/signing.jwk +**/*.key +**/*.pem +**/*.p12 +**/*.pfx diff --git a/.env.example b/.env.example index 8ba82c1..7e12e99 100644 --- a/.env.example +++ b/.env.example @@ -1,135 +1,26 @@ -# Generate .env with `just gen-secrets`. Values here are placeholders only. +# Generate .env and the ignored Evidence/Mint/TLS files with `just gen-secrets`. +# Values here are placeholders only. COMPOSE_PROJECT_NAME=solmara-lab-replace-me -SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS=4102444800000 -SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS=2592000000 -REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD= -REGISTRY_ESIGNET_KYC_TOKEN_SECRET= -REGISTRY_ESIGNET_PSUT_SECRET= - -SOLMARA_POSTGRES_USER=solmara_registry -SOLMARA_POSTGRES_PASSWORD= -SOLMARA_POSTGRES_DB=solmara_lab -NIA_SOURCE_POSTGRES_READER_PASSWORD= -SIPF_SOURCE_POSTGRES_READER_PASSWORD= -SOLMARA_NIA_DATABASE_URL=postgres://solmara_source_nia_reader:replace-me@postgres:5432/solmara_lab?sslmode=require -SOLMARA_SIPF_DATABASE_URL=postgres://solmara_source_sipf_reader:replace-me@postgres:5432/solmara_lab?sslmode=require -SOLMARA_ESIGNET_POSTGRES_PASSWORD= - -CRA_RELAY_AUDIT_HASH_SECRET= -CRA_RELAY_AUDIT_PSEUDONYM_EPOCH_1= -CRA_RELAY_POSTGRES_RUNTIME_PASSWORD= -CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD= -CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD= -CRA_NOTARY_AUDIT_HASH_SECRET= -CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD= -CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD= -NIA_RELAY_AUDIT_HASH_SECRET= -NIA_RELAY_AUDIT_PSEUDONYM_EPOCH_1= -NIA_RELAY_POSTGRES_RUNTIME_PASSWORD= -NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD= -NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD= -NIA_NOTARY_AUDIT_HASH_SECRET= -NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD= -NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD= -SRO_RELAY_AUDIT_HASH_SECRET= -SRO_RELAY_AUDIT_PSEUDONYM_EPOCH_1= -SRO_RELAY_POSTGRES_RUNTIME_PASSWORD= -SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD= -SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD= -SRO_NOTARY_AUDIT_HASH_SECRET= -SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD= -SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD= -PROGRAMME_RELAY_AUDIT_HASH_SECRET= -PROGRAMME_RELAY_AUDIT_PSEUDONYM_EPOCH_1= -PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD= -PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD= -PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD= -PROGRAMME_NOTARY_AUDIT_HASH_SECRET= -PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD= -PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD= -SIPF_RELAY_AUDIT_HASH_SECRET= -SIPF_RELAY_AUDIT_PSEUDONYM_EPOCH_1= -SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD= -SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD= -SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD= -SIPF_NOTARY_AUDIT_HASH_SECRET= -SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD= -SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD= -NAGDI_RELAY_AUDIT_HASH_SECRET= -NAGDI_RELAY_AUDIT_PSEUDONYM_EPOCH_1= -NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD= -NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD= -NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD= -NAGDI_NOTARY_AUDIT_HASH_SECRET= -NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD= -NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD= - +CRA_RELAY_AUDIT_KEY= +NIA_RELAY_AUDIT_KEY= +MOSD_RELAY_AUDIT_KEY= +SIPF_RELAY_AUDIT_KEY= +SIPF_RELAY_CURSOR_KEY= +NAGDI_RELAY_AUDIT_KEY= +NAGDI_RELAY_CURSOR_KEY= CHILD_BENEFIT_FEDERATOR_TOKEN= -CRA_CHILD_BENEFIT_CLIENT_TOKEN= -CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH= -NIA_CHILD_BENEFIT_CLIENT_TOKEN= -NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH= -SRO_CHILD_BENEFIT_CLIENT_TOKEN= -SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH= -PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN= -PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH= -CRA_PENSION_CLIENT_TOKEN= -CRA_PENSION_CLIENT_TOKEN_HASH= -CRA_CITIZEN_CLIENT_TOKEN= -CRA_CITIZEN_CLIENT_TOKEN_HASH= -NIA_CITIZEN_CLIENT_TOKEN= -NIA_CITIZEN_CLIENT_TOKEN_HASH= -SIPF_PENSION_CLIENT_TOKEN= -SIPF_PENSION_CLIENT_TOKEN_HASH= -NAGDI_NOTARY_TOKEN= -NAGDI_CLIENT_TOKEN_HASH= - -NIA_NOTARY_ISSUER_JWK= -SIPF_NOTARY_ISSUER_JWK= -NAGDI_NOTARY_ISSUER_JWK= -CRA_RELAY_WORKLOAD_JWK= -NIA_RELAY_WORKLOAD_JWK= -NIA_ESIGNET_RELAY_WORKLOAD_JWK= -SRO_RELAY_WORKLOAD_JWK= -PROGRAMME_RELAY_WORKLOAD_JWK= -SIPF_RELAY_WORKLOAD_JWK= -NAGDI_RELAY_WORKLOAD_JWK= +SOLMARA_EVIDENCE_CLIENT_KEY=/absolute/path/to/solmara-lab/config/evidence/local/cells/mint/clients/solmara-demo-client-key PORTAL_SESSION_SECRET= PORTAL_AUTH_PROVIDER=mock + +# Optional local eSignet profile. +SOLMARA_ESIGNET_POSTGRES_PASSWORD= +NIA_ESIGNET_CLIENT_PRIVATE_JWK= +REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD= +REGISTRY_ESIGNET_KYC_TOKEN_SECRET= +REGISTRY_ESIGNET_PSUT_SECRET= PORTAL_ESIGNET_CLIENT_ID=solmara-portal PORTAL_ESIGNET_CLIENT_KEY_ID=solmara-portal-key-1 PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64= -PORTAL_ESIGNET_ISSUER=http://127.0.0.1:4308 -PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT=http://127.0.0.1:4309/authorize -PORTAL_ESIGNET_TOKEN_ENDPOINT=http://esignet:8088/v1/esignet/oauth/v2/token -PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE=http://127.0.0.1:4308/v1/esignet/oauth/v2/token -PORTAL_ESIGNET_USERINFO_ENDPOINT=http://esignet:8088/v1/esignet/oidc/userinfo -PORTAL_ESIGNET_REDIRECT_URI=http://127.0.0.1:4300/auth/callback -PORTAL_ESIGNET_SCOPE=openid profile -PORTAL_ESIGNET_SUBJECT_CLAIM=individual_id - -CHILD_BENEFIT_FEDERATOR_URL=http://127.0.0.1:4321 -CRA_NOTARY_URL=http://127.0.0.1:4325 -NIA_NOTARY_URL=http://127.0.0.1:4326 -SRO_NOTARY_URL=http://127.0.0.1:4327 -PROGRAMME_NOTARY_URL=http://127.0.0.1:4328 -SIPF_NOTARY_URL=http://127.0.0.1:4322 -NAGDI_NOTARY_URL=http://127.0.0.1:4323 -PORTAL_URL=http://127.0.0.1:4300 -SCENARIO_RUNNER_URL=http://127.0.0.1:4302 -STATIC_METADATA_URL=http://127.0.0.1:4331 -SOLMARA_ESIGNET_PORT=4308 -SOLMARA_ESIGNET_UI_PORT=4309 -SOLMARA_ESIGNET_PUBLIC_BASE_URL=http://127.0.0.1:4308 -SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL=http://127.0.0.1:4309 - -# Hosted Walt holder wallet. -CONFIG_REPO_REF=main -WALT_DB_PASSWORD= -WALT_AUTH_ENCRYPTION_KEY= -WALT_AUTH_SIGN_KEY= -WALT_AUTH_TOKEN_KEY= -WALT_KTOR_SIGNING_KEY= -WALT_KTOR_VERIFICATION_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc92583..3fc57a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,43 +7,50 @@ on: - main workflow_dispatch: +permissions: + contents: read + jobs: - checks: + authority-cell-checks: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - - name: Enable pnpm - run: corepack enable - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4.4.0 with: node-version: "22" - cache: pnpm - cache-dependency-path: portal/pnpm-lock.yaml - name: Install just - uses: extractions/setup-just@v2 - - name: Setup - run: just setup - - name: Publish metadata + env: + JUST_VERSION: 1.58.0 + JUST_SHA256: 4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d + run: | + set -euo pipefail + archive="$RUNNER_TEMP/just.tar.gz" + curl --fail --location --silent --show-error \ + --retry 5 --retry-all-errors --connect-timeout 30 --max-time 120 \ + --output "$archive" \ + "https://github.com/casey/just/releases/download/$JUST_VERSION/just-$JUST_VERSION-x86_64-unknown-linux-musl.tar.gz" + printf '%s %s\n' "$JUST_SHA256" "$archive" | sha256sum --check --status + mkdir -p "$RUNNER_TEMP/just-bin" + tar --extract --file "$archive" --directory "$RUNNER_TEMP/just-bin" just + printf '%s\n' "$RUNNER_TEMP/just-bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/just-bin/just" --version + - name: Install dependencies + run: | + corepack enable + just setup + - name: Generate ignored runtime material + run: just generate + - name: Publish checked-in metadata run: just metadata-publish - - name: Lint + - name: Lint authority-cell configuration run: just lint - - name: Test + - name: Test authority-cell application run: just test - - name: Verify compiler-generated authority projects - run: | - just registry-projects-editor - if [ -n "$(git status --short -- projects)" ]; then - git status --short -- projects - exit 1 - fi - just registry-projects-check - just registry-projects-test - just registry-projects-capabilities - just registry-projects-runtime-check - - name: Generate local secrets - run: just gen-secrets - - name: Compose + - name: Validate and package all Relay V2 projects + run: just relay-check + - name: Validate Compose topology run: just compose - - name: Review - run: just review diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index b26a8fc..c84a616 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -3,15 +3,6 @@ name: release-candidate on: workflow_dispatch: inputs: - registry_relay_image: - description: Digest-pinned registry-relay image - required: true - registry_notary_image: - description: Digest-pinned registry-notary image - required: true - registry_stack_tag: - description: Registry Stack candidate or release tag matching the supplied image digests - required: true solmara_image_tag: description: Tag for Solmara-owned images. Defaults to the workflow commit SHA. required: false @@ -21,199 +12,398 @@ permissions: packages: write jobs: - smoke: + verify-and-publish: runs-on: ubuntu-latest + timeout-minutes: 60 env: - REGISTRY_RELAY_IMAGE: ${{ inputs.registry_relay_image }} - REGISTRY_NOTARY_IMAGE: ${{ inputs.registry_notary_image }} - REGISTRY_STACK_TAG: ${{ inputs.registry_stack_tag }} SOLMARA_IMAGE_REGISTRY: ghcr.io/registrystack SOLMARA_IMAGE_TAG: ${{ inputs.solmara_image_tag || github.sha }} - POSTGRES_IMAGE: postgres:16-alpine - ESIGNET_POSTGRES_IMAGE: postgres:16 - ESIGNET_BASE_IMAGE: mosipid/esignet-with-plugins:1.8.0 - ESIGNET_UI_IMAGE: mosipid/oidc-ui:1.8.0 - ESIGNET_RELAY_AUTHENTICATOR_REF: 2e7d0e452a778e048f2c259391397d1e1844318e - STATIC_METADATA_IMAGE: python:3.12-alpine steps: - - uses: actions/checkout@v4 - - name: Checkout Registry Stack source - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Read immutable Registry Stack source identity + id: registry-stack + run: | + set -euo pipefail + set -a + source versions.env + set +a + printf 'source_ref=%s\n' "$REGISTRY_STACK_SOURCE_REF" >> "$GITHUB_OUTPUT" + printf 'source_commit=%s\n' "$REGISTRY_STACK_SOURCE_COMMIT" >> "$GITHUB_OUTPUT" + for key in REGISTRY_STACK_REQUIRED_VERSION \ + REGISTRY_RELAY_IMAGE SOLMARA_EVIDENCE_IMAGE SOLMARA_MINT_IMAGE \ + ESIGNET_BASE_IMAGE ESIGNET_POSTGRES_IMAGE ESIGNET_UI_IMAGE \ + ESIGNET_AUTHENTICATOR_JAR_URL ESIGNET_AUTHENTICATOR_JAR_SHA256 \ + REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256; do + value="$(printenv "$key")" + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV" + done + - name: Require published Registry Stack and authenticator pins + run: scripts/check-registry-stack-release-pin.py --require-public + - name: Checkout the exact Registry Stack source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: registrystack/registry-stack - ref: ${{ env.REGISTRY_STACK_TAG }} + ref: ${{ steps.registry-stack.outputs.source_commit }} path: vendor/registry-stack - - name: Verify Registry Stack source + persist-credentials: false + - name: Verify official Registry Stack runtime images and Relayctl run: | set -euo pipefail set -a source versions.env set +a - test "$REGISTRY_STACK_SOURCE_REF" = "$REGISTRY_STACK_TAG" test "$(git -C vendor/registry-stack rev-parse HEAD)" = "$REGISTRY_STACK_SOURCE_COMMIT" - { - printf 'SOLMARA_RELAY_BUNDLE_SEQUENCE=%s\n' "$SOLMARA_RELAY_BUNDLE_SEQUENCE" - printf 'VOLUME_INIT_IMAGE=%s\n' "$VOLUME_INIT_IMAGE" - } >> "$GITHUB_ENV" - - name: Checkout eSignet relay authenticator - uses: actions/checkout@v4 - with: - repository: jeremi/esignet-relay-authenticator - ref: ${{ env.ESIGNET_RELAY_AUTHENTICATOR_REF }} - path: vendor/esignet-relay-authenticator - ssh-key: ${{ secrets.ESIGNET_RELAY_AUTHENTICATOR_SSH_KEY }} - - uses: astral-sh/setup-uv@v5 - - uses: actions/setup-node@v4 + for component in relay evidence mint; do + case "$component" in + relay) image="$REGISTRY_RELAY_IMAGE" ;; + evidence) image="$SOLMARA_EVIDENCE_IMAGE" ;; + mint) image="$SOLMARA_MINT_IMAGE" ;; + esac + expected_prefix="ghcr.io/registrystack/${component}@sha256:" + if ! printf '%s\n' "$image" | grep -Eq "^ghcr\\.io/registrystack/${component}@sha256:[0-9a-f]{64}$"; then + echo "$component must use ${expected_prefix}<64 lowercase hex>" >&2 + exit 1 + fi + docker pull "$image" + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "$REGISTRY_STACK_SOURCE_COMMIT" + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" = "$REGISTRY_STACK_REQUIRED_VERSION" + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.opencontainers.image.source" }}')" = https://github.com/registrystack/registry-stack + done + relayctl="$RUNNER_TEMP/relayctl-v${REGISTRY_STACK_REQUIRED_VERSION}-linux-amd64" + curl --fail --location --silent --show-error "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL" --output "$relayctl" + printf '%s %s\n' "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256" "$relayctl" | sha256sum --check --status + printf 'REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_FILE=%s\n' "$relayctl" >> "$GITHUB_ENV" + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4.4.0 with: node-version: "22" - - uses: docker/setup-buildx-action@v3 - - name: Install tools + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.11.1 + - name: Install just + env: + JUST_VERSION: 1.58.0 + JUST_SHA256: 4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d + run: | + set -euo pipefail + archive="$RUNNER_TEMP/just.tar.gz" + curl --fail --location --silent --show-error \ + --retry 5 --retry-all-errors --connect-timeout 30 --max-time 120 \ + --output "$archive" \ + "https://github.com/casey/just/releases/download/$JUST_VERSION/just-$JUST_VERSION-x86_64-unknown-linux-musl.tar.gz" + printf '%s %s\n' "$JUST_SHA256" "$archive" | sha256sum --check --status + mkdir -p "$RUNNER_TEMP/just-bin" + tar --extract --file "$archive" --directory "$RUNNER_TEMP/just-bin" just + printf '%s\n' "$RUNNER_TEMP/just-bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/just-bin/just" --version + - name: Install dependencies run: | corepack enable - sudo apt-get update - sudo apt-get install -y just - - name: Setup - run: just setup - - name: Install Playwright browser + just setup + - name: Install browser test dependencies run: | cd portal && pnpm exec playwright install --with-deps chromium cd ../home && pnpm exec playwright install chromium - - name: Generate fixtures and secrets + - name: Generate and validate authority-cell runtime + env: + REGISTRY_STACK_SOURCE_DIR: ${{ github.workspace }}/vendor/registry-stack run: | - just generate + just prepare just metadata-publish - - name: Validate - run: | just lint just test - just registry-projects-editor - if [ -n "$(git status --short -- projects)" ]; then - git status --short -- projects - exit 1 - fi - just registry-projects-check - just registry-projects-test - just registry-projects-capabilities - just registry-projects-runtime-check just compose - just review-release "$REGISTRY_STACK_TAG" - - name: Prove SRO contract generation transition - run: just contract-generation-proof - - name: Start stack - run: just up - - name: Smoke and PostgreSQL restart persistence - run: just smoke - - name: Portal live e2e - run: just portal-live-e2e - - name: Visitor center e2e - run: just home-live-e2e - - name: Login to GHCR - uses: docker/login-action@v3 + just relay-check + - name: Start and smoke authority cells + env: + REGISTRY_STACK_SOURCE_DIR: ${{ github.workspace }}/vendor/registry-stack + run: | + just up + just smoke + just evidence-check + just programme-acceptance + just lifecycle-proof + just portal-live-e2e + just home-live-e2e + - name: Report authority-cell startup failure + if: failure() + run: | + project_name="$(python3 scripts/compose_project_name.py)" + docker compose --env-file versions.env --env-file .env --project-name "$project_name" -f compose.yaml ps --all + docker compose --env-file versions.env --env-file .env --project-name "$project_name" -f compose.yaml logs --no-color \ + authority-audit-init mint evidence-gateway relay-issuer-readiness \ + cra-relay nia-relay mosd-relay sipf-relay nagdi-relay + - name: Record recoverable rollout phases + run: | + just rollout side-by-side + just rollout switch + just rollout disable + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v3.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push hosted Relay image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/relay/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-relay:${{ env.SOLMARA_IMAGE_TAG }} - build-args: | - REGISTRY_RELAY_IMAGE=${{ env.REGISTRY_RELAY_IMAGE }} - VOLUME_INIT_IMAGE=${{ env.VOLUME_INIT_IMAGE }} - - name: Verify hosted Relay signed bundles + - name: Require pre-provisioned public Solmara packages env: - SOLMARA_HOSTED_RELAY_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-relay:${{ env.SOLMARA_IMAGE_TAG }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - for project in cra-civil nia-population sro-social mosd-programme sipf-pensions nagdi-agriculture; do - for bundle in "$project" "$project/consultation"; do - docker run --rm --platform linux/amd64 \ - -e REGISTRY_RELAY_AUDIT_HASH_SECRET=0123456789abcdef0123456789abcdef \ - -e REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1=abcdef0123456789abcdef0123456789 \ - -e REGISTRY_RELAY_CONSULTATION_DATABASE_URL=postgresql://runtime:placeholder@postgres:5432/consultation?sslmode=require \ - -e SOLMARA_NIA_DATABASE_URL=postgresql://reader:placeholder@postgres:5432/source?sslmode=require \ - -e SOLMARA_SIPF_DATABASE_URL=postgresql://reader:placeholder@postgres:5432/source?sslmode=require \ - "$SOLMARA_HOSTED_RELAY_IMAGE" \ - config verify-bundle \ - --bundle-dir "/etc/solmara/hosted-relay-bundles/$bundle/bundle" \ - --anchor-path "/etc/solmara/hosted-relay-bundles/$bundle/trust-anchor.json" \ - --state-path "/etc/solmara/hosted-relay-bundles/$bundle/antirollback-seed.json" \ - | jq -e --argjson expected "$SOLMARA_RELAY_BUNDLE_SEQUENCE" \ - '.result == "verified" and .bundle_sequence == $expected' >/dev/null - done + for package in \ + solmara-lab-authority-provisioner \ + solmara-lab-transit-signer; do + visibility="$(gh api "/orgs/registrystack/packages/container/${package}" --jq .visibility)" + if [ "$visibility" != public ]; then + echo "$package must be pre-provisioned as a public package with Actions write access" >&2 + exit 1 + fi done - - name: Build and push hosted Notary image - uses: docker/build-push-action@v6 + - name: Build and push authority provisioner + id: authority_provisioner + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . - file: docker/notary/Dockerfile + file: docker/hosted-authority-provisioner/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-notary:${{ env.SOLMARA_IMAGE_TAG }} + tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-authority-provisioner:${{ env.SOLMARA_IMAGE_TAG }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ env.SOLMARA_IMAGE_TAG }} + org.opencontainers.image.source=https://github.com/registrystack/solmara-lab + org.registrystack.release.version=${{ env.REGISTRY_STACK_REQUIRED_VERSION }} + org.registrystack.release.revision=${{ steps.registry-stack.outputs.source_commit }} build-args: | - REGISTRY_NOTARY_IMAGE=${{ env.REGISTRY_NOTARY_IMAGE }} - - name: Build and push hosted Postgres image - uses: docker/build-push-action@v6 + REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL=${{ env.REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL }} + REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256=${{ env.REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256 }} + - name: Build and push Transit signer + id: transit_signer + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . - file: docker/postgres/Dockerfile + file: docker/hosted-transit-signer/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-postgres:${{ env.SOLMARA_IMAGE_TAG }} - build-args: | - POSTGRES_IMAGE=${{ env.POSTGRES_IMAGE }} - - name: Build and push hosted static metadata image - uses: docker/build-push-action@v6 + tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-transit-signer:${{ env.SOLMARA_IMAGE_TAG }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ env.SOLMARA_IMAGE_TAG }} + org.opencontainers.image.source=https://github.com/registrystack/solmara-lab + org.registrystack.release.version=${{ env.REGISTRY_STACK_REQUIRED_VERSION }} + org.registrystack.release.revision=${{ steps.registry-stack.outputs.source_commit }} + - name: Verify hosted authority image source labels + run: | + set -euo pipefail + for component in authority-provisioner transit-signer; do + case "$component" in + authority-provisioner) digest='${{ steps.authority_provisioner.outputs.digest }}' ;; + transit-signer) digest='${{ steps.transit_signer.outputs.digest }}' ;; + esac + image="$SOLMARA_IMAGE_REGISTRY/solmara-lab-${component}@${digest}" + docker pull "$image" + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = '${{ github.sha }}' + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.opencontainers.image.source" }}')" = https://github.com/registrystack/solmara-lab + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.registrystack.release.version" }}')" = "$REGISTRY_STACK_REQUIRED_VERSION" + test "$(docker image inspect "$image" --format '{{ index .Config.Labels "org.registrystack.release.revision" }}')" = '${{ steps.registry-stack.outputs.source_commit }}' + done + - name: Smoke hosted authority provisioner image + run: | + set -euo pipefail + image="$SOLMARA_IMAGE_REGISTRY/solmara-lab-authority-provisioner@${{ steps.authority_provisioner.outputs.digest }}" + state="$RUNNER_TEMP/solmara-authority-provisioner-state" + mkdir -p "$state/runtime" "$state/source" + for _ in 1 2; do + docker run --rm --platform linux/amd64 --network none --read-only --tmpfs /tmp \ + --user 0:0 --cap-drop ALL \ + --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \ + --mount "type=bind,source=$state/runtime,target=/provisioned/runtime" \ + --mount "type=bind,source=$state/source,target=/provisioned/source" \ + "$image" provision \ + --target cra-relay \ + --assets /opt/solmara-hosted-assets \ + --runtime-output /provisioned/runtime \ + --source-output /provisioned/source \ + --mint-origin https://mint-authority-cells.solmara.registrystack.org + done + python3 - "$state" <<'PY' + import sqlite3 + import sys + from pathlib import Path + root = Path(sys.argv[1]) + with sqlite3.connect(f"file:{root / 'source/cra.sqlite'}?mode=ro", uri=True) as connection: + assert connection.execute("PRAGMA quick_check").fetchall() == [("ok",)] + assert (root / "runtime/runtime.yaml").is_file() + assert any((root / "runtime/package").iterdir()) + PY + docker run --rm --platform linux/amd64 --network none --read-only --tmpfs /tmp \ + --user 0:0 --cap-drop ALL --cap-add DAC_OVERRIDE --cap-add FOWNER \ + --entrypoint python \ + --mount "type=bind,source=$state/runtime,target=/provisioned/runtime" \ + "$image" -c \ + "from pathlib import Path; p=Path('/provisioned/runtime/runtime.yaml'); p.chmod(0o644); p.open('a', encoding='utf-8').write('\\n')" + stdout="$RUNNER_TEMP/solmara-authority-provisioner.stdout" + stderr="$RUNNER_TEMP/solmara-authority-provisioner.stderr" + set +e + docker run --rm --platform linux/amd64 --network none --read-only --tmpfs /tmp \ + --user 0:0 --cap-drop ALL \ + --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \ + --mount "type=bind,source=$state/runtime,target=/provisioned/runtime" \ + --mount "type=bind,source=$state/source,target=/provisioned/source" \ + "$image" provision \ + --target cra-relay \ + --assets /opt/solmara-hosted-assets \ + --runtime-output /provisioned/runtime \ + --source-output /provisioned/source \ + --mint-origin https://mint-authority-cells.solmara.registrystack.org \ + >"$stdout" 2>"$stderr" + status=$? + set -e + test "$status" -eq 1 + test ! -s "$stdout" + test "$(cat "$stderr")" = "hosted target provisioning failed" + uv run scripts/smoke-hosted-provisioner-image.py \ + --image "$image" \ + --state-root "$RUNNER_TEMP/solmara-authority-provisioner-authority-state" + - name: Smoke hosted Transit signer image + run: | + set -euo pipefail + image="$SOLMARA_IMAGE_REGISTRY/solmara-lab-transit-signer@${{ steps.transit_signer.outputs.digest }}" + docker run --rm --network none --read-only --entrypoint python "$image" -c 'import cryptography' + export SIGNER_IMAGE="$image" + export SIGNING_PRIVATE_JWK + SIGNING_PRIVATE_JWK="$(cat config/evidence/local/cells/cra/secrets/signing.jwk)" + export SIGNING_PUBLIC_JWK + SIGNING_PUBLIC_JWK="$(cat runtime/evidence-cells/cells/cra/bundle/public-keys/*.jwk.json)" + project="solmara-transit-signer-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + compose="$RUNNER_TEMP/solmara-transit-signer.compose.yaml" + cat >"$compose" <<'YAML' + services: + transit-init: + image: ${SIGNER_IMAGE:?required} + entrypoint: [/usr/local/bin/python, -c] + command: ["import os; os.chown('/transit', 65532, 65532); os.chmod('/transit', 0o700)"] + restart: "no" + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + volumes: [transit:/transit] + signer: + image: ${SIGNER_IMAGE:?required} + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-cra] + restart: "no" + user: "65532:65532" + read_only: false + network_mode: none + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + volumes: [transit:/transit] + secrets: + - {source: signing-private, target: /tmp/solmara-signing.jwk, uid: "65532", gid: "65532", mode: 0400} + - {source: signing-public, target: /tmp/solmara-signing-public.jwk, uid: "65532", gid: "65532", mode: 0400} + healthcheck: + test: [CMD, /usr/local/bin/python, -c, "import socket; s=socket.socket(socket.AF_UNIX); s.connect('/transit/transit-proxy.sock'); s.close()"] + interval: 1s + timeout: 1s + retries: 30 + start_period: 1s + depends_on: + transit-init: {condition: service_completed_successfully} + secrets: + signing-private: {environment: SIGNING_PRIVATE_JWK} + signing-public: {environment: SIGNING_PUBLIC_JWK} + volumes: + transit: {} + YAML + chmod 0600 "$compose" + cleanup() { + docker compose -p "$project" -f "$compose" down -v >/dev/null 2>&1 || true + unset SIGNING_PRIVATE_JWK SIGNING_PUBLIC_JWK + } + trap cleanup EXIT + docker compose -p "$project" -f "$compose" up -d --wait --wait-timeout 60 + docker compose -p "$project" -f "$compose" exec -T signer python -c ' + import glob + import os + from pathlib import Path + assert os.geteuid() == 65532 + assert not Path("/tmp/solmara-signing.jwk").exists() + assert not Path("/tmp/solmara-signing-public.jwk").exists() + assert not glob.glob("/tmp/solmara-transit-*") + assert Path("/transit/transit-proxy.sock").is_socket() + ' + docker compose -p "$project" -f "$compose" exec -T signer python -c ' + import socket + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(5) + client.connect("/transit/transit-proxy.sock") + client.sendall(b"GET /v1/transit/keys/solmara-evidence-cra HTTP/1.1\r\nHost: localhost\r\nX-Vault-Request: true\r\nConnection: close\r\n\r\n") + response = bytearray() + while True: + chunk = client.recv(65536) + if not chunk: + break + response.extend(chunk) + client.close() + raise SystemExit(0 if response.startswith(b"HTTP/1.1 200 ") else 1) + ' + cleanup + trap - EXIT + - name: Build and push static metadata + id: static_metadata + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/static-metadata/Dockerfile platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-static-metadata:${{ env.SOLMARA_IMAGE_TAG }} - build-args: | - STATIC_METADATA_IMAGE=${{ env.STATIC_METADATA_IMAGE }} - - name: Build and push scenario runner image - uses: docker/build-push-action@v6 + - name: Build and push scenario runner + id: scenario_runner + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: scenario-runner/Dockerfile platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-scenario-runner:${{ env.SOLMARA_IMAGE_TAG }} - - name: Build and push Visitor Center image - uses: docker/build-push-action@v6 + - name: Build and push Visitor Center + id: home + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: home/Dockerfile platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-home:${{ env.SOLMARA_IMAGE_TAG }} - - name: Build and push portal image - uses: docker/build-push-action@v6 + - name: Build and push programme portal + id: portal + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: ./portal file: ./portal/Dockerfile platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-portal:${{ env.SOLMARA_IMAGE_TAG }} - - name: Build and push eSignet relay image - uses: docker/build-push-action@v6 + - name: Build and push eSignet Relay V2 authenticator image + id: esignet_relay + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/esignet-relay/Dockerfile - build-contexts: | - esignet_relay_authenticator_src=./vendor/esignet-relay-authenticator platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-relay:${{ env.SOLMARA_IMAGE_TAG }} build-args: | ESIGNET_BASE_IMAGE=${{ env.ESIGNET_BASE_IMAGE }} - - name: Build and push eSignet Postgres image - uses: docker/build-push-action@v6 + ESIGNET_AUTHENTICATOR_JAR_URL=${{ env.ESIGNET_AUTHENTICATOR_JAR_URL }} + ESIGNET_AUTHENTICATOR_JAR_SHA256=${{ env.ESIGNET_AUTHENTICATOR_JAR_SHA256 }} + - name: Build and push isolated eSignet database + id: esignet_postgres + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/esignet-postgres/Dockerfile @@ -222,8 +412,9 @@ jobs: tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-postgres:${{ env.SOLMARA_IMAGE_TAG }} build-args: | POSTGRES_IMAGE=${{ env.ESIGNET_POSTGRES_IMAGE }} - - name: Build and push eSignet UI image - uses: docker/build-push-action@v6 + - name: Build and push eSignet UI + id: esignet_ui + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/esignet-ui/Dockerfile @@ -234,27 +425,50 @@ jobs: ESIGNET_UI_IMAGE=${{ env.ESIGNET_UI_IMAGE }} ESIGNET_NGINX_CONF=config/esignet/nginx-hosted.conf - name: Build and push eSignet seed image - uses: docker/build-push-action@v6 + id: esignet_seed + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/esignet-seed/Dockerfile platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-seed:${{ env.SOLMARA_IMAGE_TAG }} - - name: Report image digests + - name: Generate Coolify image manifest + env: + REGISTRY_RELAY_IMAGE: ${{ env.REGISTRY_RELAY_IMAGE }} + SOLMARA_EVIDENCE_IMAGE: ${{ env.SOLMARA_EVIDENCE_IMAGE }} + SOLMARA_MINT_IMAGE: ${{ env.SOLMARA_MINT_IMAGE }} + SOLMARA_AUTHORITY_PROVISIONER_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-authority-provisioner@${{ steps.authority_provisioner.outputs.digest }} + SOLMARA_TRANSIT_SIGNER_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-transit-signer@${{ steps.transit_signer.outputs.digest }} + SOLMARA_STATIC_METADATA_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-static-metadata@${{ steps.static_metadata.outputs.digest }} + SOLMARA_SCENARIO_RUNNER_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-scenario-runner@${{ steps.scenario_runner.outputs.digest }} + SOLMARA_HOME_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-home@${{ steps.home.outputs.digest }} + SOLMARA_PORTAL_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-portal@${{ steps.portal.outputs.digest }} + SOLMARA_ESIGNET_RELAY_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-relay@${{ steps.esignet_relay.outputs.digest }} + SOLMARA_ESIGNET_POSTGRES_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-postgres@${{ steps.esignet_postgres.outputs.digest }} + SOLMARA_ESIGNET_UI_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-ui@${{ steps.esignet_ui.outputs.digest }} + SOLMARA_ESIGNET_SEED_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-seed@${{ steps.esignet_seed.outputs.digest }} + run: | + set -euo pipefail + manifest="$RUNNER_TEMP/solmara-release/solmara-hosted-images.env" + python3 scripts/hosted-image-manifest.py write --output "$manifest" + python3 scripts/hosted-image-manifest.py validate --manifest "$manifest" + - name: Upload Coolify image manifest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: solmara-hosted-images-${{ github.sha }} + path: ${{ runner.temp }}/solmara-release/solmara-hosted-images.env + if-no-files-found: error + retention-days: 30 + - name: Report immutable Solmara image digests run: | { echo "### Solmara image digests" echo - for image in solmara-lab-relay solmara-lab-notary solmara-lab-postgres solmara-lab-static-metadata solmara-lab-scenario-runner solmara-lab-home solmara-lab-portal solmara-lab-esignet-relay solmara-lab-esignet-postgres solmara-lab-esignet-ui solmara-lab-esignet-seed; do - ref="${SOLMARA_IMAGE_REGISTRY}/${image}:${SOLMARA_IMAGE_TAG}" - digest="$(docker buildx imagetools inspect "$ref" --format '{{.Manifest.Digest}}')" - echo "- ${SOLMARA_IMAGE_REGISTRY}/${image}@${digest}" - done - } | tee -a "$GITHUB_STEP_SUMMARY" - - name: Stop stack + while IFS='=' read -r key image; do + printf -- '- %s=%s\n' "$key" "$image" + done < "$RUNNER_TEMP/solmara-release/solmara-hosted-images.env" + } >> "$GITHUB_STEP_SUMMARY" + - name: Stop authority-cell stack if: always() - run: | - if command -v just >/dev/null 2>&1; then - just down - fi + run: just down || true diff --git a/.gitignore b/.gitignore index 264ab1e..b96da6a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ output/* !output/smoke/ output/smoke/* !output/smoke/.gitkeep +relays/*/package/ +relays/*/.package.*/ +runtime/evidence-cells/ +runtime/local-transit-signers/ static-metadata/* !static-metadata/.gitkeep node_modules/ @@ -27,3 +31,5 @@ projects/*/.registry-stack/ *.log config/postgres/ssl/* !config/postgres/ssl/.gitkeep +config/evidence/local/* +!config/evidence/local/.gitkeep diff --git a/README.md b/README.md index 5ad6d1d..89c5d21 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,151 @@ # Solmara Lab -Solmara Lab is a standalone Registry Stack adopter demo for the fictional -Republic of Solmara. It replaces the old monorepo lab with one coherent country -story, generated synthetic data, purpose-limited Relay APIs, Notary evidence -services, and a citizen portal wired to the real local stack. +Solmara Lab is a fictional Registry Stack adopter lab for authority-owned, +independently signed evidence. The programme application composes assertions. +Registry Evidence does not make a cross-authority programme decision. -The lab uses published Registry Stack images pinned by digest. A local -`registry-stack` checkout is useful for development, but it is not required for -normal generation, live smoke tests, portal e2e, or hosted deployment. +The reset has two evidence cadences: -## Quick Start +- CRA birth, NIA population, and SRO poverty requirements read checkpointed, + immutable SQLite extracts. +- CRA civil links and death status, MoSD enrolment, SIPF pension and survivor + cases, and NAgDI agriculture requirements use named Relay V2 exact lookups. -From this repository: +Six Evidence cells have distinct providers, issuers, signing keys, JWKS, audit +sinks, subject-binding secrets, and endpoints. Five Relays expose only the +named non-enumerating operations needed by the lab. NIA's Relay is reserved for +the optional eSignet UserInfo profile; NIA Evidence reads its own extract. + +## Release prerequisite + +Registry Stack v0.19.0 cannot run this topology. Its Mint cannot issue the +Relay-compatible scope and purpose claims required by Relay V2, and its +Evidence runtime cannot classify Relay's concealed `consultation.unresolved` +response as a data-free unresolved consultation. Registry Stack v0.21.0 is the +first coherent release that contains both capabilities and publishes official +Relay, Evidence, and Mint runtime images. Solmara tracks that release line and +currently pins v0.22.0, recording its release tag and source commit, those +official OCI references by digest, and the `relayctl` binary checksum in +`versions.env`. The release handoff and every hosted deployment consume those +same full references without reconstructing them from a second deployment +input. + +The eSignet profile uses the separately released +`esignet-relay-authenticator` v0.2.0 JAR and its matching SHA-256 checksum. No +source-build, locally wrapped runtime, floating-tag, or v0.19 compatibility +fallback is accepted. + +## Quick start + +Prerequisites are Docker with Compose, `just`, `uv`, and `pnpm`. ```bash just setup -just up-generated +just generate +just relay-check +just compose +just up +just evidence-check just smoke -just portal-live-e2e -just down +just programme-acceptance +just lifecycle-proof +``` + +`just generate` publishes five mutable Relay databases and three versioned +immutable extracts below ignored `output/sqlite/`. It also creates ignored +runtime secrets and public projections. An active extract is never overwritten +in place. + +Local entry points after startup are: + +- Visitor Center: `http://127.0.0.1:4301` +- Programme portal: `http://127.0.0.1:4300` +- Authority Evidence gateway: `https://localhost:4341/evidence/{authority}` +- Static metadata: `http://127.0.0.1:4331` + +The gateway strips `/evidence/{authority}` before forwarding. Application code +uses six authority base URLs and never assumes a national Evidence host. + +## Architecture + +| Authority | Evidence source | Relay V2 role | +|---|---|---| +| CRA | immutable birth extract; Relay for death and civil link | `civil-person/death-by-uin`, `civil-person/citizen-link-by-uin` | +| NIA | immutable population extract | `population-person/esignet-userinfo` for eSignet | +| SRO | immutable poverty extract | none | +| MoSD | Relay lookup | `beneficiary-enrolment/by-uin` | +| SIPF | Relay lookups | `pension-payment/by-pensioner-uin`, `survivor-case/by-spouse-uin` | +| NAgDI | Relay lookups | `farmer/voucher-by-farmer-id`, `livestock-herd/movement-by-farmer-id` | + +One lab Mint issues tokens for the common `solmara-runtime` audience. Every +operation still has a distinct client, fixed scope, canonical purpose claim, +access profile, and disclosure profile. This shared audience is a lab +convenience, not production tenancy guidance. + +Direct extracts have a maximum age of 86,400 seconds and assertions valid for +at most 3,600 seconds. Relay-backed assertions are valid for at most 300 +seconds. Every response is a flattened ES256 JWS and is verified against the +JWKS of the authority endpoint that issued it. + +## Publisher lifecycle + +From `generator/`: + +```bash +uv run python -m solmara_lab.publisher --root .. publish-all +uv run python -m solmara_lab.publisher --root .. mutate-mosd \ + --uin 2300010248 --duplicate-flag true \ + --recorded-at 2026-08-12T12:00:00Z +uv run python -m solmara_lab.publisher --root .. publish-extract \ + --authority sro --published-at 2026-08-12T12:00:00Z \ + --extract-id sro-poverty-20260812T120000Z ``` -`just up`, `just down`, and `just reset` use a checkout-specific Docker Compose -project name by default so two local clones do not share containers or volumes. -Use `just down` to stop services while keeping local data. Use `just reset` only -when you intend to delete this checkout's Compose volumes. - -`up-generated` is the single clean-checkout generate/start journey. It creates -the synthetic fixtures and local secrets, regenerates every authority's Relay -and Notary closure with the real `registryctl` version pinned in `versions.env`, -compares those closures with the committed runtime, and starts the topology. -If the exact tool version is not installed, the helper downloads the matching -release binary and verifies it against the release SHA-256 file. - -`registry-projects-runtime-check` can run the compiler comparison without -starting services. The project wrapper consumes Registryctl's versioned JSON -build report and validates its project-owned output root rather than depending -on Registryctl's private build-directory layout. Its fixture gate also -consumes the versioned test report and requires independently authored -request-to-consultation evidence for every reachable target. Integration-only -fixtures are not accepted as caller compatibility proof. -`contract-generation-proof` is a separate release gate for one bounded SRO -authority pair. It compiles a harmless successor, proves the blue pair works, -rejects a mixed Relay/Notary generation before Relay execution or source -dispatch, activates the complete successor, and proves it works. Its temporary -Compose project and volumes are removed when the check finishes. - -The first wave covers three journeys: - -- Birth to child benefit. -- Death to pension stop plus survivor benefit. -- Farmer climate-smart voucher and livestock movement control. - -## Repository Map - -- `docs/` contains the Solmara world bible, purpose catalogue, naming record, - and story docs. -- `generator/` owns deterministic truth tables, per-registry projections, and - generated fixture checks. -- `geo/` contains the hand-authored Solmara geometry source used by the - generator. -- `ministries/` contains authority-owned source fixtures, manifest fragments, - and crosswalks. -- `projects/` contains the six authority-owned Registry project sources. Each - project generates one Relay config and one Notary config under `runtime/`. -- `metadata/` assembles the multi-authority metadata publication. -- `portal/` contains the citizen portal and BFF. -- `scenarios/`, `requests/`, and `perf/` carry guided scenarios, API examples, - and k6 smoke coverage. -- `scripts/` contains root quality gates and orchestration helpers. - -## Development Commands +The live MoSD mutation is visible on the next Relay-backed Evidence request +without a restart. A newly named SRO extract is not visible until only the SRO +Evidence cell is rebound and restarted. Invalid, stale, or overwritten extracts +fail closed. Run lifecycle smoke in isolated volumes or restore the deterministic +fixture state afterward. + +## Verification ```bash -just lint # static repo checks, including fiction lint -just test # generator, portal, and script tests when their projects exist -just compose # docker compose config validation -just smoke # story previews plus live Relay, Notary, and Compose portal checks -just smoke-live # live Notary checks only -just portal-compose-smoke # HTTP smoke against the Compose portal and live BFF -just portal-live-e2e # browser e2e against the running local stack -just hosted-smoke # public hosted health, endpoint, scenario, and portal checks -just up # local stack with mock portal login -just up-esignet # local stack with eSignet-backed portal login -just up-dev # explicit source-built Relay development stack -just up-esignet-dev # source-built Relay development stack with eSignet -just smoke-esignet # eSignet public discovery smoke -just down # stop the local Compose topology without deleting volumes -just reset # stop the local Compose topology and delete its volumes -just up-generated # clean-checkout generation, compiler comparison, and start -just registry-projects-runtime-check # regenerate and compare all authority runtime closures -just hosted-relay-bundles-check # verify hosted Relay signatures and config closure -just registry-projects-review # complete redacted acquisition and disclosure reports -just registry-projects-capabilities # value-free installed/used/missing capability inventory -just registry-projects-editor # version-matched VS Code and Zed schemas for all projects -just contract-generation-proof # release-only live SRO blue/mixed/successor proof -just release-pins # compare committed versions.env pins against a candidate or release tag -just review # normal security and release-readiness checks -just review-release # candidate review with published pin validation +just relay-check # relayctl check --production, generate, test, package +just evidence-check # six bundles, 11 requirements, source/error fixtures +just lint # metadata, redaction, portal and Visitor Center checks +just test # Python and web unit/integration suites +just compose # local, hosted, Coolify, and optional eSignet config +just smoke # local UI and Relay health +just programme-acceptance # programme stories and generic denial controls +just lifecycle-proof # live Relay and immutable-extract cadence +just portal-live-e2e +just home-live-e2e +just smoke-esignet # optional NIA Relay V2 login profile ``` -Normal startup pulls the immutable canonical Relay image pinned in -`versions.env`; it does not clone or compile Registry Stack. Governed attribute -release is part of the canonical Registry Stack v0.15.2 Relay build. The -`*-dev` recipes are the explicit source-build path. They verify the pinned -source commit and build the same default feature set into a separate local -image, leaving the standalone path unchanged. - -`just generate` rewrites generated fixtures. Review those diffs like any other -committed generated artifact. - -Each authority project commits Registryctl-generated schemas plus VS Code and -Zed mappings under its own project directory. Open that authority directory as -the editor workspace to get validation and completion for its project, -environment, integration, fixture, and entity YAML. Refresh all six with -`just registry-projects-editor` only after updating the pinned Registryctl -release. CI reruns the generator and fails on drift, so the editor contract -cannot silently move to a different Registry Stack version. - -## Image Pins - -`versions.env` is the root source for the published Registry Stack image -digests and the exact source ref and commit used for release binding and the -explicit Relay development build. The Registry Stack `v0.15.2` Relay and -Notary images are both consumed directly by digest. Solmara does not publish -or select a feature-specific Relay runtime. - -Use `just up` rather than invoking `docker compose up` directly so the -checkout-specific Compose project name and complete env-file set are applied. -Because the release publishes amd64 images, Compose defaults -`REGISTRY_STACK_PLATFORM` to `linux/amd64`; override it only when every -selected base image is available for another platform. - -Every authority exposes one public Relay and one Notary. A separate private -consultation Relay shares only the Notary network namespace and is never -published on the Relay endpoint. Relay consultation state and all Notary -correctness state are PostgreSQL-backed. `just gen-secrets` creates local -PostgreSQL TLS material and distinct runtime and migrator passwords for each -authority. See -[`docs/notary-postgresql-state.md`](docs/notary-postgresql-state.md) for the -database map, diagnosis, backup, recovery, and upgrade workflow. - -Local public and consultation Relay namespaces each have their own -loopback-only workload issuer. The consultation issuer writes the Notary token -to a private, authority-specific volume; duplicating the issuer process avoids -opening either Relay's loopback JWKS listener onto the shared Compose network. - -The `REGISTRY_RELAY_STATE_EPOCH=v015` pin starts a fresh Relay state plane for -the v0.15.2 cache-persistence cutover. Earlier hosted deployments persisted -PostgreSQL publication pointers but not the immutable snapshot files they -referenced, so they cannot safely reuse the `v013` databases after adopting -durable Relay cache volumes. Keep the old `v013` databases quiesced for -rollback. The PostgreSQL runbook describes the stopped-writer and rollback -boundary. - -## Hosted Deployment - -See [`docs/hosted-deployment.md`](docs/hosted-deployment.md) for the full -runbook. Coolify uses one hosted Compose file for the lab edge plus four -ministry-grouped authority applications: - -- `compose.coolify.yaml` for the Visitor Center, portal, scenario runner, - child-benefit evidence composition, and static metadata. -- `compose.coolify.interior.yaml` for the CRA and NIA Relay and Notary pairs - and their PostgreSQL databases. -- `compose.coolify.esignet.yaml` for eSignet, eSignet UI, and its backing - Postgres/Redis/seed services. -- `compose.coolify.social-development.yaml` for the SRO and Programme Relay - and Notary pairs and their PostgreSQL databases. -- `compose.coolify.labour-pensions.yaml` for the SIPF Relay and Notary pair and - its PostgreSQL databases. -- `compose.coolify.agriculture.yaml` for the NAgDI Relay and Notary pair and - its PostgreSQL databases. - -The hosted compose files remove host port bindings and avoid repo bind mounts -because Coolify does not seed bind-mount sources from the Git checkout. They do -not define custom Docker networks; cross-authority calls use the public -`*.solmara.registrystack.org` TLS endpoints. Authority compose files preserve -authority-owned PostgreSQL state, separate public and consultation Relay -caches, and workload credentials. Notary containers do not use Redis or a -writable state directory. - -Each hosted public Relay and private consultation Relay starts from its own -instance-bound signed Config Bundle and anti-rollback state. Only the -consultation bundle contains the private consultation artifacts. The wrapper -contains public trust anchors and signed closures only; the offline signing key -is not committed. A sequence-zero baseline is copied only when the matching -Relay state volume is empty, allowing first boot while keeping later bundle -sequence rollback protection durable in that volume. - -Hosted workload agents keep Relay bearer credentials short-lived and confined -to per-consumer volumes. The separately served workload JWKS contains public -keys only; private workload JWKs remain Coolify secrets. - -Run `just registry-projects-sync` after editing an authority project, then -`just registry-projects-runtime-check` to verify the local and hosted Relay and -Notary closures are deterministic. - -Run `just hosted-smoke` after each hosted deploy from a trusted shell with the -demo tokens available in `.env` or the process environment. It checks public -routes, Relay source endpoints, Notary scenario evaluations, published-token -refusals, the Visitor Center scenario proxy, and the portal live BFF. Add -`SOLMARA_HOSTED_SMOKE_BROWSER=1` when you also want hosted Playwright coverage -for the Visitor Center and portal. - -The `release-candidate` workflow verifies the pinned Registry Stack source and -uses the canonical published Relay digest as the base for the hosted Relay -wrapper. A Solmara candidate does not recompile Registry Stack. The workflow -builds the Solmara-owned images and writes their digest refs to the workflow -summary for Coolify env vars: -`SOLMARA_RELAY_IMAGE`, `SOLMARA_NOTARY_IMAGE`, `SOLMARA_POSTGRES_IMAGE`, -`SOLMARA_STATIC_METADATA_IMAGE`, `SOLMARA_HOME_IMAGE`, -`SOLMARA_PORTAL_IMAGE`, `SOLMARA_SCENARIO_RUNNER_IMAGE`, -`SOLMARA_ESIGNET_RELAY_IMAGE`, `SOLMARA_ESIGNET_POSTGRES_IMAGE`, -`SOLMARA_ESIGNET_UI_IMAGE`, and `SOLMARA_ESIGNET_SEED_IMAGE`. - -Its manually supplied Registry Stack tag is required and must resolve to the -same Relay and Notary digests committed in `versions.env`. Run the same -candidate-only gate locally with `just review-release `; -the normal contributor and CI gate remains `just review`. - -For local eSignet testing, run `just up-esignet` instead of `just up`, then -sign in through the portal with Elena's fixture UIN `2300018263` and static -OTP `111111`. This sign-in is the end-to-end check of the NIA -`solmara-nia-userinfo` attribute-release profile and its rotating eSignet -workload identity. Run `just smoke-esignet` for the public discovery checks. - -Set `UMAMI_WEBSITE_ID` in the hosted environment to enable analytics for the -Visitor Center through the Registry Stack Umami instance. - -## Privacy Rules - -Solmara data is synthetic. Do not use real people, real email domains, real -addresses, or real administrative geography. Use `@mail.solmara.example` for -emails and keep all story domains under `gov.solmara.example`. +Generated SQLite databases, Relay packages, runtime bundles, keys, secrets, +tokens, and private audit records are not tracked. Public metadata and governed +Relay/Evidence authoring inputs are tracked. + +## Repository map + +- `relays/` contains five authority-governed Relay V2 projects. +- `evidence/cells/` contains six authority Evidence bundle templates and + runtime bindings. +- `generator/solmara_lab/publisher.py` publishes deterministic SQLite sources. +- `scenarios/` and `scenario-runner/` route requirements to authority cells, + verify multiple JWKS, and compose application outcomes. +- `portal/` and `home/` display safe authority, issuer, and source-type labels. +- `metadata/` and `requests/registry-lab/` publish discovery and request + examples for the reset. + +## Hosted rollout and recovery + +Deploy the new V2 services and new volumes alongside the existing deployment. +Smoke the new endpoints before switching application and metadata routing. +After the switch, disable superseded services but retain their volumes. Deleting +old data is a separate, explicitly approved cleanup and is not part of this +reset. + +All Solmara data is synthetic. Never put real people, credentials, hosted +deployment evidence, private audit output, or private keys in this repository. diff --git a/compose.coolify.agriculture.yaml b/compose.coolify.agriculture.yaml index fad559a..3f1bd7f 100644 --- a/compose.coolify.agriculture.yaml +++ b/compose.coolify.agriculture.yaml @@ -1,235 +1,60 @@ -name: solmara-lab-agriculture +name: solmara-lab-agriculture-cells -x-relay-environment: &relay-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_RELAY_BIND: 0.0.0.0:8080 - -x-notary-environment: ¬ary-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_NOTARY_BIND: 0.0.0.0:8081 - REGISTRY_NOTARY_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - -x-relay-common: &relay-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: unless-stopped - environment: *relay-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-relay", "healthcheck"] - interval: 30s - timeout: 10s - retries: 3 - -x-relay-bootstrap-common: &relay-bootstrap-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - -x-relay-config-state-init: &relay-config-state-init - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "0:0" - restart: "no" - entrypoint: ["/bin/busybox", "sh", "-eu", "-c"] - -x-workload-agent-common: &workload-agent-common - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} - command: ["python", "scripts/relay_workload_identity_agent.py"] +x-runtime: &runtime restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)\""] - interval: 10s - timeout: 5s - retries: 6 - -x-notary-common: ¬ary-common - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "65534:65534" - restart: unless-stopped - environment: *notary-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-notary", "healthcheck", "--url", "http://127.0.0.1:8081/ready"] - interval: 30s - timeout: 10s - retries: 3 + read_only: true + tmpfs: [/tmp] + networks: [runtime] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + audit-permissions: {condition: service_completed_successfully} services: - postgres: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - entrypoint: ["/bin/sh", "/usr/local/bin/solmara-postgres-entrypoint.sh"] - command: - - postgres - - -c - - ssl=on - - -c - - ssl_cert_file=/var/lib/postgresql/server-ssl/server.crt - - -c - - ssl_key_file=/var/lib/postgresql/server-ssl/server.key - environment: &postgres-environment - POSTGRES_USER: ${SOLMARA_POSTGRES_USER:-solmara_registry} - POSTGRES_PASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - POSTGRES_DB: ${SOLMARA_POSTGRES_DB:-solmara_lab} - SOLMARA_SOURCE_READER_DATABASES: "" - REGISTRY_RELAY_STATE_EPOCH: ${REGISTRY_RELAY_STATE_EPOCH:-v015} - SOLMARA_RELAY_DATABASES: nagdi - SOLMARA_NOTARY_DATABASES: nagdi - NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD: ${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD} - NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 - - registry-postgresql-bootstrap: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - restart: "no" - entrypoint: ["/bin/sh", "/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh"] - environment: - <<: *postgres-environment - PGHOST: postgres - PGPASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - PGSSLMODE: require - depends_on: - postgres: - condition: service_healthy - - nagdi-workload-agent: - <<: *workload-agent-common - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"nagdi-notary","subject":"nagdi-notary","scopes":["registry:consult:livestock","registry:consult:voucher"],"token_file":"/run/secrets/nagdi-notary-relay-token","private_jwk_env":"NAGDI_RELAY_WORKLOAD_JWK"}] - NAGDI_RELAY_WORKLOAD_JWK: ${NAGDI_RELAY_WORKLOAD_JWK} - volumes: - - nagdi-workload-token:/run/secrets - - nagdi-relay-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/nagdi-agriculture/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - nagdi-relay-cache:/var/lib/registry-relay/cache - - nagdi-relay-consultation-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - nagdi-relay-consultation-cache:/var/lib/registry-relay/cache - - nagdi-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/bootstrap.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NAGDI_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NAGDI_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - nagdi-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - nagdi-relay-consultation-config-state-init: - condition: service_completed_successfully - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nagdi-agriculture-relay: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/nagdi-agriculture/bootstrap.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NAGDI_RELAY_AUDIT_HASH_SECRET} - expose: ["8080"] - volumes: - - nagdi-relay-cache:/var/lib/registry-relay/cache - depends_on: - nagdi-relay-config-state-init: - condition: service_completed_successfully - labels: - solmara.lab.host: nagdi-relay.solmara.registrystack.org - - nagdi-agriculture-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/bootstrap.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NAGDI_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NAGDI_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - expose: ["8081"] - volumes: - - nagdi-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - nagdi-relay-state-bootstrap: - condition: service_completed_successfully - nagdi-workload-agent: - condition: service_healthy - - nagdi-notary: - <<: *notary-common - command: ["--config", "/etc/solmara/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml"] - network_mode: service:nagdi-agriculture-relay-consultation - environment: &nagdi-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${NAGDI_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_nagdi_runtime:${NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_nagdi?sslmode=require" - NAGDI_CLIENT_TOKEN_HASH: ${NAGDI_CLIENT_TOKEN_HASH} - NAGDI_NOTARY_ISSUER_JWK: ${NAGDI_NOTARY_ISSUER_JWK} - volumes: - - nagdi-workload-token:/run/secrets:ro - depends_on: - nagdi-agriculture-relay-consultation: - condition: service_healthy - nagdi-notary-state-install: - condition: service_completed_successfully - nagdi-workload-agent: - condition: service_healthy - labels: - solmara.lab.host: nagdi-notary.solmara.registrystack.org - - nagdi-notary-state-install: - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} + audit-permissions: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + entrypoint: [python, /app/scripts/provision-hosted-runtime.py] + command: [init-audit, --destination, /audit/nagdi-relay, --destination, /audit/nagdi-evidence, --uid, "65532", --gid, "65532"] restart: "no" - command: ["--config", "/etc/solmara/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_nagdi_owner", "--runtime-role", "solmara_notary_nagdi_runtime"] - environment: - <<: *nagdi-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_nagdi_migrator:${NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_nagdi?sslmode=require" - volumes: - - nagdi-workload-token:/run/secrets:ro + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + volumes: [nagdi-relay-audit:/audit/nagdi-relay, nagdi-evidence-audit:/audit/nagdi-evidence] + + nagdi-relay: + <<: *runtime + image: ${REGISTRY_RELAY_IMAGE:?set the digest-pinned official Registry Stack Relay image} + command: [serve, --runtime, /etc/relay/nagdi/runtime.yaml] + environment: {SOLMARA_RELAY_AUDIT_KEY: "${NAGDI_RELAY_AUDIT_KEY:?required}", SOLMARA_RELAY_CURSOR_KEY: "${NAGDI_RELAY_CURSOR_KEY:?required}"} + volumes: [nagdi-relay-runtime:/etc/relay/nagdi:ro, nagdi-relay-source:/var/lib/relay/source:ro, nagdi-relay-audit:/var/lib/relay/audit] + labels: {solmara.lab.host: nagdi-relay-authority-cells.solmara.registrystack.org, solmara.rollout.slot: authority-cells} + + nagdi-evidence: + <<: *runtime + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + command: [--runtime, /etc/registry-evidence/nagdi/runtime.yaml, serve] + volumes: [nagdi-evidence-runtime:/etc/registry-evidence/nagdi:ro, nagdi-evidence-secrets:/run/secrets/registry-evidence/nagdi:ro, nagdi-evidence-transit:/run/registry-evidence/nagdi:ro, nagdi-evidence-audit:/var/lib/registry-evidence/nagdi/audit] + networks: {runtime: {ipv4_address: 172.29.5.26}} depends_on: - nagdi-workload-agent: - condition: service_healthy - registry-postgresql-bootstrap: - condition: service_completed_successfully + audit-permissions: {condition: service_completed_successfully} + nagdi-relay: {condition: service_started} + labels: {solmara.lab.host: "${SOLMARA_NAGDI_EVIDENCE_PUBLIC_HOST:-nagdi-evidence.solmara.registrystack.org}", solmara.rollout.slot: authority-cells} volumes: - postgres-data: - nagdi-relay-cache: - nagdi-relay-consultation-cache: - nagdi-workload-token: + nagdi-relay-runtime: {external: true, name: solmara-authority-cells-nagdi-relay-runtime} + nagdi-relay-source: {external: true, name: solmara-authority-cells-nagdi-relay-source} + nagdi-relay-audit: + nagdi-evidence-runtime: {external: true, name: solmara-authority-cells-nagdi-evidence-runtime} + nagdi-evidence-secrets: {external: true, name: solmara-authority-cells-nagdi-evidence-secrets} + nagdi-evidence-transit: {external: true, name: solmara-authority-cells-nagdi-evidence-transit} + nagdi-evidence-audit: + superseded-state: {external: true, name: "${SOLMARA_SUPERSEDED_AGRICULTURE_VOLUME:-solmara-lab-agriculture-state}"} + +networks: + runtime: + ipam: + config: [{subnet: 172.29.5.0/24}] diff --git a/compose.coolify.esignet.yaml b/compose.coolify.esignet.yaml index ac5a630..aae556c 100644 --- a/compose.coolify.esignet.yaml +++ b/compose.coolify.esignet.yaml @@ -1,52 +1,32 @@ -name: solmara-lab-esignet +name: solmara-esignet-authority-cells services: esignet-database: - image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:?set digest-pinned Solmara eSignet Postgres image} + image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:?set digest-pinned eSignet database image} environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD} - volumes: - - esignet-pgdata:/var/lib/postgresql/data + POSTGRES_USER: esignet + POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} + volumes: [esignet-db-data:/var/lib/postgresql] healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + test: [CMD-SHELL, pg_isready -U esignet -d esignet] interval: 5s timeout: 5s retries: 30 esignet-redis: - image: ${ESIGNET_REDIS_IMAGE:-redis:6.0} - command: ["redis-server"] - restart: unless-stopped - volumes: - - esignet-redis-data:/data + image: ${ESIGNET_REDIS_IMAGE:?versions.env must pin Redis} + command: [redis-server] + volumes: [esignet-redis-data:/data] healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [CMD, redis-cli, ping] interval: 10s timeout: 5s retries: 30 - nia-esignet-workload-agent: - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} - command: ["python", "scripts/relay_workload_identity_agent.py"] - restart: unless-stopped - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"solmara-esignet","subject":"solmara-esignet","scopes":["population:identity_release"],"token_file":"/run/secrets/solmara-esignet-relay-token","private_jwk_env":"NIA_ESIGNET_RELAY_WORKLOAD_JWK","token_uid":1001,"token_gid":1001}] - NIA_ESIGNET_RELAY_WORKLOAD_JWK: ${NIA_ESIGNET_RELAY_WORKLOAD_JWK} - volumes: - - nia-esignet-workload-token:/run/secrets - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)\""] - interval: 10s - timeout: 5s - retries: 6 - esignet: - image: ${SOLMARA_ESIGNET_RELAY_IMAGE:?set digest-pinned Solmara eSignet relay image} - user: root + image: ${SOLMARA_ESIGNET_RELAY_IMAGE:?set digest-pinned eSignet image} restart: unless-stopped + user: root environment: active_profile_env: default,local spring_config_label_env: "" @@ -64,140 +44,80 @@ services: SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration SPRING_CACHE_TYPE: redis SPRING_DATA_REDIS_HOST: esignet-redis - SPRING_REDIS_PASSWORD: "" - MOSIP_ESIGNET_HOST: esignet.solmara.registrystack.org - MOSIP_ESIGNET_DISCOVERY_ISSUER_ID: https://esignet.solmara.registrystack.org - MOSIP_ESIGNET_DISCOVERY_KEY_VALUES: "{'issuer':'https://esignet.solmara.registrystack.org','authorization_endpoint':'https://esignet-ui.solmara.registrystack.org/authorize','token_endpoint':'https://esignet.solmara.registrystack.org/v1/esignet/oauth/v2/token','userinfo_endpoint':'https://esignet.solmara.registrystack.org/v1/esignet/oidc/userinfo','jwks_uri':'https://esignet.solmara.registrystack.org/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" - MOSIP_ESIGNET_OAUTH_KEY_VALUES: "{'issuer':'https://esignet.solmara.registrystack.org','authorization_endpoint':'https://esignet-ui.solmara.registrystack.org/authorize','token_endpoint':'https://esignet.solmara.registrystack.org/v1/esignet/oauth/v2/token','jwks_uri':'https://esignet.solmara.registrystack.org/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" + MOSIP_ESIGNET_HOST: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} + MOSIP_ESIGNET_DISCOVERY_ISSUER_ID: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org} + MOSIP_ESIGNET_DISCOVERY_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token','userinfo_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oidc/userinfo','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" + MOSIP_ESIGNET_OAUTH_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" MOSIP_ESIGNET_DATABASE_URL: jdbc:postgresql://esignet-database:5432/mosip_esignet?currentSchema=esignet - MOSIP_ESIGNET_DATABASE_USERNAME: postgres - MOSIP_ESIGNET_DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD} + MOSIP_ESIGNET_DATABASE_USERNAME: esignet + MOSIP_ESIGNET_DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} MOSIP_ESIGNET_INTEGRATION_SCAN_BASE_PACKAGE: io.mosip.esignet.mock.integration,io.registry.esignet.relay MOSIP_ESIGNET_INTEGRATION_AUTHENTICATOR: RelayAuthenticationService MOSIP_ESIGNET_INTEGRATION_KEY_BINDER: MockKeyBindingWrapperService - MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS: "{'profile' : {'name','given_name','family_name','gender','birthdate','individual_id'}}" - REGISTRY_RELAY_BASE_URL: https://nia-relay.solmara.registrystack.org - REGISTRY_RELAY_ATTRIBUTE_RELEASE_PROFILE_ID: solmara-nia-userinfo - REGISTRY_RELAY_ATTRIBUTE_RELEASE_PROFILE_VERSION: v1 - REGISTRY_RELAY_ATTRIBUTE_RELEASE_PURPOSE: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - REGISTRY_RELAY_AUTH_BEARER_TOKEN_FILE: /run/secrets/solmara-esignet-relay-token - REGISTRY_RELAY_SUBJECT_ID_TYPE: national_id - REGISTRY_RELAY_DEFAULT_CLAIMS: individual_id,name,given_name,family_name,birthdate,gender - REGISTRY_ESIGNET_AUTH_SUPPORTED_FACTORS: OTP - REGISTRY_ESIGNET_AUTH_OTP_CHANNELS: email,phone + MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS: "{'profile' : {'given_name','family_name','gender','birthdate','individual_id'}}" + REGISTRY_RELAY_BASE_URL: https://nia-relay-authority-cells.solmara.registrystack.org + REGISTRY_RELAY_RESOURCE: population-person + REGISTRY_RELAY_LOOKUP: esignet-userinfo + REGISTRY_RELAY_ACCESS_PROFILE: esignet + REGISTRY_RELAY_DEFAULT_CLAIMS: individualId,givenName,familyName,birthdate,gender + SPRING_APPLICATION_JSON: '{"registry":{"esignet":{"claim-map":{"sub":"$$psut","individual_id":"individualId","given_name":"givenName","family_name":"familyName","birthdate":"birthdate","gender":"gender"}}}}' + REGISTRY_MINT_TOKEN_ENDPOINT: https://mint-authority-cells.solmara.registrystack.org/token + REGISTRY_MINT_CLIENT_ID: nia-esignet + REGISTRY_MINT_PRIVATE_JWK: ${NIA_ESIGNET_CLIENT_PRIVATE_JWK:?required} + REGISTRY_MINT_TOKEN_CACHE_MAX_SECONDS: "300" REGISTRY_ESIGNET_AUTH_OTP_STATIC_ENABLED: "true" - REGISTRY_ESIGNET_AUTH_OTP_STATIC_VALUE: "111111" - REGISTRY_ESIGNET_KYC_TOKEN_HMAC_SECRET: ${REGISTRY_ESIGNET_KYC_TOKEN_SECRET} - REGISTRY_ESIGNET_PSUT_HMAC_SECRET: ${REGISTRY_ESIGNET_PSUT_SECRET} - REGISTRY_ESIGNET_USER_INFO_ISSUER: https://esignet.solmara.registrystack.org + REGISTRY_ESIGNET_AUTH_OTP_STATIC_VALUE: ${ESIGNET_DEMO_OTP:?required} + REGISTRY_ESIGNET_AUTH_OTP_CHANNELS: EMAIL,PHONE + REGISTRY_ESIGNET_ACCOUNT_CHECK_CLAIMS: individualId + MOSIP_ESIGNET_AUTHENTICATOR_IDA_OTP_CHANNELS: EMAIL,PHONE + REGISTRY_ESIGNET_KYC_TOKEN_HMAC_SECRET: ${REGISTRY_ESIGNET_KYC_TOKEN_SECRET:?required} + REGISTRY_ESIGNET_PSUT_HMAC_SECRET: ${REGISTRY_ESIGNET_PSUT_SECRET:?required} REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PATH: /home/mosip/kyc-signing/kyc-signing.p12 REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_TYPE: PKCS12 - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD} + REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?required} REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS: esignet-relay-kyc - REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD} + REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?required} MOSIP_KERNEL_KEYMANAGER_HSM_CONFIG_PATH: /home/mosip/keystore/esignet_local.p12 - expose: - - "8088" volumes: - esignet-keystore:/home/mosip/keystore - esignet-kyc-signing:/home/mosip/kyc-signing - - nia-esignet-workload-token:/run/secrets:ro depends_on: - esignet-database: - condition: service_healthy - esignet-redis: - condition: service_healthy - nia-esignet-workload-agent: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-fsS", "http://esignet:8088/v1/esignet/oidc/.well-known/openid-configuration"] - interval: 10s - timeout: 5s - retries: 60 - start_period: 90s - - esignet-ui: - image: ${SOLMARA_ESIGNET_UI_IMAGE:?set digest-pinned Solmara eSignet UI image} - user: root - restart: unless-stopped - environment: - container_user: mosip - DEFAULT_WELLKNOWN: "%5B%7B%22name%22%3A%22OpenID%20Configuration%22%2C%22value%22%3A%22%2F.well-known%2Fopenid-configuration%22%7D%2C%7B%22name%22%3A%22Jwks%20Json%22%2C%22value%22%3A%22%2F.well-known%2Fjwks.json%22%7D%2C%7B%22name%22%3A%22Authorization%20Server%22%2C%22value%22%3A%22%2F.well-known%2Foauth-authorization-server%22%7D%5D" - SIGN_IN_WITH_ESIGNET_PLUGIN_URL: https://raw.githubusercontent.com/mosip/artifactory-ref-impl/977607990661e864c2adddb9d7de50ea6c651b8f/artifacts/src/mosip-plugins/sign-in-with-esignet/sign-in-with-esignet.zip - i18n_url_env: "" - theme_url_env: "" - images_url_env: "" - expose: - - "3000" - depends_on: - esignet: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://esignet-ui:3000/.well-known/openid-configuration"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 30s + esignet-database: {condition: service_healthy} + esignet-redis: {condition: service_healthy} labels: - solmara.lab.host: esignet-ui.solmara.registrystack.org + solmara.lab.host: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} + solmara.rollout.slot: authority-cells - esignet-edge: - image: ${SOLMARA_ESIGNET_UI_IMAGE:?set digest-pinned Solmara eSignet UI image} - user: root - restart: unless-stopped + esignet-ui: + image: ${SOLMARA_ESIGNET_UI_IMAGE:?set digest-pinned eSignet UI image} environment: - container_user: mosip - DEFAULT_WELLKNOWN: "%5B%7B%22name%22%3A%22OpenID%20Configuration%22%2C%22value%22%3A%22%2F.well-known%2Fopenid-configuration%22%7D%2C%7B%22name%22%3A%22Jwks%20Json%22%2C%22value%22%3A%22%2F.well-known%2Fjwks.json%22%7D%2C%7B%22name%22%3A%22Authorization%20Server%22%2C%22value%22%3A%22%2F.well-known%2Foauth-authorization-server%22%7D%5D" - SIGN_IN_WITH_ESIGNET_PLUGIN_URL: https://raw.githubusercontent.com/mosip/artifactory-ref-impl/977607990661e864c2adddb9d7de50ea6c651b8f/artifacts/src/mosip-plugins/sign-in-with-esignet/sign-in-with-esignet.zip - i18n_url_env: "" - theme_url_env: "" - images_url_env: "" - expose: - - "3000" + SOLMARA_ESIGNET_PUBLIC_HOST: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} + SOLMARA_ESIGNET_UI_PUBLIC_HOST: ${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-esignet-ui.solmara.registrystack.org} depends_on: - esignet: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://esignet-edge:3000/.well-known/openid-configuration"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 30s + esignet: {condition: service_started} labels: - solmara.lab.host: esignet.solmara.registrystack.org + solmara.lab.host: ${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-esignet-ui.solmara.registrystack.org} + solmara.rollout.slot: authority-cells esignet-seed: - image: ${SOLMARA_ESIGNET_SEED_IMAGE:?set digest-pinned Solmara eSignet seed image} + image: ${SOLMARA_ESIGNET_SEED_IMAGE:?set digest-pinned eSignet seed image} environment: PGHOST: esignet-database - PGPORT: "5432" - PGUSER: postgres - PGPASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD} + PGUSER: esignet + PGPASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64} - ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["https://portal.solmara.registrystack.org/auth/callback"]' - ESIGNET_REQUIRE_HTTPS_REDIRECTS: "true" + ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?required} ESIGNET_REDIS_HOST: esignet-redis - ESIGNET_ACCOUNT_SOURCE_LABEL: NIA population Relay profile solmara-nia-userinfo - ESIGNET_DEMO_OTP: "111111" - ESIGNET_SEED_STAY_READY: "true" - entrypoint: ["seed-esignet.py"] + ESIGNET_REQUIRE_HTTPS_REDIRECTS: "true" + ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback"]' + entrypoint: [seed-esignet.py] depends_on: - esignet-database: - condition: service_healthy - esignet: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "test", "-f", "/tmp/ready"] - interval: 10s - timeout: 5s - retries: 30 + esignet-database: {condition: service_healthy} + esignet: {condition: service_started} volumes: - esignet-pgdata: + esignet-db-data: esignet-keystore: esignet-kyc-signing: esignet-redis-data: - nia-esignet-workload-token: diff --git a/compose.coolify.interior.yaml b/compose.coolify.interior.yaml index 3d0c719..9f2947e 100644 --- a/compose.coolify.interior.yaml +++ b/compose.coolify.interior.yaml @@ -1,379 +1,85 @@ -name: solmara-lab-interior +name: solmara-lab-interior-cells -x-relay-environment: &relay-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_RELAY_BIND: 0.0.0.0:8080 - -x-notary-environment: ¬ary-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_NOTARY_BIND: 0.0.0.0:8081 - REGISTRY_NOTARY_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - -x-relay-common: &relay-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: unless-stopped - environment: *relay-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-relay", "healthcheck"] - interval: 30s - timeout: 10s - retries: 3 - -x-relay-bootstrap-common: &relay-bootstrap-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - -x-relay-config-state-init: &relay-config-state-init - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "0:0" - restart: "no" - entrypoint: ["/bin/busybox", "sh", "-eu", "-c"] - -x-workload-agent-common: &workload-agent-common - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} - command: ["python", "scripts/relay_workload_identity_agent.py"] - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)\""] - interval: 10s - timeout: 5s - retries: 6 - -x-notary-common: ¬ary-common - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "65534:65534" +x-runtime: &runtime restart: unless-stopped - environment: *notary-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-notary", "healthcheck", "--url", "http://127.0.0.1:8081/ready"] - interval: 30s - timeout: 10s - retries: 3 + read_only: true + tmpfs: [/tmp] + networks: [runtime] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + audit-permissions: {condition: service_completed_successfully} services: - postgres: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - entrypoint: ["/bin/sh", "/usr/local/bin/solmara-postgres-entrypoint.sh"] - command: - - postgres - - -c - - ssl=on - - -c - - ssl_cert_file=/var/lib/postgresql/server-ssl/server.crt - - -c - - ssl_key_file=/var/lib/postgresql/server-ssl/server.key - environment: &postgres-environment - POSTGRES_USER: ${SOLMARA_POSTGRES_USER:-solmara_registry} - POSTGRES_PASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - POSTGRES_DB: ${SOLMARA_POSTGRES_DB:-solmara_lab} - SOLMARA_SOURCE_READER_DATABASES: nia - NIA_SOURCE_POSTGRES_READER_PASSWORD: ${NIA_SOURCE_POSTGRES_READER_PASSWORD} - REGISTRY_RELAY_STATE_EPOCH: ${REGISTRY_RELAY_STATE_EPOCH:-v015} - SOLMARA_RELAY_DATABASES: cra nia - SOLMARA_NOTARY_DATABASES: cra nia - CRA_RELAY_POSTGRES_RUNTIME_PASSWORD: ${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD} - CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD} - NIA_RELAY_POSTGRES_RUNTIME_PASSWORD: ${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD} - NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 - - registry-postgresql-bootstrap: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - restart: "no" - entrypoint: ["/bin/sh", "/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh"] - environment: - <<: *postgres-environment - PGHOST: postgres - PGPASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - PGSSLMODE: require - depends_on: - postgres: - condition: service_healthy - - cra-workload-agent: - <<: *workload-agent-common - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"cra-notary","subject":"cra-notary","scopes":["registry:consult:cra-child-benefit","registry:consult:cra-citizen-record","registry:consult:cra-pension-death"],"token_file":"/run/secrets/cra-notary-relay-token","private_jwk_env":"CRA_RELAY_WORKLOAD_JWK"}] - CRA_RELAY_WORKLOAD_JWK: ${CRA_RELAY_WORKLOAD_JWK} - volumes: - - cra-workload-token:/run/secrets - - nia-workload-agent: - <<: *workload-agent-common - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"nia-notary","subject":"nia-notary","scopes":["registry:consult:nia-child-benefit","registry:consult:nia-citizen-status"],"token_file":"/run/secrets/nia-notary-relay-token","private_jwk_env":"NIA_RELAY_WORKLOAD_JWK"}] - NIA_RELAY_WORKLOAD_JWK: ${NIA_RELAY_WORKLOAD_JWK} - volumes: - - nia-workload-token:/run/secrets - - cra-relay-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/cra-civil/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - cra-relay-cache:/var/lib/registry-relay/cache - - nia-relay-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/nia-population/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - nia-relay-cache:/var/lib/registry-relay/cache - - cra-relay-consultation-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/cra-civil/consultation/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - cra-relay-consultation-cache:/var/lib/registry-relay/cache - - nia-relay-consultation-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/nia-population/consultation/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - nia-relay-consultation-cache:/var/lib/registry-relay/cache - - cra-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/solmara/hosted-relay-bundles/cra-civil/consultation/bootstrap.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${CRA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${CRA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - cra-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - cra-relay-consultation-config-state-init: - condition: service_completed_successfully - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nia-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/solmara/hosted-relay-bundles/nia-population/consultation/bootstrap.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NIA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NIA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_NIA_DATABASE_URL: ${SOLMARA_NIA_DATABASE_URL} - volumes: - - nia-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - nia-relay-consultation-config-state-init: - condition: service_completed_successfully - registry-postgresql-bootstrap: - condition: service_completed_successfully - - cra-civil-relay: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/cra-civil/bootstrap.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${CRA_RELAY_AUDIT_HASH_SECRET} - expose: ["8080"] - volumes: - - cra-relay-cache:/var/lib/registry-relay/cache - depends_on: - cra-relay-config-state-init: - condition: service_completed_successfully - labels: - solmara.lab.host: cra-relay.solmara.registrystack.org - - nia-population-relay: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/nia-population/bootstrap.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NIA_RELAY_AUDIT_HASH_SECRET} - SOLMARA_NIA_DATABASE_URL: ${SOLMARA_NIA_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - expose: ["8080"] - volumes: - - nia-relay-cache:/var/lib/registry-relay/cache - depends_on: - nia-relay-config-state-init: - condition: service_completed_successfully - labels: - solmara.lab.host: nia-relay.solmara.registrystack.org - - cra-civil-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/cra-civil/consultation/bootstrap.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${CRA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${CRA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - expose: ["8081"] - volumes: - - cra-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - cra-relay-state-bootstrap: - condition: service_completed_successfully - cra-workload-agent: - condition: service_healthy - - nia-population-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/nia-population/consultation/bootstrap.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NIA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NIA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_NIA_DATABASE_URL: ${SOLMARA_NIA_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - expose: ["8081"] - volumes: - - nia-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - nia-relay-state-bootstrap: - condition: service_completed_successfully - nia-workload-agent: - condition: service_healthy - - cra-notary: - <<: *notary-common - command: ["--config", "/etc/solmara/registry-projects/hosted/cra-civil/notary/notary.yaml"] - network_mode: service:cra-civil-relay-consultation - environment: &cra-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${CRA_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_cra_runtime:${CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_cra?sslmode=require" - CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - CRA_PENSION_CLIENT_TOKEN_HASH: ${CRA_PENSION_CLIENT_TOKEN_HASH} - CRA_CITIZEN_CLIENT_TOKEN_HASH: ${CRA_CITIZEN_CLIENT_TOKEN_HASH} - volumes: - - cra-workload-token:/run/secrets:ro - depends_on: - cra-civil-relay-consultation: - condition: service_healthy - cra-notary-state-install: - condition: service_completed_successfully - cra-workload-agent: - condition: service_healthy - labels: - solmara.lab.host: cra-notary.solmara.registrystack.org - - cra-notary-state-install: - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/solmara/registry-projects/hosted/cra-civil/notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_cra_owner", "--runtime-role", "solmara_notary_cra_runtime"] - environment: - <<: *cra-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_cra_migrator:${CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_cra?sslmode=require" - volumes: - - cra-workload-token:/run/secrets:ro - depends_on: - cra-workload-agent: - condition: service_healthy - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nia-notary: - <<: *notary-common - command: ["--config", "/etc/solmara/registry-projects/hosted/nia-population/notary/notary.yaml"] - network_mode: service:nia-population-relay-consultation - environment: &nia-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${NIA_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_nia_runtime:${NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_nia?sslmode=require" - NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - NIA_CITIZEN_CLIENT_TOKEN_HASH: ${NIA_CITIZEN_CLIENT_TOKEN_HASH} - NIA_NOTARY_ISSUER_JWK: ${NIA_NOTARY_ISSUER_JWK} - volumes: - - nia-workload-token:/run/secrets:ro - depends_on: - nia-population-relay-consultation: - condition: service_healthy - nia-notary-state-install: - condition: service_completed_successfully - nia-workload-agent: - condition: service_healthy - labels: - solmara.lab.host: nia-notary.solmara.registrystack.org - - nia-notary-state-install: - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} + audit-permissions: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + entrypoint: [python, /app/scripts/provision-hosted-runtime.py] + command: [init-audit, --destination, /audit/cra-relay, --destination, /audit/nia-relay, --destination, /audit/cra-evidence, --destination, /audit/nia-evidence, --uid, "65532", --gid, "65532"] restart: "no" - command: ["--config", "/etc/solmara/registry-projects/hosted/nia-population/notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_nia_owner", "--runtime-role", "solmara_notary_nia_runtime"] - environment: - <<: *nia-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_nia_migrator:${NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_nia?sslmode=require" - volumes: - - nia-workload-token:/run/secrets:ro + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + volumes: [cra-relay-audit:/audit/cra-relay, nia-relay-audit:/audit/nia-relay, cra-evidence-audit:/audit/cra-evidence, nia-evidence-audit:/audit/nia-evidence] + + cra-relay: + <<: *runtime + image: ${REGISTRY_RELAY_IMAGE:?set the digest-pinned official Registry Stack Relay image} + command: [serve, --runtime, /etc/relay/cra/runtime.yaml] + environment: {SOLMARA_RELAY_AUDIT_KEY: "${CRA_RELAY_AUDIT_KEY:?required}"} + volumes: [cra-relay-runtime:/etc/relay/cra:ro, cra-relay-source:/var/lib/relay/source:ro, cra-relay-audit:/var/lib/relay/audit] + labels: {solmara.lab.host: cra-relay-authority-cells.solmara.registrystack.org, solmara.rollout.slot: authority-cells} + + nia-relay: + <<: *runtime + image: ${REGISTRY_RELAY_IMAGE:?set the digest-pinned official Registry Stack Relay image} + command: [serve, --runtime, /etc/relay/nia/runtime.yaml] + environment: {SOLMARA_RELAY_AUDIT_KEY: "${NIA_RELAY_AUDIT_KEY:?required}"} + volumes: [nia-relay-runtime:/etc/relay/nia:ro, nia-relay-source:/var/lib/relay/source:ro, nia-relay-audit:/var/lib/relay/audit] + labels: {solmara.lab.host: nia-relay-authority-cells.solmara.registrystack.org, solmara.rollout.slot: authority-cells} + + cra-evidence: + <<: *runtime + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + command: [--runtime, /etc/registry-evidence/cra/runtime.yaml, serve] + volumes: [cra-evidence-runtime:/etc/registry-evidence/cra:ro, cra-evidence-secrets:/run/secrets/registry-evidence/cra:ro, cra-evidence-transit:/run/registry-evidence/cra:ro, cra-evidence-extracts:/var/lib/registry-evidence/cra/extracts:ro, cra-evidence-audit:/var/lib/registry-evidence/cra/audit] + networks: {runtime: {ipv4_address: 172.29.2.21}} depends_on: - nia-workload-agent: - condition: service_healthy - registry-postgresql-bootstrap: - condition: service_completed_successfully + audit-permissions: {condition: service_completed_successfully} + cra-relay: {condition: service_started} + labels: {solmara.lab.host: "${SOLMARA_CRA_EVIDENCE_PUBLIC_HOST:-cra-evidence.solmara.registrystack.org}", solmara.rollout.slot: authority-cells} + + nia-evidence: + <<: *runtime + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + command: [--runtime, /etc/registry-evidence/nia/runtime.yaml, serve] + volumes: [nia-evidence-runtime:/etc/registry-evidence/nia:ro, nia-evidence-secrets:/run/secrets/registry-evidence/nia:ro, nia-evidence-transit:/run/registry-evidence/nia:ro, nia-evidence-extracts:/var/lib/registry-evidence/nia/extracts:ro, nia-evidence-audit:/var/lib/registry-evidence/nia/audit] + networks: {runtime: {ipv4_address: 172.29.2.22}} + labels: {solmara.lab.host: "${SOLMARA_NIA_EVIDENCE_PUBLIC_HOST:-nia-evidence.solmara.registrystack.org}", solmara.rollout.slot: authority-cells} volumes: - postgres-data: - cra-relay-cache: - cra-relay-consultation-cache: - nia-relay-cache: - nia-relay-consultation-cache: - cra-workload-token: - nia-workload-token: + cra-relay-runtime: {external: true, name: solmara-authority-cells-cra-relay-runtime} + cra-relay-source: {external: true, name: solmara-authority-cells-cra-relay-source} + nia-relay-runtime: {external: true, name: solmara-authority-cells-nia-relay-runtime} + nia-relay-source: {external: true, name: solmara-authority-cells-nia-relay-source} + cra-relay-audit: + nia-relay-audit: + cra-evidence-runtime: {external: true, name: solmara-authority-cells-cra-evidence-runtime} + cra-evidence-secrets: {external: true, name: solmara-authority-cells-cra-evidence-secrets} + cra-evidence-transit: {external: true, name: solmara-authority-cells-cra-evidence-transit} + cra-evidence-extracts: {external: true, name: solmara-authority-cells-cra-evidence-extracts} + cra-evidence-audit: + nia-evidence-runtime: {external: true, name: solmara-authority-cells-nia-evidence-runtime} + nia-evidence-secrets: {external: true, name: solmara-authority-cells-nia-evidence-secrets} + nia-evidence-transit: {external: true, name: solmara-authority-cells-nia-evidence-transit} + nia-evidence-extracts: {external: true, name: solmara-authority-cells-nia-evidence-extracts} + nia-evidence-audit: + superseded-state: {external: true, name: "${SOLMARA_SUPERSEDED_INTERIOR_VOLUME:-solmara-lab-interior-state}"} + +networks: + runtime: + ipam: + config: [{subnet: 172.29.2.0/24}] diff --git a/compose.coolify.labour-pensions.yaml b/compose.coolify.labour-pensions.yaml index 0ea82e6..00e7cc4 100644 --- a/compose.coolify.labour-pensions.yaml +++ b/compose.coolify.labour-pensions.yaml @@ -1,241 +1,60 @@ -name: solmara-lab-labour-pensions +name: solmara-lab-pensions-cells -x-relay-environment: &relay-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_RELAY_BIND: 0.0.0.0:8080 - -x-notary-environment: ¬ary-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_NOTARY_BIND: 0.0.0.0:8081 - REGISTRY_NOTARY_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - -x-relay-common: &relay-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: unless-stopped - environment: *relay-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-relay", "healthcheck"] - interval: 30s - timeout: 10s - retries: 3 - -x-relay-bootstrap-common: &relay-bootstrap-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - -x-relay-config-state-init: &relay-config-state-init - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "0:0" - restart: "no" - entrypoint: ["/bin/busybox", "sh", "-eu", "-c"] - -x-workload-agent-common: &workload-agent-common - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} - command: ["python", "scripts/relay_workload_identity_agent.py"] +x-runtime: &runtime restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)\""] - interval: 10s - timeout: 5s - retries: 6 - -x-notary-common: ¬ary-common - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "65534:65534" - restart: unless-stopped - environment: *notary-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-notary", "healthcheck", "--url", "http://127.0.0.1:8081/ready"] - interval: 30s - timeout: 10s - retries: 3 + read_only: true + tmpfs: [/tmp] + networks: [runtime] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + audit-permissions: {condition: service_completed_successfully} services: - postgres: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - entrypoint: ["/bin/sh", "/usr/local/bin/solmara-postgres-entrypoint.sh"] - command: - - postgres - - -c - - ssl=on - - -c - - ssl_cert_file=/var/lib/postgresql/server-ssl/server.crt - - -c - - ssl_key_file=/var/lib/postgresql/server-ssl/server.key - environment: &postgres-environment - POSTGRES_USER: ${SOLMARA_POSTGRES_USER:-solmara_registry} - POSTGRES_PASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - POSTGRES_DB: ${SOLMARA_POSTGRES_DB:-solmara_lab} - SOLMARA_SOURCE_READER_DATABASES: sipf - SIPF_SOURCE_POSTGRES_READER_PASSWORD: ${SIPF_SOURCE_POSTGRES_READER_PASSWORD} - REGISTRY_RELAY_STATE_EPOCH: ${REGISTRY_RELAY_STATE_EPOCH:-v015} - SOLMARA_RELAY_DATABASES: sipf - SOLMARA_NOTARY_DATABASES: sipf - SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD: ${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD} - SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 - - registry-postgresql-bootstrap: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - restart: "no" - entrypoint: ["/bin/sh", "/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh"] - environment: - <<: *postgres-environment - PGHOST: postgres - PGPASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - PGSSLMODE: require - depends_on: - postgres: - condition: service_healthy - - sipf-workload-agent: - <<: *workload-agent-common - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"sipf-notary","subject":"sipf-notary","scopes":["registry:consult:sipf-pension-payment-review","registry:consult:sipf-survivor-benefit"],"token_file":"/run/secrets/sipf-notary-relay-token","private_jwk_env":"SIPF_RELAY_WORKLOAD_JWK"}] - SIPF_RELAY_WORKLOAD_JWK: ${SIPF_RELAY_WORKLOAD_JWK} - volumes: - - sipf-workload-token:/run/secrets - - sipf-relay-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/sipf-pensions/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - sipf-relay-cache:/var/lib/registry-relay/cache - - sipf-relay-consultation-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - sipf-relay-consultation-cache:/var/lib/registry-relay/cache - - sipf-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/bootstrap.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SIPF_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SIPF_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_SIPF_DATABASE_URL: ${SOLMARA_SIPF_DATABASE_URL} - volumes: - - sipf-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - sipf-relay-consultation-config-state-init: - condition: service_completed_successfully - registry-postgresql-bootstrap: - condition: service_completed_successfully - - sipf-pensions-relay: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/sipf-pensions/bootstrap.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SIPF_RELAY_AUDIT_HASH_SECRET} - SOLMARA_SIPF_DATABASE_URL: ${SOLMARA_SIPF_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - expose: ["8080"] - volumes: - - sipf-relay-cache:/var/lib/registry-relay/cache - depends_on: - sipf-relay-config-state-init: - condition: service_completed_successfully - labels: - solmara.lab.host: sipf-relay.solmara.registrystack.org - - sipf-pensions-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/bootstrap.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SIPF_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SIPF_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_SIPF_DATABASE_URL: ${SOLMARA_SIPF_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - expose: ["8081"] - volumes: - - sipf-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - sipf-relay-state-bootstrap: - condition: service_completed_successfully - sipf-workload-agent: - condition: service_healthy - - sipf-notary: - <<: *notary-common - command: ["--config", "/etc/solmara/registry-projects/hosted/sipf-pensions/notary/notary.yaml"] - network_mode: service:sipf-pensions-relay-consultation - environment: &sipf-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${SIPF_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_sipf_runtime:${SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_sipf?sslmode=require" - SIPF_PENSION_CLIENT_TOKEN_HASH: ${SIPF_PENSION_CLIENT_TOKEN_HASH} - SIPF_NOTARY_ISSUER_JWK: ${SIPF_NOTARY_ISSUER_JWK} - volumes: - - sipf-workload-token:/run/secrets:ro - depends_on: - sipf-pensions-relay-consultation: - condition: service_healthy - sipf-notary-state-install: - condition: service_completed_successfully - sipf-workload-agent: - condition: service_healthy - labels: - solmara.lab.host: sipf-notary.solmara.registrystack.org - - sipf-notary-state-install: - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} + audit-permissions: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + entrypoint: [python, /app/scripts/provision-hosted-runtime.py] + command: [init-audit, --destination, /audit/sipf-relay, --destination, /audit/sipf-evidence, --uid, "65532", --gid, "65532"] restart: "no" - command: ["--config", "/etc/solmara/registry-projects/hosted/sipf-pensions/notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_sipf_owner", "--runtime-role", "solmara_notary_sipf_runtime"] - environment: - <<: *sipf-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_sipf_migrator:${SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_sipf?sslmode=require" - volumes: - - sipf-workload-token:/run/secrets:ro + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + volumes: [sipf-relay-audit:/audit/sipf-relay, sipf-evidence-audit:/audit/sipf-evidence] + + sipf-relay: + <<: *runtime + image: ${REGISTRY_RELAY_IMAGE:?set the digest-pinned official Registry Stack Relay image} + command: [serve, --runtime, /etc/relay/sipf/runtime.yaml] + environment: {SOLMARA_RELAY_AUDIT_KEY: "${SIPF_RELAY_AUDIT_KEY:?required}", SOLMARA_RELAY_CURSOR_KEY: "${SIPF_RELAY_CURSOR_KEY:?required}"} + volumes: [sipf-relay-runtime:/etc/relay/sipf:ro, sipf-relay-source:/var/lib/relay/source:ro, sipf-relay-audit:/var/lib/relay/audit] + labels: {solmara.lab.host: sipf-relay-authority-cells.solmara.registrystack.org, solmara.rollout.slot: authority-cells} + + sipf-evidence: + <<: *runtime + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + command: [--runtime, /etc/registry-evidence/sipf/runtime.yaml, serve] + volumes: [sipf-evidence-runtime:/etc/registry-evidence/sipf:ro, sipf-evidence-secrets:/run/secrets/registry-evidence/sipf:ro, sipf-evidence-transit:/run/registry-evidence/sipf:ro, sipf-evidence-audit:/var/lib/registry-evidence/sipf/audit] + networks: {runtime: {ipv4_address: 172.29.4.25}} depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - sipf-workload-agent: - condition: service_healthy + audit-permissions: {condition: service_completed_successfully} + sipf-relay: {condition: service_started} + labels: {solmara.lab.host: "${SOLMARA_SIPF_EVIDENCE_PUBLIC_HOST:-sipf-evidence.solmara.registrystack.org}", solmara.rollout.slot: authority-cells} volumes: - postgres-data: - sipf-relay-cache: - sipf-relay-consultation-cache: - sipf-workload-token: + sipf-relay-runtime: {external: true, name: solmara-authority-cells-sipf-relay-runtime} + sipf-relay-source: {external: true, name: solmara-authority-cells-sipf-relay-source} + sipf-relay-audit: + sipf-evidence-runtime: {external: true, name: solmara-authority-cells-sipf-evidence-runtime} + sipf-evidence-secrets: {external: true, name: solmara-authority-cells-sipf-evidence-secrets} + sipf-evidence-transit: {external: true, name: solmara-authority-cells-sipf-evidence-transit} + sipf-evidence-audit: + superseded-state: {external: true, name: "${SOLMARA_SUPERSEDED_PENSIONS_VOLUME:-solmara-lab-pensions-state}"} + +networks: + runtime: + ipam: + config: [{subnet: 172.29.4.0/24}] diff --git a/compose.coolify.provision.yaml b/compose.coolify.provision.yaml new file mode 100644 index 0000000..37e4a5d --- /dev/null +++ b/compose.coolify.provision.yaml @@ -0,0 +1,232 @@ +name: solmara-authority-cells + +# This operator-only application owns every active runtime volume. Runtime +# applications attach them by fixed external name and never receive write +# access to authored contracts, source publications, extracts, or secrets. +x-provisioner-common: &provisioner-common + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + restart: "no" + # Each one-shot service is confined to one target's volumes and needs root + # only to initialize Docker-owned volume roots and hand private runtime + # material to UID 65532. It has no network or writable container root. + user: "0:0" + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, FOWNER] + security_opt: [no-new-privileges:true] + +x-provisioner: &provisioner + <<: *provisioner-common + read_only: true + tmpfs: /tmp + +# Docker Compose copies environment-backed secrets before starting a container. +# Keep those inputs on the one-shot container's ephemeral root filesystem so a +# tmpfs mount cannot mask them, and relax only the targets that consume them. +# The provisioner removes the closed input inventory on every exit. Relay +# provisioners remain fully read-only and receive no secret path. +x-secret-provisioner: &secret-provisioner + <<: *provisioner-common + read_only: false + +services: + cra-relay-provisioner: + <<: *provisioner + command: [provision, --target, cra-relay, --assets, /opt/solmara-hosted-assets, --runtime-output, /provisioned/runtime, --source-output, /provisioned/source, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [cra-relay-runtime:/provisioned/runtime, cra-relay-source:/provisioned/source] + + nia-relay-provisioner: + <<: *provisioner + command: [provision, --target, nia-relay, --assets, /opt/solmara-hosted-assets, --runtime-output, /provisioned/runtime, --source-output, /provisioned/source, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [nia-relay-runtime:/provisioned/runtime, nia-relay-source:/provisioned/source] + + mosd-relay-provisioner: + <<: *provisioner + command: [provision, --target, mosd-relay, --assets, /opt/solmara-hosted-assets, --runtime-output, /provisioned/runtime, --source-output, /provisioned/source, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [mosd-relay-runtime:/provisioned/runtime, mosd-relay-source:/provisioned/source] + + sipf-relay-provisioner: + <<: *provisioner + command: [provision, --target, sipf-relay, --assets, /opt/solmara-hosted-assets, --runtime-output, /provisioned/runtime, --source-output, /provisioned/source, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [sipf-relay-runtime:/provisioned/runtime, sipf-relay-source:/provisioned/source] + + nagdi-relay-provisioner: + <<: *provisioner + command: [provision, --target, nagdi-relay, --assets, /opt/solmara-hosted-assets, --runtime-output, /provisioned/runtime, --source-output, /provisioned/source, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [nagdi-relay-runtime:/provisioned/runtime, nagdi-relay-source:/provisioned/source] + + cra-evidence-provisioner: + <<: *secret-provisioner + command: [provision, --target, cra-evidence, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --extract-output, /provisioned/extracts, --bind-host, 172.29.2.21, --mint-origin, https://mint-authority-cells.solmara.registrystack.org, --relay-origin, https://cra-relay-authority-cells.solmara.registrystack.org] + volumes: [cra-evidence-runtime:/provisioned/runtime, cra-evidence-secrets:/provisioned/secrets, cra-evidence-extracts:/provisioned/extracts] + secrets: + - {source: cra-evidence-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: cra-evidence-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: cra-evidence-subject-binding-hmac-key, target: /tmp/solmara-provisioning/subject-binding-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: cra-pension-evidence-client-key, target: /tmp/solmara-provisioning/cra-pension-evidence-client-key, uid: "0", gid: "0", mode: 0400} + - {source: cra-citizen-evidence-client-key, target: /tmp/solmara-provisioning/cra-citizen-evidence-client-key, uid: "0", gid: "0", mode: 0400} + + nia-evidence-provisioner: + <<: *secret-provisioner + command: [provision, --target, nia-evidence, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --extract-output, /provisioned/extracts, --bind-host, 172.29.2.22, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [nia-evidence-runtime:/provisioned/runtime, nia-evidence-secrets:/provisioned/secrets, nia-evidence-extracts:/provisioned/extracts] + secrets: + - {source: nia-evidence-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: nia-evidence-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: nia-evidence-subject-binding-hmac-key, target: /tmp/solmara-provisioning/subject-binding-hmac-key, uid: "0", gid: "0", mode: 0400} + + sro-evidence-provisioner: + <<: *secret-provisioner + command: [provision, --target, sro-evidence, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --extract-output, /provisioned/extracts, --bind-host, 172.29.3.23, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [sro-evidence-runtime:/provisioned/runtime, sro-evidence-secrets:/provisioned/secrets, sro-evidence-extracts:/provisioned/extracts] + secrets: + - {source: sro-evidence-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: sro-evidence-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: sro-evidence-subject-binding-hmac-key, target: /tmp/solmara-provisioning/subject-binding-hmac-key, uid: "0", gid: "0", mode: 0400} + + mosd-evidence-provisioner: + <<: *secret-provisioner + command: [provision, --target, mosd-programme-evidence, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --bind-host, 172.29.3.24, --mint-origin, https://mint-authority-cells.solmara.registrystack.org, --relay-origin, https://mosd-programme-relay-authority-cells.solmara.registrystack.org] + volumes: [mosd-evidence-runtime:/provisioned/runtime, mosd-evidence-secrets:/provisioned/secrets] + secrets: + - {source: mosd-evidence-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: mosd-evidence-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: mosd-evidence-subject-binding-hmac-key, target: /tmp/solmara-provisioning/subject-binding-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: mosd-child-benefit-evidence-client-key, target: /tmp/solmara-provisioning/mosd-child-benefit-evidence-client-key, uid: "0", gid: "0", mode: 0400} + + sipf-evidence-provisioner: + <<: *secret-provisioner + command: [provision, --target, sipf-evidence, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --bind-host, 172.29.4.25, --mint-origin, https://mint-authority-cells.solmara.registrystack.org, --relay-origin, https://sipf-relay-authority-cells.solmara.registrystack.org] + volumes: [sipf-evidence-runtime:/provisioned/runtime, sipf-evidence-secrets:/provisioned/secrets] + secrets: + - {source: sipf-evidence-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: sipf-evidence-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: sipf-evidence-subject-binding-hmac-key, target: /tmp/solmara-provisioning/subject-binding-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: sipf-pension-evidence-client-key, target: /tmp/solmara-provisioning/sipf-pension-evidence-client-key, uid: "0", gid: "0", mode: 0400} + - {source: sipf-survivor-evidence-client-key, target: /tmp/solmara-provisioning/sipf-survivor-evidence-client-key, uid: "0", gid: "0", mode: 0400} + + nagdi-evidence-provisioner: + <<: *secret-provisioner + command: [provision, --target, nagdi-evidence, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --bind-host, 172.29.5.26, --mint-origin, https://mint-authority-cells.solmara.registrystack.org, --relay-origin, https://nagdi-relay-authority-cells.solmara.registrystack.org] + volumes: [nagdi-evidence-runtime:/provisioned/runtime, nagdi-evidence-secrets:/provisioned/secrets] + secrets: + - {source: nagdi-evidence-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: nagdi-evidence-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: nagdi-evidence-subject-binding-hmac-key, target: /tmp/solmara-provisioning/subject-binding-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: nagdi-voucher-evidence-client-key, target: /tmp/solmara-provisioning/nagdi-voucher-evidence-client-key, uid: "0", gid: "0", mode: 0400} + - {source: nagdi-livestock-evidence-client-key, target: /tmp/solmara-provisioning/nagdi-livestock-evidence-client-key, uid: "0", gid: "0", mode: 0400} + + mint-provisioner: + <<: *secret-provisioner + command: [provision, --target, mint, --assets, /opt/solmara-hosted-assets, --secrets, /tmp/solmara-provisioning, --runtime-output, /provisioned/runtime, --secret-output, /provisioned/secrets, --bind-host, 172.29.1.20, --mint-origin, https://mint-authority-cells.solmara.registrystack.org] + volumes: [mint-runtime:/provisioned/runtime, mint-secrets:/provisioned/secrets] + secrets: + - {source: mint-signing-public-jwk, target: /tmp/solmara-provisioning/signing-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: mint-audit-hmac-key, target: /tmp/solmara-provisioning/audit-hmac-key, uid: "0", gid: "0", mode: 0400} + - {source: cra-pension-evidence-client-public-jwk, target: /tmp/solmara-provisioning/cra-pension-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: cra-citizen-evidence-client-public-jwk, target: /tmp/solmara-provisioning/cra-citizen-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: mosd-child-benefit-evidence-client-public-jwk, target: /tmp/solmara-provisioning/mosd-child-benefit-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: sipf-pension-evidence-client-public-jwk, target: /tmp/solmara-provisioning/sipf-pension-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: sipf-survivor-evidence-client-public-jwk, target: /tmp/solmara-provisioning/sipf-survivor-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: nagdi-voucher-evidence-client-public-jwk, target: /tmp/solmara-provisioning/nagdi-voucher-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: nagdi-livestock-evidence-client-public-jwk, target: /tmp/solmara-provisioning/nagdi-livestock-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: nia-esignet-client-public-jwk, target: /tmp/solmara-provisioning/nia-esignet-public.jwk, uid: "0", gid: "0", mode: 0400} + - {source: solmara-demo-client-public-jwk, target: /tmp/solmara-provisioning/solmara-demo-client-public.jwk, uid: "0", gid: "0", mode: 0400} + + provisioning-ready: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + command: [ready] + restart: "no" + user: "65532:65532" + read_only: true + tmpfs: /tmp + network_mode: none + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + cra-relay-provisioner: {condition: service_completed_successfully} + nia-relay-provisioner: {condition: service_completed_successfully} + mosd-relay-provisioner: {condition: service_completed_successfully} + sipf-relay-provisioner: {condition: service_completed_successfully} + nagdi-relay-provisioner: {condition: service_completed_successfully} + cra-evidence-provisioner: {condition: service_completed_successfully} + nia-evidence-provisioner: {condition: service_completed_successfully} + sro-evidence-provisioner: {condition: service_completed_successfully} + mosd-evidence-provisioner: {condition: service_completed_successfully} + sipf-evidence-provisioner: {condition: service_completed_successfully} + nagdi-evidence-provisioner: {condition: service_completed_successfully} + mint-provisioner: {condition: service_completed_successfully} + +secrets: + mint-signing-public-jwk: {environment: MINT_SIGNING_PUBLIC_JWK} + mint-audit-hmac-key: {environment: MINT_AUDIT_HMAC_KEY} + cra-evidence-signing-public-jwk: {environment: CRA_EVIDENCE_SIGNING_PUBLIC_JWK} + cra-evidence-audit-hmac-key: {environment: CRA_EVIDENCE_AUDIT_HMAC_KEY} + cra-evidence-subject-binding-hmac-key: {environment: CRA_EVIDENCE_SUBJECT_BINDING_HMAC_KEY} + nia-evidence-signing-public-jwk: {environment: NIA_EVIDENCE_SIGNING_PUBLIC_JWK} + nia-evidence-audit-hmac-key: {environment: NIA_EVIDENCE_AUDIT_HMAC_KEY} + nia-evidence-subject-binding-hmac-key: {environment: NIA_EVIDENCE_SUBJECT_BINDING_HMAC_KEY} + sro-evidence-signing-public-jwk: {environment: SRO_EVIDENCE_SIGNING_PUBLIC_JWK} + sro-evidence-audit-hmac-key: {environment: SRO_EVIDENCE_AUDIT_HMAC_KEY} + sro-evidence-subject-binding-hmac-key: {environment: SRO_EVIDENCE_SUBJECT_BINDING_HMAC_KEY} + mosd-evidence-signing-public-jwk: {environment: MOSD_EVIDENCE_SIGNING_PUBLIC_JWK} + mosd-evidence-audit-hmac-key: {environment: MOSD_EVIDENCE_AUDIT_HMAC_KEY} + mosd-evidence-subject-binding-hmac-key: {environment: MOSD_EVIDENCE_SUBJECT_BINDING_HMAC_KEY} + sipf-evidence-signing-public-jwk: {environment: SIPF_EVIDENCE_SIGNING_PUBLIC_JWK} + sipf-evidence-audit-hmac-key: {environment: SIPF_EVIDENCE_AUDIT_HMAC_KEY} + sipf-evidence-subject-binding-hmac-key: {environment: SIPF_EVIDENCE_SUBJECT_BINDING_HMAC_KEY} + nagdi-evidence-signing-public-jwk: {environment: NAGDI_EVIDENCE_SIGNING_PUBLIC_JWK} + nagdi-evidence-audit-hmac-key: {environment: NAGDI_EVIDENCE_AUDIT_HMAC_KEY} + nagdi-evidence-subject-binding-hmac-key: {environment: NAGDI_EVIDENCE_SUBJECT_BINDING_HMAC_KEY} + cra-pension-evidence-client-key: {environment: CRA_PENSION_EVIDENCE_CLIENT_PRIVATE_JWK} + cra-citizen-evidence-client-key: {environment: CRA_CITIZEN_EVIDENCE_CLIENT_PRIVATE_JWK} + mosd-child-benefit-evidence-client-key: {environment: MOSD_CHILD_BENEFIT_EVIDENCE_CLIENT_PRIVATE_JWK} + sipf-pension-evidence-client-key: {environment: SIPF_PENSION_EVIDENCE_CLIENT_PRIVATE_JWK} + sipf-survivor-evidence-client-key: {environment: SIPF_SURVIVOR_EVIDENCE_CLIENT_PRIVATE_JWK} + nagdi-voucher-evidence-client-key: {environment: NAGDI_VOUCHER_EVIDENCE_CLIENT_PRIVATE_JWK} + nagdi-livestock-evidence-client-key: {environment: NAGDI_LIVESTOCK_EVIDENCE_CLIENT_PRIVATE_JWK} + cra-pension-evidence-client-public-jwk: {environment: CRA_PENSION_EVIDENCE_CLIENT_PUBLIC_JWK} + cra-citizen-evidence-client-public-jwk: {environment: CRA_CITIZEN_EVIDENCE_CLIENT_PUBLIC_JWK} + mosd-child-benefit-evidence-client-public-jwk: {environment: MOSD_CHILD_BENEFIT_EVIDENCE_CLIENT_PUBLIC_JWK} + sipf-pension-evidence-client-public-jwk: {environment: SIPF_PENSION_EVIDENCE_CLIENT_PUBLIC_JWK} + sipf-survivor-evidence-client-public-jwk: {environment: SIPF_SURVIVOR_EVIDENCE_CLIENT_PUBLIC_JWK} + nagdi-voucher-evidence-client-public-jwk: {environment: NAGDI_VOUCHER_EVIDENCE_CLIENT_PUBLIC_JWK} + nagdi-livestock-evidence-client-public-jwk: {environment: NAGDI_LIVESTOCK_EVIDENCE_CLIENT_PUBLIC_JWK} + nia-esignet-client-public-jwk: {environment: NIA_ESIGNET_CLIENT_PUBLIC_JWK} + solmara-demo-client-public-jwk: {environment: SOLMARA_DEMO_CLIENT_PUBLIC_JWK} + +volumes: + mint-runtime: {name: solmara-authority-cells-mint-runtime} + mint-secrets: {name: solmara-authority-cells-mint-secrets} + mint-transit: {name: solmara-authority-cells-mint-transit} + cra-relay-runtime: {name: solmara-authority-cells-cra-relay-runtime} + cra-relay-source: {name: solmara-authority-cells-cra-relay-source} + nia-relay-runtime: {name: solmara-authority-cells-nia-relay-runtime} + nia-relay-source: {name: solmara-authority-cells-nia-relay-source} + mosd-relay-runtime: {name: solmara-authority-cells-mosd-relay-runtime} + mosd-relay-source: {name: solmara-authority-cells-mosd-relay-source} + sipf-relay-runtime: {name: solmara-authority-cells-sipf-relay-runtime} + sipf-relay-source: {name: solmara-authority-cells-sipf-relay-source} + nagdi-relay-runtime: {name: solmara-authority-cells-nagdi-relay-runtime} + nagdi-relay-source: {name: solmara-authority-cells-nagdi-relay-source} + cra-evidence-runtime: {name: solmara-authority-cells-cra-evidence-runtime} + cra-evidence-secrets: {name: solmara-authority-cells-cra-evidence-secrets} + cra-evidence-transit: {name: solmara-authority-cells-cra-evidence-transit} + cra-evidence-extracts: {name: solmara-authority-cells-cra-evidence-extracts} + nia-evidence-runtime: {name: solmara-authority-cells-nia-evidence-runtime} + nia-evidence-secrets: {name: solmara-authority-cells-nia-evidence-secrets} + nia-evidence-transit: {name: solmara-authority-cells-nia-evidence-transit} + nia-evidence-extracts: {name: solmara-authority-cells-nia-evidence-extracts} + sro-evidence-runtime: {name: solmara-authority-cells-sro-evidence-runtime} + sro-evidence-secrets: {name: solmara-authority-cells-sro-evidence-secrets} + sro-evidence-transit: {name: solmara-authority-cells-sro-evidence-transit} + sro-evidence-extracts: {name: solmara-authority-cells-sro-evidence-extracts} + mosd-evidence-runtime: {name: solmara-authority-cells-mosd-evidence-runtime} + mosd-evidence-secrets: {name: solmara-authority-cells-mosd-evidence-secrets} + mosd-evidence-transit: {name: solmara-authority-cells-mosd-evidence-transit} + sipf-evidence-runtime: {name: solmara-authority-cells-sipf-evidence-runtime} + sipf-evidence-secrets: {name: solmara-authority-cells-sipf-evidence-secrets} + sipf-evidence-transit: {name: solmara-authority-cells-sipf-evidence-transit} + nagdi-evidence-runtime: {name: solmara-authority-cells-nagdi-evidence-runtime} + nagdi-evidence-secrets: {name: solmara-authority-cells-nagdi-evidence-secrets} + nagdi-evidence-transit: {name: solmara-authority-cells-nagdi-evidence-transit} diff --git a/compose.coolify.signers.yaml b/compose.coolify.signers.yaml new file mode 100644 index 0000000..addc0ac --- /dev/null +++ b/compose.coolify.signers.yaml @@ -0,0 +1,178 @@ +name: solmara-authority-cell-signers + +# This operator-only application is the sole private issuer-key boundary. It +# attaches only the fixed Transit volumes owned by the provisioning application. +x-transit-init: &transit-init + image: ${SOLMARA_TRANSIT_SIGNER_IMAGE:?set the digest-pinned Solmara Transit signer image} + entrypoint: [/usr/local/bin/python, -c] + command: ["import os,stat,sys; p='/transit'; s=os.lstat(p); ok=stat.S_ISDIR(s.st_mode) and not stat.S_ISLNK(s.st_mode); not ok and sys.exit(1); os.chown(p,65532,65532); os.chmod(p,0o700)"] + restart: "no" + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + +x-signer: &signer + image: ${SOLMARA_TRANSIT_SIGNER_IMAGE:?set the digest-pinned Solmara Transit signer image} + restart: unless-stopped + user: "65532:65532" + # Docker Compose copies environment-backed secrets into the container rootfs + # before start, so this process cannot use a read-only root filesystem. The + # non-root wrapper consumes and unlinks those owner-only inputs before exec. + read_only: false + network_mode: none + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + healthcheck: + test: [CMD, /usr/local/bin/python, -c, "import socket; s=socket.socket(socket.AF_UNIX); s.connect('/transit/transit-proxy.sock'); s.close()"] + interval: 2s + timeout: 1s + retries: 30 + start_period: 2s + +x-private-secret-target: &private-secret-target + target: /tmp/solmara-signing.jwk + uid: "65532" + gid: "65532" + mode: 0400 + +x-public-secret-target: &public-secret-target + target: /tmp/solmara-signing-public.jwk + uid: "65532" + gid: "65532" + mode: 0400 + +services: + mint-transit-init: + <<: *transit-init + volumes: [mint-transit:/transit] + cra-transit-init: + <<: *transit-init + volumes: [cra-evidence-transit:/transit] + nia-transit-init: + <<: *transit-init + volumes: [nia-evidence-transit:/transit] + sro-transit-init: + <<: *transit-init + volumes: [sro-evidence-transit:/transit] + mosd-transit-init: + <<: *transit-init + volumes: [mosd-evidence-transit:/transit] + sipf-transit-init: + <<: *transit-init + volumes: [sipf-evidence-transit:/transit] + nagdi-transit-init: + <<: *transit-init + volumes: [nagdi-evidence-transit:/transit] + + mint-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-mint] + volumes: [mint-transit:/transit] + secrets: + - {source: mint-signing-jwk, <<: *private-secret-target} + - {source: mint-signing-public-jwk, <<: *public-secret-target} + depends_on: + mint-transit-init: {condition: service_completed_successfully} + cra-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-cra] + volumes: [cra-evidence-transit:/transit] + secrets: + - {source: cra-evidence-signing-jwk, <<: *private-secret-target} + - {source: cra-evidence-signing-public-jwk, <<: *public-secret-target} + depends_on: + cra-transit-init: {condition: service_completed_successfully} + nia-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-nia] + volumes: [nia-evidence-transit:/transit] + secrets: + - {source: nia-evidence-signing-jwk, <<: *private-secret-target} + - {source: nia-evidence-signing-public-jwk, <<: *public-secret-target} + depends_on: + nia-transit-init: {condition: service_completed_successfully} + sro-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-sro] + volumes: [sro-evidence-transit:/transit] + secrets: + - {source: sro-evidence-signing-jwk, <<: *private-secret-target} + - {source: sro-evidence-signing-public-jwk, <<: *public-secret-target} + depends_on: + sro-transit-init: {condition: service_completed_successfully} + mosd-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-mosd-programme] + volumes: [mosd-evidence-transit:/transit] + secrets: + - {source: mosd-evidence-signing-jwk, <<: *private-secret-target} + - {source: mosd-evidence-signing-public-jwk, <<: *public-secret-target} + depends_on: + mosd-transit-init: {condition: service_completed_successfully} + sipf-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-sipf] + volumes: [sipf-evidence-transit:/transit] + secrets: + - {source: sipf-evidence-signing-jwk, <<: *private-secret-target} + - {source: sipf-evidence-signing-public-jwk, <<: *public-secret-target} + depends_on: + sipf-transit-init: {condition: service_completed_successfully} + nagdi-signer: + <<: *signer + command: [--private-jwk, /tmp/solmara-signing.jwk, --public-jwk, /tmp/solmara-signing-public.jwk, --socket, /transit/transit-proxy.sock, --key-name, solmara-evidence-nagdi] + volumes: [nagdi-evidence-transit:/transit] + secrets: + - {source: nagdi-evidence-signing-jwk, <<: *private-secret-target} + - {source: nagdi-evidence-signing-public-jwk, <<: *public-secret-target} + depends_on: + nagdi-transit-init: {condition: service_completed_successfully} + + signers-ready: + image: ${SOLMARA_TRANSIT_SIGNER_IMAGE:?set the digest-pinned Solmara Transit signer image} + entrypoint: [/usr/local/bin/python, -c] + command: ["print('authority signers ready')"] + restart: "no" + user: "65532:65532" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + mint-signer: {condition: service_healthy} + cra-signer: {condition: service_healthy} + nia-signer: {condition: service_healthy} + sro-signer: {condition: service_healthy} + mosd-signer: {condition: service_healthy} + sipf-signer: {condition: service_healthy} + nagdi-signer: {condition: service_healthy} + +secrets: + mint-signing-jwk: {environment: MINT_SIGNING_JWK} + mint-signing-public-jwk: {environment: MINT_SIGNING_PUBLIC_JWK} + cra-evidence-signing-jwk: {environment: CRA_EVIDENCE_SIGNING_JWK} + cra-evidence-signing-public-jwk: {environment: CRA_EVIDENCE_SIGNING_PUBLIC_JWK} + nia-evidence-signing-jwk: {environment: NIA_EVIDENCE_SIGNING_JWK} + nia-evidence-signing-public-jwk: {environment: NIA_EVIDENCE_SIGNING_PUBLIC_JWK} + sro-evidence-signing-jwk: {environment: SRO_EVIDENCE_SIGNING_JWK} + sro-evidence-signing-public-jwk: {environment: SRO_EVIDENCE_SIGNING_PUBLIC_JWK} + mosd-evidence-signing-jwk: {environment: MOSD_EVIDENCE_SIGNING_JWK} + mosd-evidence-signing-public-jwk: {environment: MOSD_EVIDENCE_SIGNING_PUBLIC_JWK} + sipf-evidence-signing-jwk: {environment: SIPF_EVIDENCE_SIGNING_JWK} + sipf-evidence-signing-public-jwk: {environment: SIPF_EVIDENCE_SIGNING_PUBLIC_JWK} + nagdi-evidence-signing-jwk: {environment: NAGDI_EVIDENCE_SIGNING_JWK} + nagdi-evidence-signing-public-jwk: {environment: NAGDI_EVIDENCE_SIGNING_PUBLIC_JWK} + +volumes: + mint-transit: {external: true, name: solmara-authority-cells-mint-transit} + cra-evidence-transit: {external: true, name: solmara-authority-cells-cra-evidence-transit} + nia-evidence-transit: {external: true, name: solmara-authority-cells-nia-evidence-transit} + sro-evidence-transit: {external: true, name: solmara-authority-cells-sro-evidence-transit} + mosd-evidence-transit: {external: true, name: solmara-authority-cells-mosd-evidence-transit} + sipf-evidence-transit: {external: true, name: solmara-authority-cells-sipf-evidence-transit} + nagdi-evidence-transit: {external: true, name: solmara-authority-cells-nagdi-evidence-transit} diff --git a/compose.coolify.social-development.yaml b/compose.coolify.social-development.yaml index 0e45f52..c6b574a 100644 --- a/compose.coolify.social-development.yaml +++ b/compose.coolify.social-development.yaml @@ -1,369 +1,73 @@ -name: solmara-lab-social-development +name: solmara-lab-social-cells -x-relay-environment: &relay-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_RELAY_BIND: 0.0.0.0:8080 - -x-notary-environment: ¬ary-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_NOTARY_BIND: 0.0.0.0:8081 - REGISTRY_NOTARY_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - -x-relay-common: &relay-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: unless-stopped - environment: *relay-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-relay", "healthcheck"] - interval: 30s - timeout: 10s - retries: 3 - -x-relay-bootstrap-common: &relay-bootstrap-common - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - -x-relay-config-state-init: &relay-config-state-init - image: ${SOLMARA_RELAY_IMAGE:?set digest-pinned Solmara relay image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "0:0" - restart: "no" - entrypoint: ["/bin/busybox", "sh", "-eu", "-c"] - -x-workload-agent-common: &workload-agent-common - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} - command: ["python", "scripts/relay_workload_identity_agent.py"] - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)\""] - interval: 10s - timeout: 5s - retries: 6 - -x-notary-common: ¬ary-common - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "65534:65534" +x-runtime: &runtime restart: unless-stopped - environment: *notary-environment - healthcheck: - test: ["CMD", "/usr/local/bin/registry-notary", "healthcheck", "--url", "http://127.0.0.1:8081/ready"] - interval: 30s - timeout: 10s - retries: 3 + read_only: true + tmpfs: [/tmp] + networks: [runtime] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + audit-permissions: {condition: service_completed_successfully} services: - postgres: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - entrypoint: ["/bin/sh", "/usr/local/bin/solmara-postgres-entrypoint.sh"] - command: - - postgres - - -c - - ssl=on - - -c - - ssl_cert_file=/var/lib/postgresql/server-ssl/server.crt - - -c - - ssl_key_file=/var/lib/postgresql/server-ssl/server.key - environment: &postgres-environment - POSTGRES_USER: ${SOLMARA_POSTGRES_USER:-solmara_registry} - POSTGRES_PASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - POSTGRES_DB: ${SOLMARA_POSTGRES_DB:-solmara_lab} - SOLMARA_SOURCE_READER_DATABASES: "" - REGISTRY_RELAY_STATE_EPOCH: ${REGISTRY_RELAY_STATE_EPOCH:-v015} - SOLMARA_RELAY_DATABASES: sro programme - SOLMARA_NOTARY_DATABASES: sro programme - SRO_RELAY_POSTGRES_RUNTIME_PASSWORD: ${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD} - SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD} - PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD: ${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD} - PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 - - registry-postgresql-bootstrap: - image: ${SOLMARA_POSTGRES_IMAGE:?set digest-pinned Solmara Postgres image} - restart: "no" - entrypoint: ["/bin/sh", "/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh"] - environment: - <<: *postgres-environment - PGHOST: postgres - PGPASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - PGSSLMODE: require - depends_on: - postgres: - condition: service_healthy - - sro-workload-agent: - <<: *workload-agent-common - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"sro-notary","subject":"sro-notary","scopes":["registry:consult:child-benefit"],"token_file":"/run/secrets/sro-notary-relay-token","private_jwk_env":"SRO_RELAY_WORKLOAD_JWK"}] - SRO_RELAY_WORKLOAD_JWK: ${SRO_RELAY_WORKLOAD_JWK} - volumes: - - sro-workload-token:/run/secrets - - programme-workload-agent: - <<: *workload-agent-common - environment: - WORKLOAD_ISSUER: https://workload-issuer.solmara.registrystack.org - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"programme-notary","subject":"programme-notary","scopes":["registry:consult:child-benefit"],"token_file":"/run/secrets/programme-notary-relay-token","private_jwk_env":"PROGRAMME_RELAY_WORKLOAD_JWK"}] - PROGRAMME_RELAY_WORKLOAD_JWK: ${PROGRAMME_RELAY_WORKLOAD_JWK} - volumes: - - programme-workload-token:/run/secrets - - sro-relay-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/sro-social/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - sro-relay-cache:/var/lib/registry-relay/cache - - programme-relay-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/mosd-programme/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - programme-relay-cache:/var/lib/registry-relay/cache - - sro-relay-consultation-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/sro-social/consultation/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - sro-relay-consultation-cache:/var/lib/registry-relay/cache - - programme-relay-consultation-config-state-init: - <<: *relay-config-state-init - command: - - | - /bin/busybox mkdir -p /var/lib/registry-relay/cache - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache - if ! /bin/busybox test -e /var/lib/registry-relay/cache/config-bundle-antirollback.json; then - /bin/busybox cp /etc/solmara/hosted-relay-bundles/mosd-programme/consultation/antirollback-seed.json /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chown 65532:65532 /var/lib/registry-relay/cache/config-bundle-antirollback.json - /bin/busybox chmod 0600 /var/lib/registry-relay/cache/config-bundle-antirollback.json - fi - volumes: - - programme-relay-consultation-cache:/var/lib/registry-relay/cache - - sro-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/solmara/hosted-relay-bundles/sro-social/consultation/bootstrap.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SRO_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SRO_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - sro-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - sro-relay-consultation-config-state-init: - condition: service_completed_successfully - registry-postgresql-bootstrap: - condition: service_completed_successfully - - programme-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/solmara/hosted-relay-bundles/mosd-programme/consultation/bootstrap.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${PROGRAMME_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${PROGRAMME_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - programme-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - programme-relay-consultation-config-state-init: - condition: service_completed_successfully - registry-postgresql-bootstrap: - condition: service_completed_successfully - - sro-social-relay: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/sro-social/bootstrap.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SRO_RELAY_AUDIT_HASH_SECRET} - expose: ["8080"] - volumes: - - sro-relay-cache:/var/lib/registry-relay/cache - depends_on: - sro-relay-config-state-init: - condition: service_completed_successfully - labels: - solmara.lab.host: sro-relay.solmara.registrystack.org - - programme-mis-relay: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/mosd-programme/bootstrap.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${PROGRAMME_RELAY_AUDIT_HASH_SECRET} - expose: ["8080"] - volumes: - - programme-relay-cache:/var/lib/registry-relay/cache - depends_on: - programme-relay-config-state-init: - condition: service_completed_successfully - labels: - solmara.lab.host: mosd-programme-relay.solmara.registrystack.org - - sro-social-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/sro-social/consultation/bootstrap.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SRO_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SRO_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - expose: ["8081"] - volumes: - - sro-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - sro-relay-state-bootstrap: - condition: service_completed_successfully - sro-workload-agent: - condition: service_healthy - - programme-mis-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/solmara/hosted-relay-bundles/mosd-programme/consultation/bootstrap.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${PROGRAMME_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${PROGRAMME_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - expose: ["8081"] - volumes: - - programme-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - programme-relay-state-bootstrap: - condition: service_completed_successfully - programme-workload-agent: - condition: service_healthy - - sro-notary: - <<: *notary-common - command: ["--config", "/etc/solmara/registry-projects/hosted/sro-social/notary/notary.yaml"] - network_mode: service:sro-social-relay-consultation - environment: &sro-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${SRO_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_sro_runtime:${SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_sro?sslmode=require" - SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - volumes: - - sro-workload-token:/run/secrets:ro - depends_on: - sro-social-relay-consultation: - condition: service_healthy - sro-notary-state-install: - condition: service_completed_successfully - sro-workload-agent: - condition: service_healthy - labels: - solmara.lab.host: sro-notary.solmara.registrystack.org - - sro-notary-state-install: - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/solmara/registry-projects/hosted/sro-social/notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_sro_owner", "--runtime-role", "solmara_notary_sro_runtime"] - environment: - <<: *sro-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_sro_migrator:${SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_sro?sslmode=require" - volumes: - - sro-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - sro-workload-agent: - condition: service_healthy - - programme-notary: - <<: *notary-common - command: ["--config", "/etc/solmara/registry-projects/hosted/mosd-programme/notary/notary.yaml"] - network_mode: service:programme-mis-relay-consultation - environment: &programme-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${PROGRAMME_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_programme_runtime:${PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_programme?sslmode=require" - PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - volumes: - - programme-workload-token:/run/secrets:ro - depends_on: - programme-mis-relay-consultation: - condition: service_healthy - programme-notary-state-install: - condition: service_completed_successfully - programme-workload-agent: - condition: service_healthy - labels: - solmara.lab.host: programme-notary.solmara.registrystack.org - - programme-notary-state-install: - image: ${SOLMARA_NOTARY_IMAGE:?set digest-pinned Solmara notary image} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} + audit-permissions: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + entrypoint: [python, /app/scripts/provision-hosted-runtime.py] + command: [init-audit, --destination, /audit/mosd-relay, --destination, /audit/sro-evidence, --destination, /audit/mosd-evidence, --uid, "65532", --gid, "65532"] restart: "no" - command: ["--config", "/etc/solmara/registry-projects/hosted/mosd-programme/notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_programme_owner", "--runtime-role", "solmara_notary_programme_runtime"] - environment: - <<: *programme-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_programme_migrator:${PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_programme?sslmode=require" - volumes: - - programme-workload-token:/run/secrets:ro + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + volumes: [mosd-relay-audit:/audit/mosd-relay, sro-evidence-audit:/audit/sro-evidence, mosd-evidence-audit:/audit/mosd-evidence] + + mosd-relay: + <<: *runtime + image: ${REGISTRY_RELAY_IMAGE:?set the digest-pinned official Registry Stack Relay image} + command: [serve, --runtime, /etc/relay/mosd/runtime.yaml] + environment: {SOLMARA_RELAY_AUDIT_KEY: "${MOSD_RELAY_AUDIT_KEY:?required}"} + volumes: [mosd-relay-runtime:/etc/relay/mosd:ro, mosd-relay-source:/var/lib/relay/source:ro, mosd-relay-audit:/var/lib/relay/audit] + labels: {solmara.lab.host: mosd-programme-relay-authority-cells.solmara.registrystack.org, solmara.rollout.slot: authority-cells} + + sro-evidence: + <<: *runtime + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + command: [--runtime, /etc/registry-evidence/sro/runtime.yaml, serve] + volumes: [sro-evidence-runtime:/etc/registry-evidence/sro:ro, sro-evidence-secrets:/run/secrets/registry-evidence/sro:ro, sro-evidence-transit:/run/registry-evidence/sro:ro, sro-evidence-extracts:/var/lib/registry-evidence/sro/extracts:ro, sro-evidence-audit:/var/lib/registry-evidence/sro/audit] + networks: {runtime: {ipv4_address: 172.29.3.23}} + labels: {solmara.lab.host: "${SOLMARA_SRO_EVIDENCE_PUBLIC_HOST:-sro-evidence.solmara.registrystack.org}", solmara.rollout.slot: authority-cells} + + mosd-programme-evidence: + <<: *runtime + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + command: [--runtime, /etc/registry-evidence/mosd-programme/runtime.yaml, serve] + volumes: [mosd-evidence-runtime:/etc/registry-evidence/mosd-programme:ro, mosd-evidence-secrets:/run/secrets/registry-evidence/mosd-programme:ro, mosd-evidence-transit:/run/registry-evidence/mosd-programme:ro, mosd-evidence-audit:/var/lib/registry-evidence/mosd-programme/audit] + networks: {runtime: {ipv4_address: 172.29.3.24}} depends_on: - programme-workload-agent: - condition: service_healthy - registry-postgresql-bootstrap: - condition: service_completed_successfully + audit-permissions: {condition: service_completed_successfully} + mosd-relay: {condition: service_started} + labels: {solmara.lab.host: "${SOLMARA_MOSD_PROGRAMME_EVIDENCE_PUBLIC_HOST:-mosd-programme-evidence.solmara.registrystack.org}", solmara.rollout.slot: authority-cells} volumes: - postgres-data: - sro-relay-cache: - sro-relay-consultation-cache: - programme-relay-cache: - programme-relay-consultation-cache: - sro-workload-token: - programme-workload-token: + mosd-relay-runtime: {external: true, name: solmara-authority-cells-mosd-relay-runtime} + mosd-relay-source: {external: true, name: solmara-authority-cells-mosd-relay-source} + mosd-relay-audit: + sro-evidence-runtime: {external: true, name: solmara-authority-cells-sro-evidence-runtime} + sro-evidence-secrets: {external: true, name: solmara-authority-cells-sro-evidence-secrets} + sro-evidence-transit: {external: true, name: solmara-authority-cells-sro-evidence-transit} + sro-evidence-extracts: {external: true, name: solmara-authority-cells-sro-evidence-extracts} + sro-evidence-audit: + mosd-evidence-runtime: {external: true, name: solmara-authority-cells-mosd-evidence-runtime} + mosd-evidence-secrets: {external: true, name: solmara-authority-cells-mosd-evidence-secrets} + mosd-evidence-transit: {external: true, name: solmara-authority-cells-mosd-evidence-transit} + mosd-evidence-audit: + superseded-state: {external: true, name: "${SOLMARA_SUPERSEDED_SOCIAL_VOLUME:-solmara-lab-social-state}"} + +networks: + runtime: + ipam: + config: [{subnet: 172.29.3.0/24}] diff --git a/compose.coolify.walt.yaml b/compose.coolify.walt.yaml index 47d37d2..1776511 100644 --- a/compose.coolify.walt.yaml +++ b/compose.coolify.walt.yaml @@ -1,136 +1,18 @@ name: solmara-lab-wallet - -# Self-hosted walt.id holder wallet for the Solmara hosted demo. -# -# Only the holder side is deployed (wallet-api + web wallet). Registry Stack -# issuance stays with the relevant authority-owned Notary, so walt's -# issuer-api, verifier-api, and portal are omitted. -# -# Ingress: Caddy is the single public entrypoint. Coolify/Traefik terminates TLS -# for wallet.solmara.registrystack.org and forwards to caddy:7101, which serves -# the web wallet and proxies /wallet-api/* to wallet-api:7001 on the same origin. - -x-hosted-domains: - caddy: wallet.solmara.registrystack.org - services: - config-loader: - image: ${CONFIG_LOADER_IMAGE:-alpine:3.20} - restart: "no" - environment: - CONFIG_REPO_URL: ${CONFIG_REPO_URL:-https://github.com/registrystack/solmara-lab} - CONFIG_REPO_REF: ${CONFIG_REPO_REF:?set CONFIG_REPO_REF to the deployed solmara-lab git ref} - entrypoint: ["/bin/sh", "-euc"] - command: - - | - apk add --no-cache git >/dev/null - rm -rf /tmp/repo - git init /tmp/repo - git -C /tmp/repo remote add origin "$$CONFIG_REPO_URL" - git -C /tmp/repo fetch --depth 1 origin "$$CONFIG_REPO_REF" - git -C /tmp/repo -c advice.detachedHead=false checkout FETCH_HEAD - for d in walletconf caddy; do - mkdir -p "/out/$$d" - find "/out/$$d" -mindepth 1 -delete 2>/dev/null || true - done - cp -a /tmp/repo/config/walt/. /out/walletconf/ - rm -f /out/walletconf/Caddyfile /out/walletconf/README.md - cp -a /tmp/repo/config/walt/Caddyfile /out/caddy/Caddyfile - echo "config-loader: populated walt config volumes from $$CONFIG_REPO_REF" - volumes: - - cfg-walt:/out/walletconf - - cfg-walt-caddy:/out/caddy - - walt-postgres: - image: ${WALT_POSTGRES_IMAGE:-postgres:16-alpine} - environment: - POSTGRES_DB: ${WALT_DB_NAME:-waltid} - POSTGRES_USER: ${WALT_DB_USERNAME:-waltid} - POSTGRES_PASSWORD: ${WALT_DB_PASSWORD:?set WALT_DB_PASSWORD in Coolify} - depends_on: - config-loader: - condition: service_completed_successfully - volumes: - - walt-pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 30 - start_period: 10s - restart: unless-stopped - wallet-api: - image: ${WALT_IMAGE_PREFIX:-docker.io/}waltid/wallet-api:${WALT_IMAGE_TAG:-0.20.2} - depends_on: - walt-postgres: - condition: service_healthy - config-loader: - condition: service_completed_successfully - environment: - WALLET_BACKEND_PORT: "7001" - SERVICE_HOST: wallet.solmara.registrystack.org - DATABASE_ENGINE: postgres - POSTGRES_DB_HOST: walt-postgres - POSTGRES_DB_PORT: "5432" - DB_NAME: ${WALT_DB_NAME:-waltid} - DB_USERNAME: ${WALT_DB_USERNAME:-waltid} - DB_PASSWORD: ${WALT_DB_PASSWORD:?set WALT_DB_PASSWORD in Coolify} - WALT_AUTH_ENCRYPTION_KEY: ${WALT_AUTH_ENCRYPTION_KEY:?set WALT_AUTH_ENCRYPTION_KEY in Coolify} - WALT_AUTH_SIGN_KEY: ${WALT_AUTH_SIGN_KEY:?set WALT_AUTH_SIGN_KEY in Coolify} - WALT_AUTH_TOKEN_KEY: ${WALT_AUTH_TOKEN_KEY:?set WALT_AUTH_TOKEN_KEY in Coolify} - WALT_KTOR_SIGNING_KEY: ${WALT_KTOR_SIGNING_KEY:?set WALT_KTOR_SIGNING_KEY in Coolify} - WALT_KTOR_VERIFICATION_KEY: ${WALT_KTOR_VERIFICATION_KEY:?set WALT_KTOR_VERIFICATION_KEY in Coolify} - expose: - - "7001" - volumes: - - cfg-walt:/waltid-wallet-api/config:ro - - walt-data:/waltid-wallet-api/data - healthcheck: - test: ["CMD", "curl", "-s", "-o", "/dev/null", "http://127.0.0.1:7001/"] - interval: 15s - timeout: 5s - retries: 10 - start_period: 30s + image: docker.io/waltid/wallet-api@sha256:2c10973dfbf7ecfa07bc45c35499b2689bdba1217cc95e05d1c18d0d88d9a7c6 restart: unless-stopped - - waltid-demo-wallet: - image: ${WALT_IMAGE_PREFIX:-docker.io/}waltid/waltid-demo-wallet:${WALT_IMAGE_TAG:-0.20.2} - depends_on: - wallet-api: - condition: service_started - environment: - NUXT_PUBLIC_ISSUER_CALLBACK_URL: https://wallet.solmara.registrystack.org - PORT: "7101" - expose: - - "7101" + environment: {DATABASE_ENGINE: sqlite, SQLITE_FILE: /data/wallet.sqlite} + volumes: ["wallet-data:/data"] + wallet-ui: + image: docker.io/waltid/waltid-demo-wallet@sha256:e7778f62b7ad812e9191151a958495d6493b7c81d176f23a018929aa5deb3274 restart: unless-stopped - caddy: - image: ${WALT_CADDY_IMAGE:-docker.io/caddy:2} - depends_on: - config-loader: - condition: service_completed_successfully - waltid-demo-wallet: - condition: service_started - wallet-api: - condition: service_started - expose: - - "7101" - volumes: - - cfg-walt-caddy:/etc/caddy:ro - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7101/"] - interval: 15s - timeout: 5s - retries: 10 - start_period: 20s + image: docker.io/library/caddy@sha256:844f60b64e4724a5aa8245e019dace0d3f199f7433ce6c57676cb30a920dbad9 restart: unless-stopped - labels: - solmara.lab.host: wallet.solmara.registrystack.org - + volumes: ["./config/walt/Caddyfile:/etc/caddy/Caddyfile:ro", "wallet-caddy-data:/data"] + labels: {solmara.lab.host: wallet.solmara.registrystack.org} volumes: - cfg-walt: - cfg-walt-caddy: - walt-pgdata: - walt-data: + wallet-data: + wallet-caddy-data: diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 6b712eb..4ed850d 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -1,179 +1,164 @@ -name: solmara-lab-core - -# Core hosted Coolify compose. Keep this file and the per-authority -# compose.coolify.*.yaml files free of custom Docker networks; Coolify owns the -# proxy network and domains. Cross-authority calls use public TLS endpoints. - +name: solmara-lab +x-hosted-evidence-environment: &hosted-evidence + SOLMARA_LAB_MODE: hosted + SOLMARA_MINT_URL: https://mint-authority-cells.solmara.registrystack.org + SOLMARA_MINT_ASSERTION_AUDIENCE: https://mint-authority-cells.solmara.registrystack.org/token + SOLMARA_EVIDENCE_CLIENT_ID: solmara-demo + SOLMARA_EVIDENCE_CLIENT_KEY: /run/secrets/solmara-evidence-client.jwk + SOLMARA_CRA_EVIDENCE_URL: ${SOLMARA_CRA_EVIDENCE_PUBLIC_BASE_URL:-https://cra-evidence.solmara.registrystack.org} + SOLMARA_NIA_EVIDENCE_URL: ${SOLMARA_NIA_EVIDENCE_PUBLIC_BASE_URL:-https://nia-evidence.solmara.registrystack.org} + SOLMARA_SRO_EVIDENCE_URL: ${SOLMARA_SRO_EVIDENCE_PUBLIC_BASE_URL:-https://sro-evidence.solmara.registrystack.org} + SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL: ${SOLMARA_MOSD_PROGRAMME_EVIDENCE_PUBLIC_BASE_URL:-https://mosd-programme-evidence.solmara.registrystack.org} + SOLMARA_SIPF_EVIDENCE_URL: ${SOLMARA_SIPF_EVIDENCE_PUBLIC_BASE_URL:-https://sipf-evidence.solmara.registrystack.org} + SOLMARA_NAGDI_EVIDENCE_URL: ${SOLMARA_NAGDI_EVIDENCE_PUBLIC_BASE_URL:-https://nagdi-evidence.solmara.registrystack.org} + CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN:?required} services: + audit-permissions: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + entrypoint: [python, /app/scripts/provision-hosted-runtime.py] + command: [init-audit, --destination, /audit/mint, --uid, "65532", --gid, "65532"] + restart: "no" + user: "0:0" + read_only: true + tmpfs: [/tmp] + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + volumes: [mint-audit:/audit/mint] + mint: + image: ${SOLMARA_MINT_IMAGE:?set the digest-pinned official Registry Stack Mint image} + command: ["serve", "--config", "/etc/registry-mint/mint.yaml"] + restart: unless-stopped + read_only: true + tmpfs: [/tmp] + volumes: ["mint-runtime:/etc/registry-mint:ro", "mint-secrets:/run/secrets/registry-mint:ro", "mint-transit:/run/registry-mint:ro", "mint-audit:/var/lib/registry-mint"] + networks: {runtime: {ipv4_address: 172.29.1.20}} + depends_on: + audit-permissions: {condition: service_completed_successfully} + labels: {solmara.lab.host: mint-authority-cells.solmara.registrystack.org, solmara.rollout.slot: authority-cells} + mint-readiness: + image: ${SOLMARA_AUTHORITY_PROVISIONER_IMAGE:?set the digest-pinned Solmara authority provisioner image} + entrypoint: [python, -c] + command: + - >- + import sys,time,urllib.request; + ok=False; + exec("for _ in range(60):\n try:\n with urllib.request.urlopen('http://172.29.1.20:8081/health', timeout=1) as response:\n if response.status == 200:\n ok=True; break\n except Exception:\n pass\n time.sleep(1)"); + sys.exit(0 if ok else 'mint readiness failed') + restart: "no" + user: "65532:65532" + read_only: true + tmpfs: [/tmp] + networks: [runtime] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: + mint: {condition: service_started} static-metadata: - image: ${SOLMARA_STATIC_METADATA_IMAGE:?set digest-pinned Solmara static metadata image} - command: ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv/static"] - labels: - solmara.lab.host: metadata.solmara.registrystack.org - - workload-issuer: - image: ${SOLMARA_STATIC_METADATA_IMAGE:?set digest-pinned Solmara static metadata image} - command: ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv/static"] - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/.well-known/jwks.json"] - interval: 30s - timeout: 10s - retries: 3 - labels: - solmara.lab.host: workload-issuer.solmara.registrystack.org - + image: ${SOLMARA_STATIC_METADATA_IMAGE:?set a digest-pinned Solmara static metadata image} + restart: unless-stopped + labels: {solmara.lab.host: "${SOLMARA_METADATA_PUBLIC_HOST:-metadata.solmara.registrystack.org}"} scenario-runner: - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} + image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set a digest-pinned Solmara scenario runner image} + restart: unless-stopped environment: + <<: *hosted-evidence + CHILD_BENEFIT_FEDERATOR_URL: ${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org} SCENARIO_RUNNER_HOST: 0.0.0.0 - SCENARIO_RUNNER_PORT: 8080 - SOLMARA_LAB_MODE: hosted - CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 - CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN} - CRA_NOTARY_URL: https://cra-notary.solmara.registrystack.org - CRA_PENSION_CLIENT_TOKEN: ${CRA_PENSION_CLIENT_TOKEN} - CRA_CITIZEN_CLIENT_TOKEN: ${CRA_CITIZEN_CLIENT_TOKEN} - NIA_NOTARY_URL: https://nia-notary.solmara.registrystack.org - NIA_CITIZEN_CLIENT_TOKEN: ${NIA_CITIZEN_CLIENT_TOKEN} - SIPF_NOTARY_URL: https://sipf-notary.solmara.registrystack.org - SIPF_PENSION_CLIENT_TOKEN: ${SIPF_PENSION_CLIENT_TOKEN} - NAGDI_NOTARY_URL: https://nagdi-notary.solmara.registrystack.org - NAGDI_NOTARY_TOKEN: ${NAGDI_NOTARY_TOKEN} - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)\""] - interval: 30s - timeout: 10s - retries: 3 + SCENARIO_RUNNER_PORT: "8080" + secrets: + - source: solmara-demo-client-key + target: solmara-evidence-client.jwk depends_on: - child-benefit-federator: - condition: service_healthy - labels: - solmara.lab.visibility: internal - + mint-readiness: {condition: service_completed_successfully} + labels: {solmara.lab.host: "${SOLMARA_SCENARIO_RUNNER_PUBLIC_HOST:-scenarios.solmara.registrystack.org}"} child-benefit-federator: - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set digest-pinned Solmara scenario runner image} + image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set a digest-pinned Solmara scenario runner image} command: ["python", "scenario-runner/child_benefit_federator.py"] + restart: unless-stopped environment: + <<: *hosted-evidence CHILD_BENEFIT_FEDERATOR_HOST: 0.0.0.0 - CHILD_BENEFIT_FEDERATOR_PORT: 8080 - CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN} - CRA_NOTARY_URL: https://cra-notary.solmara.registrystack.org - CRA_CHILD_BENEFIT_CLIENT_TOKEN: ${CRA_CHILD_BENEFIT_CLIENT_TOKEN} - NIA_NOTARY_URL: https://nia-notary.solmara.registrystack.org - NIA_CHILD_BENEFIT_CLIENT_TOKEN: ${NIA_CHILD_BENEFIT_CLIENT_TOKEN} - SRO_NOTARY_URL: https://sro-notary.solmara.registrystack.org - SRO_CHILD_BENEFIT_CLIENT_TOKEN: ${SRO_CHILD_BENEFIT_CLIENT_TOKEN} - PROGRAMME_NOTARY_URL: https://programme-notary.solmara.registrystack.org - PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN: ${PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN} - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)\""] - interval: 30s - timeout: 10s - retries: 3 - labels: - solmara.lab.host: child-benefit-federator.solmara.registrystack.org - + CHILD_BENEFIT_FEDERATOR_PORT: "8080" + secrets: + - source: solmara-demo-client-key + target: solmara-evidence-client.jwk + labels: {solmara.lab.host: "${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_HOST:-child-benefit.solmara.registrystack.org}"} home: - image: ${SOLMARA_HOME_IMAGE:?set digest-pinned Solmara home image} + image: ${SOLMARA_HOME_IMAGE:?set a digest-pinned Solmara home image} + restart: unless-stopped environment: - HOST: 0.0.0.0 - PORT: 4301 - SOLMARA_LAB_ROOT: /app - REPO_URL: ${REPO_URL:-https://github.com/registrystack/solmara-lab} - PORTAL_URL: https://portal.solmara.registrystack.org - SCENARIO_RUNNER_URL: http://scenario-runner:8080 - STATIC_METADATA_URL: http://static-metadata:8080 - PORTAL_PROBE_URL: http://portal:4000 - CRA_CIVIL_RELAY_URL: https://cra-relay.solmara.registrystack.org - NIA_POPULATION_RELAY_URL: https://nia-relay.solmara.registrystack.org - SRO_SOCIAL_RELAY_URL: https://sro-relay.solmara.registrystack.org - PROGRAMME_MIS_RELAY_URL: https://mosd-programme-relay.solmara.registrystack.org - SIPF_PENSIONS_RELAY_URL: https://sipf-relay.solmara.registrystack.org - NAGDI_AGRICULTURE_RELAY_URL: https://nagdi-relay.solmara.registrystack.org - CHILD_BENEFIT_FEDERATOR_URL: https://child-benefit-federator.solmara.registrystack.org - CRA_NOTARY_URL: https://cra-notary.solmara.registrystack.org - NIA_NOTARY_URL: https://nia-notary.solmara.registrystack.org - SRO_NOTARY_URL: https://sro-notary.solmara.registrystack.org - PROGRAMME_NOTARY_URL: https://programme-notary.solmara.registrystack.org - SIPF_NOTARY_URL: https://sipf-notary.solmara.registrystack.org - NAGDI_NOTARY_URL: https://nagdi-notary.solmara.registrystack.org - HOME_PUBLISHED_TOKENS: >- - {"child-benefit-federator":"${CHILD_BENEFIT_FEDERATOR_TOKEN}", - "cra-pension-client":"${CRA_PENSION_CLIENT_TOKEN}", - "cra-citizen-client":"${CRA_CITIZEN_CLIENT_TOKEN}", - "nia-citizen-client":"${NIA_CITIZEN_CLIENT_TOKEN}", - "sipf-pension-client":"${SIPF_PENSION_CLIENT_TOKEN}", - "nagdi-notary":"${NAGDI_NOTARY_TOKEN}"} + PORTAL_URL: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org} + STATIC_METADATA_URL: ${SOLMARA_METADATA_PUBLIC_BASE_URL:-https://metadata.solmara.registrystack.org} + CRA_CIVIL_RELAY_URL: https://cra-relay-authority-cells.solmara.registrystack.org + NIA_POPULATION_RELAY_URL: https://nia-relay-authority-cells.solmara.registrystack.org + MOSD_PROGRAMME_RELAY_URL: https://mosd-programme-relay-authority-cells.solmara.registrystack.org + SIPF_PENSIONS_RELAY_URL: https://sipf-relay-authority-cells.solmara.registrystack.org + NAGDI_AGRICULTURE_RELAY_URL: https://nagdi-relay-authority-cells.solmara.registrystack.org + SOLMARA_CRA_EVIDENCE_URL: ${SOLMARA_CRA_EVIDENCE_PUBLIC_BASE_URL:-https://cra-evidence.solmara.registrystack.org} + SOLMARA_NIA_EVIDENCE_URL: ${SOLMARA_NIA_EVIDENCE_PUBLIC_BASE_URL:-https://nia-evidence.solmara.registrystack.org} + SOLMARA_SRO_EVIDENCE_URL: ${SOLMARA_SRO_EVIDENCE_PUBLIC_BASE_URL:-https://sro-evidence.solmara.registrystack.org} + SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL: ${SOLMARA_MOSD_PROGRAMME_EVIDENCE_PUBLIC_BASE_URL:-https://mosd-programme-evidence.solmara.registrystack.org} + SOLMARA_SIPF_EVIDENCE_URL: ${SOLMARA_SIPF_EVIDENCE_PUBLIC_BASE_URL:-https://sipf-evidence.solmara.registrystack.org} + SOLMARA_NAGDI_EVIDENCE_URL: ${SOLMARA_NAGDI_EVIDENCE_PUBLIC_BASE_URL:-https://nagdi-evidence.solmara.registrystack.org} + MINT_URL: https://mint-authority-cells.solmara.registrystack.org + CHILD_BENEFIT_FEDERATOR_URL: ${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org} + SCENARIO_RUNNER_URL: ${SOLMARA_SCENARIO_RUNNER_PUBLIC_BASE_URL:-https://scenarios.solmara.registrystack.org} + PORTAL_PROBE_URL: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org} SOLMARA_PUBLIC_URL_MAP: >- - {"child-benefit-federator:8080":"https://child-benefit-federator.solmara.registrystack.org", - "cra-notary:8081":"https://cra-notary.solmara.registrystack.org", - "nia-notary:8081":"https://nia-notary.solmara.registrystack.org", - "sro-notary:8081":"https://sro-notary.solmara.registrystack.org", - "programme-notary:8081":"https://programme-notary.solmara.registrystack.org", - "sipf-notary:8081":"https://sipf-notary.solmara.registrystack.org", - "nagdi-notary:8081":"https://nagdi-notary.solmara.registrystack.org", - "cra-civil-relay:8080":"https://cra-relay.solmara.registrystack.org", - "nia-population-relay:8080":"https://nia-relay.solmara.registrystack.org", - "sro-social-relay:8080":"https://sro-relay.solmara.registrystack.org", - "programme-mis-relay:8080":"https://mosd-programme-relay.solmara.registrystack.org", - "sipf-pensions-relay:8080":"https://sipf-relay.solmara.registrystack.org", - "nagdi-agriculture-relay:8080":"https://nagdi-relay.solmara.registrystack.org", - "static-metadata:8080":"https://metadata.solmara.registrystack.org", - "portal:4000":"https://portal.solmara.registrystack.org"} - UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-} - healthcheck: - test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4301/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] - interval: 30s - timeout: 10s - retries: 3 - depends_on: - static-metadata: - condition: service_started - scenario-runner: - condition: service_healthy - labels: - solmara.lab.host: solmara.registrystack.org - + {"child-benefit-federator:8080":"${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org}", + "deterministic-publisher:8080":"${SOLMARA_METADATA_PUBLIC_BASE_URL:-https://metadata.solmara.registrystack.org}", + "cra-evidence:8080":"${SOLMARA_CRA_EVIDENCE_PUBLIC_BASE_URL:-https://cra-evidence.solmara.registrystack.org}", + "nia-evidence:8080":"${SOLMARA_NIA_EVIDENCE_PUBLIC_BASE_URL:-https://nia-evidence.solmara.registrystack.org}", + "sro-evidence:8080":"${SOLMARA_SRO_EVIDENCE_PUBLIC_BASE_URL:-https://sro-evidence.solmara.registrystack.org}", + "mosd-programme-evidence:8080":"${SOLMARA_MOSD_PROGRAMME_EVIDENCE_PUBLIC_BASE_URL:-https://mosd-programme-evidence.solmara.registrystack.org}", + "sipf-evidence:8080":"${SOLMARA_SIPF_EVIDENCE_PUBLIC_BASE_URL:-https://sipf-evidence.solmara.registrystack.org}", + "nagdi-evidence:8080":"${SOLMARA_NAGDI_EVIDENCE_PUBLIC_BASE_URL:-https://nagdi-evidence.solmara.registrystack.org}", + "mint:8081":"https://mint-authority-cells.solmara.registrystack.org", + "cra-relay:8080":"https://cra-relay-authority-cells.solmara.registrystack.org", + "nia-relay:8080":"https://nia-relay-authority-cells.solmara.registrystack.org", + "mosd-relay:8080":"https://mosd-programme-relay-authority-cells.solmara.registrystack.org", + "sipf-relay:8080":"https://sipf-relay-authority-cells.solmara.registrystack.org", + "nagdi-relay:8080":"https://nagdi-relay-authority-cells.solmara.registrystack.org", + "static-metadata:8080":"${SOLMARA_METADATA_PUBLIC_BASE_URL:-https://metadata.solmara.registrystack.org}", + "scenario-runner:8080":"${SOLMARA_SCENARIO_RUNNER_PUBLIC_BASE_URL:-https://scenarios.solmara.registrystack.org}", + "portal:4000":"${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}"} + labels: {solmara.lab.host: "${SOLMARA_HOME_PUBLIC_HOST:-solmara.registrystack.org}"} portal: - image: ${SOLMARA_PORTAL_IMAGE:?set digest-pinned Solmara portal image} + image: ${SOLMARA_PORTAL_IMAGE:?set a digest-pinned Solmara portal image} + restart: unless-stopped environment: - HOST: 0.0.0.0 - PORT: 4000 + <<: *hosted-evidence PORTAL_PROVIDER: live - PORTAL_SESSION_SECRET: ${PORTAL_SESSION_SECRET} - PORTAL_AUTH_PROVIDER: esignet + PORTAL_AUTH_PROVIDER: ${PORTAL_AUTH_PROVIDER:-mock} PORTAL_SECURE_COOKIES: "true" PORTAL_ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} PORTAL_ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64} - PORTAL_ESIGNET_ISSUER: https://esignet.solmara.registrystack.org - PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT: https://esignet-ui.solmara.registrystack.org/authorize - PORTAL_ESIGNET_TOKEN_ENDPOINT: https://esignet.solmara.registrystack.org/v1/esignet/oauth/v2/token - PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE: https://esignet.solmara.registrystack.org/v1/esignet/oauth/v2/token - PORTAL_ESIGNET_USERINFO_ENDPOINT: https://esignet.solmara.registrystack.org/v1/esignet/oidc/userinfo - PORTAL_ESIGNET_REDIRECT_URI: https://portal.solmara.registrystack.org/auth/callback + PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:-} + PORTAL_ESIGNET_ISSUER: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org} + PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT: ${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize + PORTAL_ESIGNET_TOKEN_ENDPOINT: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token + PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token + PORTAL_ESIGNET_USERINFO_ENDPOINT: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oidc/userinfo + PORTAL_ESIGNET_REDIRECT_URI: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback PORTAL_ESIGNET_SCOPE: openid profile - PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id - CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 - CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN} - CRA_NOTARY_URL: https://cra-notary.solmara.registrystack.org - CRA_PENSION_CLIENT_TOKEN: ${CRA_PENSION_CLIENT_TOKEN} - CRA_CITIZEN_CLIENT_TOKEN: ${CRA_CITIZEN_CLIENT_TOKEN} - NIA_NOTARY_URL: https://nia-notary.solmara.registrystack.org - NIA_CITIZEN_CLIENT_TOKEN: ${NIA_CITIZEN_CLIENT_TOKEN} - SIPF_NOTARY_URL: https://sipf-notary.solmara.registrystack.org - SIPF_PENSION_CLIENT_TOKEN: ${SIPF_PENSION_CLIENT_TOKEN} - NAGDI_NOTARY_URL: https://nagdi-notary.solmara.registrystack.org - NAGDI_NOTARY_TOKEN: ${NAGDI_NOTARY_TOKEN} - SCENARIO_RUNNER_URL: http://scenario-runner:8080 - STATIC_METADATA_URL: http://static-metadata:8080 - healthcheck: - test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] - interval: 30s - timeout: 10s - retries: 3 - depends_on: - scenario-runner: - condition: service_healthy - labels: - solmara.lab.host: portal.solmara.registrystack.org + PORTAL_ESIGNET_SUBJECT_CLAIM: sub + SCENARIO_RUNNER_URL: ${SOLMARA_SCENARIO_RUNNER_PUBLIC_BASE_URL:-https://scenarios.solmara.registrystack.org} + CHILD_BENEFIT_FEDERATOR_URL: ${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org} + secrets: + - source: solmara-demo-client-key + target: solmara-evidence-client.jwk + labels: {solmara.lab.host: "${SOLMARA_PORTAL_PUBLIC_HOST:-portal.solmara.registrystack.org}"} +secrets: + solmara-demo-client-key: + environment: SOLMARA_DEMO_CLIENT_PRIVATE_JWK +volumes: + mint-runtime: {external: true, name: solmara-authority-cells-mint-runtime} + mint-secrets: {external: true, name: solmara-authority-cells-mint-secrets} + mint-transit: {external: true, name: solmara-authority-cells-mint-transit} + mint-audit: +networks: + runtime: + ipam: + config: [{subnet: 172.29.1.0/24}] diff --git a/compose.esignet.yaml b/compose.esignet.yaml index 7124462..dd43ad8 100644 --- a/compose.esignet.yaml +++ b/compose.esignet.yaml @@ -3,23 +3,22 @@ services: build: context: . dockerfile: docker/esignet-postgres/Dockerfile - image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:-solmara-lab-esignet-postgres:local} - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?SOLMARA_ESIGNET_POSTGRES_PASSWORD is required; run just gen-secrets} - volumes: - - esignet-pgdata:/var/lib/postgresql/data + args: {POSTGRES_IMAGE: "${ESIGNET_POSTGRES_IMAGE:?versions.env must pin eSignet database image}"} + image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:-solmara-lab-esignet-db:local} + environment: {POSTGRES_USER: esignet, POSTGRES_PASSWORD: "${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets}"} + volumes: ["esignet-db-data:/var/lib/postgresql"] + networks: [runtime] healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + test: ["CMD-SHELL", "pg_isready -U esignet -d esignet"] interval: 5s timeout: 5s retries: 30 esignet-redis: - image: ${ESIGNET_REDIS_IMAGE:-redis:6.0} + image: ${ESIGNET_REDIS_IMAGE:?versions.env must pin eSignet Redis} command: ["redis-server"] - volumes: - - esignet-redis-data:/data + volumes: ["esignet-redis-data:/data"] + networks: [runtime] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -30,9 +29,11 @@ services: build: context: . dockerfile: docker/esignet-relay/Dockerfile - additional_contexts: - esignet_relay_authenticator_src: ${ESIGNET_RELAY_AUTHENTICATOR_SOURCE_DIR:-../esignet-relay-authenticator} - image: ${SOLMARA_ESIGNET_RELAY_IMAGE:-solmara-lab-esignet-relay:local} + args: + ESIGNET_BASE_IMAGE: ${ESIGNET_BASE_IMAGE:?versions.env must pin eSignet} + ESIGNET_AUTHENTICATOR_JAR_URL: ${ESIGNET_AUTHENTICATOR_JAR_URL:?eSignet authenticator v0.2.0 JAR is not published} + ESIGNET_AUTHENTICATOR_JAR_SHA256: ${ESIGNET_AUTHENTICATOR_JAR_SHA256:?eSignet authenticator v0.2.0 checksum is not published} + image: ${SOLMARA_ESIGNET_RELAY_IMAGE:-solmara-lab-esignet:local} user: root environment: active_profile_env: default,local @@ -51,157 +52,91 @@ services: SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration SPRING_CACHE_TYPE: redis SPRING_DATA_REDIS_HOST: esignet-redis - SPRING_REDIS_PASSWORD: "" MOSIP_ESIGNET_HOST: esignet MOSIP_ESIGNET_DISCOVERY_ISSUER_ID: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} MOSIP_ESIGNET_DISCOVERY_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-http://127.0.0.1:4309}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/v2/token','userinfo_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oidc/userinfo','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" MOSIP_ESIGNET_OAUTH_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-http://127.0.0.1:4309}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/v2/token','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" MOSIP_ESIGNET_DATABASE_URL: jdbc:postgresql://esignet-database:5432/mosip_esignet?currentSchema=esignet - MOSIP_ESIGNET_DATABASE_USERNAME: postgres - MOSIP_ESIGNET_DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?SOLMARA_ESIGNET_POSTGRES_PASSWORD is required; run just gen-secrets} + MOSIP_ESIGNET_DATABASE_USERNAME: esignet + MOSIP_ESIGNET_DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} MOSIP_ESIGNET_INTEGRATION_SCAN_BASE_PACKAGE: io.mosip.esignet.mock.integration,io.registry.esignet.relay MOSIP_ESIGNET_INTEGRATION_AUTHENTICATOR: RelayAuthenticationService MOSIP_ESIGNET_INTEGRATION_KEY_BINDER: MockKeyBindingWrapperService - MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS: "{'profile' : {'name','given_name','family_name','gender','birthdate','individual_id'}}" - REGISTRY_RELAY_BASE_URL: http://nia-population-relay:8080 - REGISTRY_RELAY_ATTRIBUTE_RELEASE_PROFILE_ID: solmara-nia-userinfo - REGISTRY_RELAY_ATTRIBUTE_RELEASE_PROFILE_VERSION: v1 - REGISTRY_RELAY_ATTRIBUTE_RELEASE_PURPOSE: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - REGISTRY_RELAY_AUTH_BEARER_TOKEN_FILE: /run/secrets/solmara-esignet-relay-token - REGISTRY_RELAY_SUBJECT_ID_TYPE: national_id - REGISTRY_RELAY_DEFAULT_CLAIMS: individual_id,name,given_name,family_name,birthdate,gender - REGISTRY_ESIGNET_AUTH_SUPPORTED_FACTORS: OTP - REGISTRY_ESIGNET_AUTH_OTP_CHANNELS: email,phone + MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS: "{'profile' : {'given_name','family_name','gender','birthdate','individual_id'}}" + REGISTRY_RELAY_BASE_URL: http://nia-relay:8080 + REGISTRY_RELAY_RESOURCE: population-person + REGISTRY_RELAY_LOOKUP: esignet-userinfo + REGISTRY_RELAY_ACCESS_PROFILE: esignet + REGISTRY_RELAY_DEFAULT_CLAIMS: individualId,givenName,familyName,birthdate,gender + SPRING_APPLICATION_JSON: '{"registry":{"esignet":{"claim-map":{"sub":"$$psut","individual_id":"individualId","given_name":"givenName","family_name":"familyName","birthdate":"birthdate","gender":"gender"}}}}' + REGISTRY_MINT_TOKEN_ENDPOINT: https://mint.solmara.registrystack.org/token + REGISTRY_MINT_CLIENT_ID: nia-esignet + REGISTRY_MINT_PRIVATE_JWK: ${NIA_ESIGNET_CLIENT_PRIVATE_JWK:?run just gen-secrets} + REGISTRY_MINT_TOKEN_CACHE_MAX_SECONDS: "300" + REGISTRY_TLS_CA_CERT: /etc/solmara-evidence/tls/ca.crt REGISTRY_ESIGNET_AUTH_OTP_STATIC_ENABLED: "true" - REGISTRY_ESIGNET_AUTH_OTP_STATIC_VALUE: "111111" - REGISTRY_ESIGNET_KYC_TOKEN_HMAC_SECRET: ${REGISTRY_ESIGNET_KYC_TOKEN_SECRET:?REGISTRY_ESIGNET_KYC_TOKEN_SECRET is required; run just gen-secrets} - REGISTRY_ESIGNET_PSUT_HMAC_SECRET: ${REGISTRY_ESIGNET_PSUT_SECRET:?REGISTRY_ESIGNET_PSUT_SECRET is required; run just gen-secrets} - REGISTRY_ESIGNET_USER_INFO_ISSUER: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} + REGISTRY_ESIGNET_AUTH_OTP_STATIC_VALUE: ${ESIGNET_DEMO_OTP:-111111} + REGISTRY_ESIGNET_AUTH_OTP_CHANNELS: EMAIL,PHONE + REGISTRY_ESIGNET_ACCOUNT_CHECK_CLAIMS: individualId + MOSIP_ESIGNET_AUTHENTICATOR_IDA_OTP_CHANNELS: EMAIL,PHONE + REGISTRY_ESIGNET_KYC_TOKEN_HMAC_SECRET: ${REGISTRY_ESIGNET_KYC_TOKEN_SECRET:?run just gen-secrets} + REGISTRY_ESIGNET_PSUT_HMAC_SECRET: ${REGISTRY_ESIGNET_PSUT_SECRET:?run just gen-secrets} REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PATH: /home/mosip/kyc-signing/kyc-signing.p12 REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_TYPE: PKCS12 - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD is required; run just gen-secrets} + REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?run just gen-secrets} REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS: esignet-relay-kyc - REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD is required; run just gen-secrets} + REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?run just gen-secrets} MOSIP_KERNEL_KEYMANAGER_HSM_CONFIG_PATH: /home/mosip/keystore/esignet_local.p12 - volumes: - - esignet-keystore:/home/mosip/keystore - - esignet-kyc-signing:/home/mosip/kyc-signing - - nia-esignet-workload-token:/run/secrets:ro - depends_on: - esignet-database: - condition: service_healthy - esignet-redis: - condition: service_healthy - nia-population-relay: - condition: service_healthy - nia-workload-agent: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-fsS", "http://esignet:8088/v1/esignet/oidc/.well-known/openid-configuration"] - interval: 10s - timeout: 5s - retries: 60 - start_period: 90s + volumes: ["esignet-keystore:/home/mosip/keystore", "esignet-kyc-signing:/home/mosip/kyc-signing", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro"] + extra_hosts: ["mint.solmara.registrystack.org:172.29.1.10"] + networks: [runtime] + depends_on: {esignet-database: {condition: service_healthy}, esignet-redis: {condition: service_healthy}, nia-relay: {condition: service_healthy}, mint: {condition: service_started}} esignet-ui: - build: - context: . - dockerfile: docker/esignet-ui/Dockerfile - args: - ESIGNET_NGINX_CONF: config/esignet/nginx.conf + build: {context: ., dockerfile: docker/esignet-ui/Dockerfile, args: {ESIGNET_UI_IMAGE: "${ESIGNET_UI_IMAGE:?versions.env must pin eSignet UI}", ESIGNET_NGINX_CONF: config/esignet/nginx.conf}} image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-ui:local} - user: root - environment: - container_user: mosip - DEFAULT_WELLKNOWN: "%5B%7B%22name%22%3A%22OpenID%20Configuration%22%2C%22value%22%3A%22%2F.well-known%2Fopenid-configuration%22%7D%2C%7B%22name%22%3A%22Jwks%20Json%22%2C%22value%22%3A%22%2F.well-known%2Fjwks.json%22%7D%2C%7B%22name%22%3A%22Authorization%20Server%22%2C%22value%22%3A%22%2F.well-known%2Foauth-authorization-server%22%7D%5D" - SIGN_IN_WITH_ESIGNET_PLUGIN_URL: https://raw.githubusercontent.com/mosip/artifactory-ref-impl/977607990661e864c2adddb9d7de50ea6c651b8f/artifacts/src/mosip-plugins/sign-in-with-esignet/sign-in-with-esignet.zip - i18n_url_env: "" - theme_url_env: "" - images_url_env: "" - ports: - - "${SOLMARA_ESIGNET_UI_PORT:-4309}:3000" - depends_on: - esignet: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://esignet-ui:3000/.well-known/openid-configuration"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 30s + ports: ["${SOLMARA_ESIGNET_UI_PORT:-4309}:3000"] + networks: [runtime] + depends_on: {esignet: {condition: service_started}} esignet-edge: - build: - context: . - dockerfile: docker/esignet-ui/Dockerfile - args: - ESIGNET_NGINX_CONF: config/esignet/nginx.conf + build: {context: ., dockerfile: docker/esignet-ui/Dockerfile, args: {ESIGNET_UI_IMAGE: "${ESIGNET_UI_IMAGE:?versions.env must pin eSignet UI}", ESIGNET_NGINX_CONF: config/esignet/nginx.conf}} image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-ui:local} - user: root - environment: - container_user: mosip - DEFAULT_WELLKNOWN: "%5B%7B%22name%22%3A%22OpenID%20Configuration%22%2C%22value%22%3A%22%2F.well-known%2Fopenid-configuration%22%7D%2C%7B%22name%22%3A%22Jwks%20Json%22%2C%22value%22%3A%22%2F.well-known%2Fjwks.json%22%7D%2C%7B%22name%22%3A%22Authorization%20Server%22%2C%22value%22%3A%22%2F.well-known%2Foauth-authorization-server%22%7D%5D" - SIGN_IN_WITH_ESIGNET_PLUGIN_URL: https://raw.githubusercontent.com/mosip/artifactory-ref-impl/977607990661e864c2adddb9d7de50ea6c651b8f/artifacts/src/mosip-plugins/sign-in-with-esignet/sign-in-with-esignet.zip - i18n_url_env: "" - theme_url_env: "" - images_url_env: "" - ports: - - "${SOLMARA_ESIGNET_PORT:-4308}:3000" - depends_on: - esignet: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://esignet-edge:3000/.well-known/openid-configuration"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 30s + ports: ["${SOLMARA_ESIGNET_PORT:-4308}:3000"] + networks: [runtime] + depends_on: {esignet: {condition: service_started}} esignet-seed: - build: - context: . - dockerfile: docker/esignet-seed/Dockerfile + build: {context: ., dockerfile: docker/esignet-seed/Dockerfile, args: {POSTGRES_IMAGE: "${ESIGNET_POSTGRES_IMAGE:?versions.env must pin eSignet database image}"}} image: ${SOLMARA_ESIGNET_SEED_IMAGE:-solmara-lab-esignet-seed:local} environment: PGHOST: esignet-database - PGPORT: "5432" - PGUSER: postgres - PGPASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?SOLMARA_ESIGNET_POSTGRES_PASSWORD is required; run just gen-secrets} + PGUSER: esignet + PGPASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 is required; run just gen-secrets} - ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}/auth/callback","http://localhost:${SOLMARA_PORTAL_PORT:-4300}/auth/callback"]' + ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?run just gen-secrets} ESIGNET_REDIS_HOST: esignet-redis - ESIGNET_ACCOUNT_SOURCE_LABEL: NIA population Relay profile solmara-nia-userinfo - ESIGNET_DEMO_OTP: "111111" entrypoint: ["seed-esignet.py"] - depends_on: - esignet-database: - condition: service_healthy - esignet: - condition: service_healthy - restart: "no" + networks: [runtime] + depends_on: {esignet-database: {condition: service_healthy}, esignet: {condition: service_started}} portal: environment: PORTAL_AUTH_PROVIDER: esignet PORTAL_ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} PORTAL_ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 is required; run just gen-secrets} + PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?run just gen-secrets} PORTAL_ESIGNET_ISSUER: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT: ${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-http://127.0.0.1:4309}/authorize PORTAL_ESIGNET_TOKEN_ENDPOINT: http://esignet:8088/v1/esignet/oauth/v2/token PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/v2/token PORTAL_ESIGNET_USERINFO_ENDPOINT: http://esignet:8088/v1/esignet/oidc/userinfo PORTAL_ESIGNET_REDIRECT_URI: http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}/auth/callback - PORTAL_ESIGNET_SCOPE: openid profile - PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id - depends_on: - esignet-seed: - condition: service_completed_successfully + PORTAL_ESIGNET_SUBJECT_CLAIM: sub volumes: - esignet-pgdata: + esignet-db-data: esignet-keystore: esignet-kyc-signing: esignet-redis-data: diff --git a/compose.hosted.yaml b/compose.hosted.yaml index 8368f50..8cec047 100644 --- a/compose.hosted.yaml +++ b/compose.hosted.yaml @@ -1,345 +1,85 @@ +# Overlay for the side-by-side authority-cell rollout. The previous deployment's +# volumes remain externally named and are never attached, changed, or deleted. +x-solmara-rollout: + phase: ${SOLMARA_AUTHORITY_CELLS_PHASE:-side-by-side} + switch: ${SOLMARA_AUTHORITY_CELLS_SWITCH:-disabled} + superseded-volume-policy: retain + +x-hosted-relay: &hosted-relay + image: ${REGISTRY_RELAY_IMAGE:?set the digest-pinned official Registry Stack Relay image} + build: !reset null + labels: + solmara.rollout.slot: authority-cells + solmara.rollout.phase: ${SOLMARA_AUTHORITY_CELLS_PHASE:-side-by-side} + +x-hosted-evidence: &hosted-evidence + image: ${SOLMARA_EVIDENCE_IMAGE:?set the digest-pinned official Registry Stack Evidence image} + build: !reset null + labels: + solmara.rollout.slot: authority-cells + solmara.rollout.phase: ${SOLMARA_AUTHORITY_CELLS_PHASE:-side-by-side} + services: - postgres: - ports: !reset [] + cra-relay: {<<: *hosted-relay} + nia-relay: {<<: *hosted-relay} + mosd-relay: {<<: *hosted-relay} + sipf-relay: {<<: *hosted-relay} + nagdi-relay: {<<: *hosted-relay} + + cra-evidence: {<<: *hosted-evidence} + nia-evidence: {<<: *hosted-evidence} + sro-evidence: {<<: *hosted-evidence} + mosd-programme-evidence: {<<: *hosted-evidence} + sipf-evidence: {<<: *hosted-evidence} + nagdi-evidence: {<<: *hosted-evidence} + + mint: + image: ${SOLMARA_MINT_IMAGE:?set the digest-pinned official Registry Stack Mint image} + build: !reset null + labels: + solmara.rollout.slot: authority-cells + solmara.rollout.phase: ${SOLMARA_AUTHORITY_CELLS_PHASE:-side-by-side} static-metadata: - ports: !reset [] - labels: - solmara.lab.host: metadata.solmara.registrystack.org - + image: ${SOLMARA_STATIC_METADATA_IMAGE:?set a digest-pinned Solmara static metadata image} scenario-runner: - profiles: [] - labels: - solmara.lab.visibility: internal - - home: - profiles: [] - ports: !reset [] + image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set a digest-pinned Solmara scenario runner image} + build: !reset null environment: - PORTAL_URL: https://portal.solmara.registrystack.org - # Rewrites the compose-internal URLs the scenario runner reports into the - # host-reachable *.solmara.registrystack.org URLs shown in previews and curls. - SOLMARA_PUBLIC_URL_MAP: >- - {"child-benefit-federator:8080":"https://child-benefit-federator.solmara.registrystack.org", - "cra-notary:8081":"https://cra-notary.solmara.registrystack.org", - "nia-notary:8081":"https://nia-notary.solmara.registrystack.org", - "sro-notary:8081":"https://sro-notary.solmara.registrystack.org", - "programme-notary:8081":"https://programme-notary.solmara.registrystack.org", - "sipf-notary:8081":"https://sipf-notary.solmara.registrystack.org", - "nagdi-notary:8081":"https://nagdi-notary.solmara.registrystack.org", - "cra-civil-relay:8080":"https://cra-relay.solmara.registrystack.org", - "nia-population-relay:8080":"https://nia-relay.solmara.registrystack.org", - "sro-social-relay:8080":"https://sro-relay.solmara.registrystack.org", - "programme-mis-relay:8080":"https://mosd-programme-relay.solmara.registrystack.org", - "sipf-pensions-relay:8080":"https://sipf-relay.solmara.registrystack.org", - "nagdi-agriculture-relay:8080":"https://nagdi-relay.solmara.registrystack.org", - "static-metadata:8080":"https://metadata.solmara.registrystack.org", - "portal:4000":"https://portal.solmara.registrystack.org"} - labels: - solmara.lab.host: solmara.registrystack.org - - cra-relay-state-bootstrap: - volumes: - - ./runtime/registry-projects/hosted/cra-civil/relay:/etc/registry-relay:ro - - nia-relay-state-bootstrap: - volumes: - - ./runtime/registry-projects/hosted/nia-population/relay:/etc/registry-relay:ro - - sro-relay-state-bootstrap: - volumes: - - ./runtime/registry-projects/hosted/sro-social/relay:/etc/registry-relay:ro - - programme-relay-state-bootstrap: - volumes: - - ./runtime/registry-projects/hosted/mosd-programme/relay:/etc/registry-relay:ro - - sipf-relay-state-bootstrap: - volumes: - - ./runtime/registry-projects/hosted/sipf-pensions/relay:/etc/registry-relay:ro - - nagdi-relay-state-bootstrap: - volumes: - - ./runtime/registry-projects/hosted/nagdi-agriculture/relay:/etc/registry-relay:ro - - # Hosted Relay configs trust the platform workload issuer. Its rotating - # tokens arrive through external authority volumes, so the loopback local - # agents must not start in this overlay. - cra-workload-agent: - profiles: [local-workload-issuer] - - nia-workload-agent: - profiles: [local-workload-issuer] - - sro-workload-agent: - profiles: [local-workload-issuer] - - programme-workload-agent: - profiles: [local-workload-issuer] - - sipf-workload-agent: - profiles: [local-workload-issuer] - - nagdi-workload-agent: - profiles: [local-workload-issuer] - - cra-consultation-workload-agent: - profiles: [local-workload-issuer] - - nia-consultation-workload-agent: - profiles: [local-workload-issuer] - - sro-consultation-workload-agent: - profiles: [local-workload-issuer] - - programme-consultation-workload-agent: - profiles: [local-workload-issuer] - - sipf-consultation-workload-agent: - profiles: [local-workload-issuer] - - nagdi-consultation-workload-agent: - profiles: [local-workload-issuer] - - cra-civil-relay: - ports: !reset [] - expose: ["8080"] - volumes: - - ./runtime/registry-projects/hosted/cra-civil/relay:/etc/registry-relay:ro - labels: - solmara.lab.host: cra-relay.solmara.registrystack.org - - nia-population-relay: - ports: !reset [] - expose: ["8080"] - volumes: - - ./runtime/registry-projects/hosted/nia-population/relay:/etc/registry-relay:ro - labels: - solmara.lab.host: nia-relay.solmara.registrystack.org - - sro-social-relay: - ports: !reset [] - expose: ["8080"] - volumes: - - ./runtime/registry-projects/hosted/sro-social/relay:/etc/registry-relay:ro - labels: - solmara.lab.host: sro-relay.solmara.registrystack.org - - programme-mis-relay: - ports: !reset [] - expose: ["8080"] - volumes: - - ./runtime/registry-projects/hosted/mosd-programme/relay:/etc/registry-relay:ro - labels: - solmara.lab.host: mosd-programme-relay.solmara.registrystack.org - - sipf-pensions-relay: - ports: !reset [] - expose: ["8080"] - volumes: - - ./runtime/registry-projects/hosted/sipf-pensions/relay:/etc/registry-relay:ro - labels: - solmara.lab.host: sipf-relay.solmara.registrystack.org - - nagdi-agriculture-relay: - ports: !reset [] - expose: ["8080"] - volumes: - - ./runtime/registry-projects/hosted/nagdi-agriculture/relay:/etc/registry-relay:ro - labels: - solmara.lab.host: nagdi-relay.solmara.registrystack.org - - cra-civil-relay-consultation: - ports: !reset [] - expose: ["8081"] - volumes: - - ./runtime/registry-projects/hosted/cra-civil/relay:/etc/registry-relay:ro - depends_on: !override - cra-relay-state-bootstrap: - condition: service_completed_successfully - - nia-population-relay-consultation: - ports: !reset [] - expose: ["8081"] - volumes: - - ./runtime/registry-projects/hosted/nia-population/relay:/etc/registry-relay:ro - depends_on: !override - nia-relay-state-bootstrap: - condition: service_completed_successfully - - sro-social-relay-consultation: - ports: !reset [] - expose: ["8081"] - volumes: - - ./runtime/registry-projects/hosted/sro-social/relay:/etc/registry-relay:ro - depends_on: !override - sro-relay-state-bootstrap: - condition: service_completed_successfully - - programme-mis-relay-consultation: - ports: !reset [] - expose: ["8081"] - volumes: - - ./runtime/registry-projects/hosted/mosd-programme/relay:/etc/registry-relay:ro - depends_on: !override - programme-relay-state-bootstrap: - condition: service_completed_successfully - - sipf-pensions-relay-consultation: - ports: !reset [] - expose: ["8081"] - volumes: - - ./runtime/registry-projects/hosted/sipf-pensions/relay:/etc/registry-relay:ro - depends_on: !override - sipf-relay-state-bootstrap: - condition: service_completed_successfully - - nagdi-agriculture-relay-consultation: - ports: !reset [] - expose: ["8081"] - volumes: - - ./runtime/registry-projects/hosted/nagdi-agriculture/relay:/etc/registry-relay:ro - depends_on: !override - nagdi-relay-state-bootstrap: - condition: service_completed_successfully - - cra-notary: - volumes: - - ./runtime/registry-projects/hosted/cra-civil/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - labels: - solmara.lab.host: cra-notary.solmara.registrystack.org - depends_on: !override - cra-civil-relay-consultation: - condition: service_healthy - cra-notary-state-install: - condition: service_completed_successfully - - cra-notary-state-install: - volumes: - - ./runtime/registry-projects/hosted/cra-civil/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - depends_on: !override - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nia-notary: - volumes: - - ./runtime/registry-projects/hosted/nia-population/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - labels: - solmara.lab.host: nia-notary.solmara.registrystack.org - depends_on: !override - nia-population-relay-consultation: - condition: service_healthy - nia-notary-state-install: - condition: service_completed_successfully - - nia-notary-state-install: - volumes: - - ./runtime/registry-projects/hosted/nia-population/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - depends_on: !override - registry-postgresql-bootstrap: - condition: service_completed_successfully - - sro-notary: - volumes: - - ./runtime/registry-projects/hosted/sro-social/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - labels: - solmara.lab.host: sro-notary.solmara.registrystack.org - depends_on: !override - sro-social-relay-consultation: - condition: service_healthy - sro-notary-state-install: - condition: service_completed_successfully - - sro-notary-state-install: - volumes: - - ./runtime/registry-projects/hosted/sro-social/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - depends_on: !override - registry-postgresql-bootstrap: - condition: service_completed_successfully - - programme-notary: - volumes: - - ./runtime/registry-projects/hosted/mosd-programme/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - labels: - solmara.lab.host: programme-notary.solmara.registrystack.org - depends_on: !override - programme-mis-relay-consultation: - condition: service_healthy - programme-notary-state-install: - condition: service_completed_successfully - - programme-notary-state-install: - volumes: - - ./runtime/registry-projects/hosted/mosd-programme/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - depends_on: !override - registry-postgresql-bootstrap: - condition: service_completed_successfully - - sipf-notary: - volumes: - - ./runtime/registry-projects/hosted/sipf-pensions/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - labels: - solmara.lab.host: sipf-notary.solmara.registrystack.org - depends_on: !override - sipf-pensions-relay-consultation: - condition: service_healthy - sipf-notary-state-install: - condition: service_completed_successfully - - sipf-notary-state-install: - volumes: - - ./runtime/registry-projects/hosted/sipf-pensions/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - depends_on: !override - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nagdi-notary: - volumes: - - ./runtime/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - labels: - solmara.lab.host: nagdi-notary.solmara.registrystack.org - depends_on: !override - nagdi-agriculture-relay-consultation: - condition: service_healthy - nagdi-notary-state-install: - condition: service_completed_successfully - - nagdi-notary-state-install: - volumes: - - ./runtime/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - depends_on: !override - registry-postgresql-bootstrap: - condition: service_completed_successfully - + SOLMARA_LAB_MODE: hosted + SOLMARA_MINT_URL: https://mint.solmara.registrystack.org + SOLMARA_CRA_EVIDENCE_URL: https://cra-evidence.solmara.registrystack.org + SOLMARA_NIA_EVIDENCE_URL: https://nia-evidence.solmara.registrystack.org + SOLMARA_SRO_EVIDENCE_URL: https://sro-evidence.solmara.registrystack.org + SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL: https://mosd-programme-evidence.solmara.registrystack.org + SOLMARA_SIPF_EVIDENCE_URL: https://sipf-evidence.solmara.registrystack.org + SOLMARA_NAGDI_EVIDENCE_URL: https://nagdi-evidence.solmara.registrystack.org child-benefit-federator: - ports: !reset [] + image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:?set a digest-pinned Solmara scenario runner image} + build: !reset null environment: - CHILD_BENEFIT_PUBLIC_DOMAIN: solmara.registrystack.org - labels: - solmara.lab.host: child-benefit-federator.solmara.registrystack.org - + SOLMARA_MINT_URL: https://mint.solmara.registrystack.org + SOLMARA_CRA_EVIDENCE_URL: https://cra-evidence.solmara.registrystack.org + SOLMARA_NIA_EVIDENCE_URL: https://nia-evidence.solmara.registrystack.org + SOLMARA_SRO_EVIDENCE_URL: https://sro-evidence.solmara.registrystack.org + SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL: https://mosd-programme-evidence.solmara.registrystack.org portal: - profiles: [] - ports: !reset [] - labels: - solmara.lab.host: portal.solmara.registrystack.org + image: ${SOLMARA_PORTAL_IMAGE:?set a digest-pinned Solmara portal image} + build: !reset null + home: + image: ${SOLMARA_HOME_IMAGE:?set a digest-pinned Solmara home image} + build: !reset null volumes: - cra-consultation-workload-token: - external: true - name: ${CRA_WORKLOAD_TOKEN_VOLUME:-solmara-cra-workload-token} - nia-consultation-workload-token: - external: true - name: ${NIA_WORKLOAD_TOKEN_VOLUME:-solmara-nia-workload-token} - sro-consultation-workload-token: + superseded-interior-state: external: true - name: ${SRO_WORKLOAD_TOKEN_VOLUME:-solmara-sro-workload-token} - programme-consultation-workload-token: + name: ${SOLMARA_SUPERSEDED_INTERIOR_VOLUME:-solmara-lab-interior-state} + superseded-social-development-state: external: true - name: ${PROGRAMME_WORKLOAD_TOKEN_VOLUME:-solmara-programme-workload-token} - sipf-consultation-workload-token: + name: ${SOLMARA_SUPERSEDED_SOCIAL_VOLUME:-solmara-lab-social-development-state} + superseded-pensions-state: external: true - name: ${SIPF_WORKLOAD_TOKEN_VOLUME:-solmara-sipf-workload-token} - nagdi-consultation-workload-token: + name: ${SOLMARA_SUPERSEDED_PENSIONS_VOLUME:-solmara-lab-pensions-state} + superseded-agriculture-state: external: true - name: ${NAGDI_WORKLOAD_TOKEN_VOLUME:-solmara-nagdi-workload-token} + name: ${SOLMARA_SUPERSEDED_AGRICULTURE_VOLUME:-solmara-lab-agriculture-state} diff --git a/compose.yaml b/compose.yaml index 535bcda..5af6d5c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,1097 +1,706 @@ -x-relay-environment: &relay-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_RELAY_BIND: 0.0.0.0:8080 +name: ${COMPOSE_PROJECT_NAME:-solmara-lab} -x-notary-environment: ¬ary-environment - RUST_LOG: ${RUST_LOG:-info} - REGISTRY_NOTARY_BIND: 0.0.0.0:8081 - REGISTRY_NOTARY_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - -x-relay-common: &relay-common - image: ${REGISTRY_RELAY_IMAGE:-ghcr.io/registrystack/registry-relay@sha256:71082176f00107e420ffe79471f0bd3375c3dd1ae1c3c8c3a24100c602fa82b7} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} +x-relay: &relay + image: ${REGISTRY_RELAY_IMAGE:?versions.env must name the digest-pinned Relay V2 image} + platform: linux/amd64 + user: "65532:65532" restart: unless-stopped - environment: *relay-environment + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp] + networks: [runtime, issuer-validation] + depends_on: {relay-audit-init: {condition: service_completed_successfully}} healthcheck: - test: ["CMD", "/usr/local/bin/registry-relay", "healthcheck"] - interval: 30s - timeout: 10s - retries: 3 + test: ["CMD", "/usr/local/bin/relay", "healthcheck", "--url", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 5s + retries: 12 -x-notary-common: ¬ary-common - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - user: "65534:65534" +x-evidence: &evidence + image: ${SOLMARA_EVIDENCE_IMAGE:?versions.env must name the digest-pinned official Registry Stack Evidence image} + platform: linux/amd64 restart: unless-stopped - environment: *notary-environment - healthcheck: - test: - [ - "CMD", - "/usr/local/bin/registry-notary", - "healthcheck", - "--url", - "http://127.0.0.1:8081/ready", - ] - interval: 30s - timeout: 10s - retries: 3 - -x-relay-bootstrap-common: &relay-bootstrap-common - image: ${REGISTRY_RELAY_IMAGE:-ghcr.io/registrystack/registry-relay@sha256:71082176f00107e420ffe79471f0bd3375c3dd1ae1c3c8c3a24100c602fa82b7} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} + user: "0:0" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp] + environment: {SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt} + networks: [runtime, issuer-validation] + +# Docker Desktop and Linux preserve different owners for host bind mounts. Copy +# each authority's closed secret set into its own volume so the official +# runtimes see the exact ownership and mode required by SecretResolver. +x-authority-secret-stager: &authority-secret-stager + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} + entrypoint: + - python + - -c + - |- + import os + import stat + import sys + + max_secret_bytes = 64 * 1024 + expected = set(sys.argv[2:]) + if sys.argv[1:2] != ['stage'] or not expected or len(expected) != len(sys.argv) - 2: + raise RuntimeError('invalid secret allowlist') + source_fd = os.open('/source', os.O_RDONLY | os.O_DIRECTORY) + destination_fd = os.open('/staged', os.O_RDONLY | os.O_DIRECTORY) + try: + os.fchown(destination_fd, 0, 0) + os.fchmod(destination_fd, 0o700) + with os.scandir(source_fd) as entries: + source_entries = {entry.name: entry.stat(follow_symlinks=False) for entry in entries} + if set(source_entries) != expected: + raise RuntimeError('secret source does not match allowlist') + for metadata in source_entries.values(): + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_size > max_secret_bytes or stat.S_IMODE(metadata.st_mode) not in {0o400, 0o600}: + raise RuntimeError('secret source has unsafe metadata') + + with os.scandir(destination_fd) as entries: + destination_entries = {entry.name: entry.stat(follow_symlinks=False) for entry in entries} + permitted_destination_names = expected | {f'.stage-{name}' for name in expected} + if not set(destination_entries) <= permitted_destination_names: + raise RuntimeError('secret destination contains unexpected entries') + for name, metadata in destination_entries.items(): + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise RuntimeError('secret destination has unsafe metadata') + if name.startswith('.stage-'): + os.unlink(name, dir_fd=destination_fd) + + staged = [] + for name in sorted(expected): + metadata = source_entries[name] + source = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=source_fd) + temporary = f'.stage-{name}' + destination = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=destination_fd, + ) + try: + opened = os.fstat(source) + if (opened.st_dev, opened.st_ino) != (metadata.st_dev, metadata.st_ino) or not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 or opened.st_size > max_secret_bytes or stat.S_IMODE(opened.st_mode) not in {0o400, 0o600}: + raise RuntimeError('secret source changed during staging') + copied = 0 + while chunk := os.read(source, min(65536, max_secret_bytes + 1 - copied)): + copied += len(chunk) + if copied > max_secret_bytes: + raise RuntimeError('secret source changed during staging') + remaining = memoryview(chunk) + while remaining: + remaining = remaining[os.write(destination, remaining):] + os.fchmod(destination, 0o600) + os.fchown(destination, 0, 0) + os.fsync(destination) + finally: + os.close(destination) + os.close(source) + staged.append((temporary, name)) + for temporary, name in staged: + os.replace(temporary, name, src_dir_fd=destination_fd, dst_dir_fd=destination_fd) + os.fsync(destination_fd) + finally: + os.close(destination_fd) + os.close(source_fd) + user: "0:0" + read_only: true + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE] + security_opt: [no-new-privileges:true] + network_mode: none restart: "no" -x-workload-agent-common: &workload-agent-common - build: - context: . - dockerfile: scenario-runner/Dockerfile +# Docker Desktop cannot carry a host Unix listener through a bind mount. Each +# local authority therefore gets one isolated signer container and one private +# named socket volume. Registry services receive the socket, never the key. +x-transit-signer: &transit-signer image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:-solmara-lab-scenario-runner:local} - command: ["python", "scripts/relay_workload_identity_agent.py"] restart: unless-stopped + user: "0:0" + read_only: true + cap_drop: [ALL] + # Linux CI bind mounts retain the runner's ownership on the mode-0600 key. + # Grant only the capability needed to read that one read-only bind. Hosted + # signers stage operator-owned keys and do not use this local exception. + cap_add: [DAC_OVERRIDE] + security_opt: [no-new-privileges:true] + network_mode: none healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)\""] - interval: 10s - timeout: 5s - retries: 6 + test: ["CMD", "python", "-c", "import socket; s=socket.socket(socket.AF_UNIX); s.connect('/transit/transit-proxy.sock'); s.close()"] + interval: 2s + timeout: 1s + retries: 15 + +# Docker Desktop does not reliably invalidate SQLite pages held by a long-lived +# reader when the database is a host bind mount. Keep each authority's mutable +# publication on its own Linux volume, with a separate no-network publisher as +# the only writer. Relays receive only their authority volume and mount it RO. +x-relay-source-publisher: &relay-source-publisher + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} + user: "0:0" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + network_mode: none + tmpfs: [/tmp] + restart: "no" + +# Native Linux preserves host ownership on bind mounts, while Relay accepts a +# runtime only when every path component is owned by root or its effective UID. +# Stage each authority's public runtime and sealed package into a dedicated +# volume so the official image can keep its fixed unprivileged identity. +x-relay-runtime-stager: &relay-runtime-stager + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} + entrypoint: ["python", "/app/local-relay-runtime-stager.py"] + user: "0:0" + read_only: true + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, FOWNER] + security_opt: [no-new-privileges:true] + network_mode: none + tmpfs: [/tmp] + restart: "no" + +x-evidence-client-environment: &evidence-client-environment + SOLMARA_MINT_URL: https://mint.solmara.registrystack.org + SOLMARA_MINT_ASSERTION_AUDIENCE: https://mint.solmara.registrystack.org/token + SOLMARA_EVIDENCE_CLIENT_ID: solmara-demo + SOLMARA_EVIDENCE_CLIENT_KEY: /run/secrets/solmara-evidence-client.jwk + SOLMARA_EVIDENCE_CA_BUNDLE: /etc/solmara-evidence/tls/ca.crt + SOLMARA_CRA_EVIDENCE_URL: https://evidence.solmara.invalid/evidence/cra + SOLMARA_NIA_EVIDENCE_URL: https://evidence.solmara.invalid/evidence/nia + SOLMARA_SRO_EVIDENCE_URL: https://evidence.solmara.invalid/evidence/sro + SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL: https://evidence.solmara.invalid/evidence/mosd-programme + SOLMARA_SIPF_EVIDENCE_URL: https://evidence.solmara.invalid/evidence/sipf + SOLMARA_NAGDI_EVIDENCE_URL: https://evidence.solmara.invalid/evidence/nagdi + SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt services: - postgres: - image: ${POSTGRES_IMAGE:-postgres:16-alpine} - entrypoint: ["/bin/sh", "/usr/local/bin/solmara-postgres-entrypoint.sh"] + mint-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key] + volumes: ["./runtime/evidence-cells/secrets/mint:/source:ro", "mint-runtime-secrets:/staged"] + + cra-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key, subject-binding-hmac-key, cra-pension-evidence-client-id, cra-pension-evidence-client-key, cra-citizen-evidence-client-id, cra-citizen-evidence-client-key] + volumes: ["./runtime/evidence-cells/secrets/cra:/source:ro", "cra-evidence-runtime-secrets:/staged"] + + nia-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key, subject-binding-hmac-key] + volumes: ["./runtime/evidence-cells/secrets/nia:/source:ro", "nia-evidence-runtime-secrets:/staged"] + + sro-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key, subject-binding-hmac-key] + volumes: ["./runtime/evidence-cells/secrets/sro:/source:ro", "sro-evidence-runtime-secrets:/staged"] + + mosd-programme-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key, subject-binding-hmac-key, mosd-child-benefit-evidence-client-id, mosd-child-benefit-evidence-client-key] + volumes: ["./runtime/evidence-cells/secrets/mosd-programme:/source:ro", "mosd-programme-evidence-runtime-secrets:/staged"] + + sipf-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key, subject-binding-hmac-key, sipf-pension-evidence-client-id, sipf-pension-evidence-client-key, sipf-survivor-evidence-client-id, sipf-survivor-evidence-client-key] + volumes: ["./runtime/evidence-cells/secrets/sipf:/source:ro", "sipf-evidence-runtime-secrets:/staged"] + + nagdi-secret-stager: + <<: *authority-secret-stager + command: [stage, audit-hmac-key, subject-binding-hmac-key, nagdi-voucher-evidence-client-id, nagdi-voucher-evidence-client-key, nagdi-livestock-evidence-client-id, nagdi-livestock-evidence-client-key] + volumes: ["./runtime/evidence-cells/secrets/nagdi:/source:ro", "nagdi-evidence-runtime-secrets:/staged"] + + cra-source-publisher: + <<: *relay-source-publisher + entrypoint: ["python", "/app/local-relay-source-publisher.py", "--authority", "cra", "--database", "/var/lib/relay/source/cra.sqlite", "--seed", "/seed/cra.sqlite"] + command: ["ensure-seeded"] + volumes: ["./scripts/local-relay-source-publisher.py:/app/local-relay-source-publisher.py:ro", "./generator/solmara_lab:/app/solmara_lab:ro", "./output/sqlite/relay/cra.sqlite:/seed/cra.sqlite:ro", "cra-relay-source:/var/lib/relay/source"] + + nia-source-publisher: + <<: *relay-source-publisher + entrypoint: ["python", "/app/local-relay-source-publisher.py", "--authority", "nia", "--database", "/var/lib/relay/source/nia.sqlite", "--seed", "/seed/nia.sqlite"] + command: ["ensure-seeded"] + volumes: ["./scripts/local-relay-source-publisher.py:/app/local-relay-source-publisher.py:ro", "./generator/solmara_lab:/app/solmara_lab:ro", "./output/sqlite/relay/nia.sqlite:/seed/nia.sqlite:ro", "nia-relay-source:/var/lib/relay/source"] + + mosd-source-publisher: + <<: *relay-source-publisher + entrypoint: ["python", "/app/local-relay-source-publisher.py", "--authority", "mosd", "--database", "/var/lib/relay/source/mosd.sqlite", "--seed", "/seed/mosd.sqlite"] + command: ["ensure-seeded"] + volumes: ["./scripts/local-relay-source-publisher.py:/app/local-relay-source-publisher.py:ro", "./generator/solmara_lab:/app/solmara_lab:ro", "./output/sqlite/relay/mosd.sqlite:/seed/mosd.sqlite:ro", "mosd-relay-source:/var/lib/relay/source"] + + sipf-source-publisher: + <<: *relay-source-publisher + entrypoint: ["python", "/app/local-relay-source-publisher.py", "--authority", "sipf", "--database", "/var/lib/relay/source/sipf.sqlite", "--seed", "/seed/sipf.sqlite"] + command: ["ensure-seeded"] + volumes: ["./scripts/local-relay-source-publisher.py:/app/local-relay-source-publisher.py:ro", "./generator/solmara_lab:/app/solmara_lab:ro", "./output/sqlite/relay/sipf.sqlite:/seed/sipf.sqlite:ro", "sipf-relay-source:/var/lib/relay/source"] + + nagdi-source-publisher: + <<: *relay-source-publisher + entrypoint: ["python", "/app/local-relay-source-publisher.py", "--authority", "nagdi", "--database", "/var/lib/relay/source/nagdi.sqlite", "--seed", "/seed/nagdi.sqlite"] + command: ["ensure-seeded"] + volumes: ["./scripts/local-relay-source-publisher.py:/app/local-relay-source-publisher.py:ro", "./generator/solmara_lab:/app/solmara_lab:ro", "./output/sqlite/relay/nagdi.sqlite:/seed/nagdi.sqlite:ro", "nagdi-relay-source:/var/lib/relay/source"] + + cra-relay-runtime-stager: + <<: *relay-runtime-stager + command: ["--authority", "cra", "--source", "/source", "--destination", "/staged", "stage"] + volumes: ["./scripts/local-relay-runtime-stager.py:/app/local-relay-runtime-stager.py:ro", "./relays/cra/runtime.yaml:/source/runtime.yaml:ro", "./relays/cra/package:/source/package:ro", "cra-relay-runtime:/staged"] + + nia-relay-runtime-stager: + <<: *relay-runtime-stager + command: ["--authority", "nia", "--source", "/source", "--destination", "/staged", "stage"] + volumes: ["./scripts/local-relay-runtime-stager.py:/app/local-relay-runtime-stager.py:ro", "./relays/nia/runtime.yaml:/source/runtime.yaml:ro", "./relays/nia/package:/source/package:ro", "nia-relay-runtime:/staged"] + + mosd-relay-runtime-stager: + <<: *relay-runtime-stager + command: ["--authority", "mosd", "--source", "/source", "--destination", "/staged", "stage"] + volumes: ["./scripts/local-relay-runtime-stager.py:/app/local-relay-runtime-stager.py:ro", "./relays/mosd/runtime.yaml:/source/runtime.yaml:ro", "./relays/mosd/package:/source/package:ro", "mosd-relay-runtime:/staged"] + + sipf-relay-runtime-stager: + <<: *relay-runtime-stager + command: ["--authority", "sipf", "--source", "/source", "--destination", "/staged", "stage"] + volumes: ["./scripts/local-relay-runtime-stager.py:/app/local-relay-runtime-stager.py:ro", "./relays/sipf/runtime.yaml:/source/runtime.yaml:ro", "./relays/sipf/package:/source/package:ro", "sipf-relay-runtime:/staged"] + + nagdi-relay-runtime-stager: + <<: *relay-runtime-stager + command: ["--authority", "nagdi", "--source", "/source", "--destination", "/staged", "stage"] + volumes: ["./scripts/local-relay-runtime-stager.py:/app/local-relay-runtime-stager.py:ro", "./relays/nagdi/runtime.yaml:/source/runtime.yaml:ro", "./relays/nagdi/package:/source/package:ro", "nagdi-relay-runtime:/staged"] + + relay-audit-init: + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} command: - - postgres + - python - -c - - ssl=on - - -c - - ssl_cert_file=/var/lib/postgresql/server-ssl/server.crt - - -c - - ssl_key_file=/var/lib/postgresql/server-ssl/server.key - environment: &postgres-environment - POSTGRES_USER: ${SOLMARA_POSTGRES_USER:-solmara_registry} - POSTGRES_PASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - POSTGRES_DB: ${SOLMARA_POSTGRES_DB:-solmara_lab} - NIA_SOURCE_POSTGRES_READER_PASSWORD: ${NIA_SOURCE_POSTGRES_READER_PASSWORD} - SIPF_SOURCE_POSTGRES_READER_PASSWORD: ${SIPF_SOURCE_POSTGRES_READER_PASSWORD} - REGISTRY_RELAY_STATE_EPOCH: ${REGISTRY_RELAY_STATE_EPOCH:-v015} - SOLMARA_RELAY_DATABASES: cra nia sro programme sipf nagdi - SOLMARA_NOTARY_DATABASES: cra nia sro programme sipf nagdi - SOLMARA_SOURCE_READER_DATABASES: nia sipf - CRA_RELAY_POSTGRES_RUNTIME_PASSWORD: ${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD} - CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD} - NIA_RELAY_POSTGRES_RUNTIME_PASSWORD: ${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD} - NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD} - SRO_RELAY_POSTGRES_RUNTIME_PASSWORD: ${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD} - SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD} - PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD: ${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD} - PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD} - SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD: ${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD} - SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD} - NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD: ${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD} - NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD: ${NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD} - NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD: ${NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD} - NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD: ${NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD} - NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD: ${NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD} - ports: - - "${SOLMARA_POSTGRES_PORT:-54329}:5432" + - |- + import os + import re + import stat + + target_uid = 65532 + target_gid = 65532 + paths = ['/audit/cra', '/audit/nia', '/audit/mosd', '/audit/sipf', '/audit/nagdi'] + for path in paths: + # Reclaim the directory long enough to validate existing metadata. + # The final chown returns it to Relay before this process exits. + os.chown(path, 0, 0) + os.chmod(path, 0o700) + for entry in os.scandir(path): + if entry.name not in {'audit.jsonl', 'audit.jsonl.lock'} and not re.fullmatch(r'audit\.jsonl\.\d{8}', entry.name): + continue + metadata = entry.stat(follow_symlinks=False) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise RuntimeError('audit sink has unsafe metadata') + os.chown(entry.path, target_uid, target_gid, follow_symlinks=False) + os.chmod(entry.path, 0o600, follow_symlinks=False) + os.chown(path, target_uid, target_gid) + user: "0:0" + read_only: true + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + network_mode: none + restart: "no" volumes: - - postgres-data:/var/lib/postgresql/data - - ./ministries/interior-population/fixtures/001-schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro - - ./ministries/interior-population/fixtures/002-load.sql:/docker-entrypoint-initdb.d/002-load.sql:ro - - ./ministries/interior-population/fixtures/population_person.csv:/docker-entrypoint-initdb.d/population_person.csv:ro - - ./ministries/interior-population/fixtures/identity_document.csv:/docker-entrypoint-initdb.d/identity_document.csv:ro - - ./ministries/interior-population/fixtures/consent_directive.csv:/docker-entrypoint-initdb.d/consent_directive.csv:ro - - ./ministries/labour-pensions/fixtures/003-schema.sql:/docker-entrypoint-initdb.d/003-schema.sql:ro - - ./ministries/labour-pensions/fixtures/004-load.sql:/docker-entrypoint-initdb.d/004-load.sql:ro - - ./ministries/labour-pensions/fixtures/pension_case.csv:/docker-entrypoint-initdb.d/pension_case.csv:ro - - ./scripts/init-notary-postgresql.sh:/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh:ro - - ./scripts/postgres-entrypoint.sh:/usr/local/bin/solmara-postgres-entrypoint.sh:ro - - ./config/postgres/ssl:/run/solmara-postgres-ssl:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 + - cra-relay-audit:/audit/cra + - nia-relay-audit:/audit/nia + - mosd-relay-audit:/audit/mosd + - sipf-relay-audit:/audit/sipf + - nagdi-relay-audit:/audit/nagdi - registry-postgresql-bootstrap: - image: ${POSTGRES_IMAGE:-postgres:16-alpine} + authority-audit-init: + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} + command: + - python + - -c + - |- + import os + import re + import stat + + paths = ['/audit/mint', '/audit/cra', '/audit/nia', '/audit/sro', '/audit/mosd-programme', '/audit/sipf', '/audit/nagdi'] + for path in paths: + metadata = os.lstat(path) + if not stat.S_ISDIR(metadata.st_mode): + raise RuntimeError('audit sink has unsafe metadata') + os.chown(path, 0, 0) + os.chmod(path, 0o700) + mint_chain = '/audit/mint/audit' + try: + os.mkdir(mint_chain, 0o700) + except FileExistsError: + pass + metadata = os.lstat(mint_chain) + if not stat.S_ISDIR(metadata.st_mode): + raise RuntimeError('audit sink has unsafe metadata') + os.chown(mint_chain, 0, 0) + os.chmod(mint_chain, 0o700) + audit_files = { + '/audit/cra': 'evidence.jsonl', + '/audit/nia': 'evidence.jsonl', + '/audit/sro': 'evidence.jsonl', + '/audit/mosd-programme': 'evidence.jsonl', + '/audit/sipf': 'evidence.jsonl', + '/audit/nagdi': 'evidence.jsonl', + mint_chain: 'mint.jsonl', + } + for path, active_name in audit_files.items(): + with os.scandir(path) as entries: + for entry in entries: + if entry.name not in {active_name, active_name + '.lock'} and not re.fullmatch(re.escape(active_name) + r'\.\d{8}', entry.name): + continue + metadata = entry.stat(follow_symlinks=False) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise RuntimeError('audit sink has unsafe metadata') + os.chown(entry.path, 0, 0, follow_symlinks=False) + os.chmod(entry.path, 0o600, follow_symlinks=False) + user: "0:0" + read_only: true + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER] + security_opt: [no-new-privileges:true] + network_mode: none restart: "no" - entrypoint: ["/bin/sh", "/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh"] - environment: - <<: *postgres-environment - PGHOST: postgres - PGPASSWORD: ${SOLMARA_POSTGRES_PASSWORD} - PGSSLMODE: require volumes: - - ./scripts/init-notary-postgresql.sh:/docker-entrypoint-initdb.d/010-init-notary-postgresql.sh:ro - depends_on: - postgres: - condition: service_healthy + - mint-v2-audit:/audit/mint + - cra-evidence-audit:/audit/cra + - nia-evidence-audit:/audit/nia + - sro-evidence-audit:/audit/sro + - mosd-evidence-audit:/audit/mosd-programme + - sipf-evidence-audit:/audit/sipf + - nagdi-evidence-audit:/audit/nagdi + + # A fresh Relay can create its audit chain before OIDC discovery completes. + # If discovery then fails, the interrupted empty chain makes every later + # consultation fail closed. Prove the shared issuer through the same strict + # TEST-NET gateway path before any Relay process is allowed to start. + relay-issuer-readiness: + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} + command: + - python + - -c + - |- + import json + import ssl + import time + import urllib.request + + issuer = 'https://mint.solmara.registrystack.org' + deadline = time.monotonic() + 60 + context = ssl.create_default_context(cafile='/etc/solmara-evidence/tls/ca.crt') + while True: + try: + with urllib.request.urlopen( + issuer + '/.well-known/openid-configuration', + context=context, + timeout=5, + ) as response: + document = json.load(response) + if response.status == 200 and document.get('issuer') == issuer: + break + except Exception: + pass + if time.monotonic() >= deadline: + raise SystemExit('Relay issuer is unavailable') + time.sleep(1) + user: "65532:65532" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + restart: "no" + volumes: ["./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro"] + networks: [issuer-validation] + depends_on: + mint: {condition: service_started} + evidence-gateway: {condition: service_started} + + mint-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-mint", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/mint/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "mint-transit:/transit"] + + cra-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-evidence-cra", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/cra/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "cra-transit:/transit"] + + nia-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-evidence-nia", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/nia/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "nia-transit:/transit"] + + sro-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-evidence-sro", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/sro/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "sro-transit:/transit"] + + mosd-programme-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-evidence-mosd-programme", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/mosd-programme/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "mosd-programme-transit:/transit"] + + sipf-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-evidence-sipf", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/sipf/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "sipf-transit:/transit"] + + nagdi-signer: + <<: *transit-signer + command: ["python", "/app/local-transit-proxy.py", "--private-jwk", "/run/secrets/signing.jwk", "--socket", "/transit/transit-proxy.sock", "--key-name", "solmara-evidence-nagdi", "--allow-root-bind-owner"] + volumes: ["./scripts/local-transit-proxy.py:/app/local-transit-proxy.py:ro", "./config/evidence/local/cells/nagdi/secrets/signing.jwk:/run/secrets/signing.jwk:ro", "nagdi-transit:/transit"] static-metadata: - image: ${STATIC_METADATA_IMAGE:-python:3.12-alpine} + image: ${PYTHON_STATIC_IMAGE:?versions.env must pin the static server image} command: ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv/static"] - ports: - - "${SOLMARA_METADATA_PORT:-4331}:8080" - volumes: - - ./metadata/public:/srv/static:ro - - scenario-runner: - build: - context: . - dockerfile: scenario-runner/Dockerfile - image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:-solmara-lab-scenario-runner:local} - environment: - SCENARIO_RUNNER_HOST: 0.0.0.0 - SCENARIO_RUNNER_PORT: 8080 - SOLMARA_LAB_MODE: local - CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 - CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN} - CRA_NOTARY_URL: http://cra-notary:8081 - CRA_PENSION_CLIENT_TOKEN: ${CRA_PENSION_CLIENT_TOKEN} - CRA_CITIZEN_CLIENT_TOKEN: ${CRA_CITIZEN_CLIENT_TOKEN} - SIPF_NOTARY_URL: http://sipf-notary:8081 - SIPF_PENSION_CLIENT_TOKEN: ${SIPF_PENSION_CLIENT_TOKEN} - NIA_NOTARY_URL: http://nia-notary:8081 - NIA_CITIZEN_CLIENT_TOKEN: ${NIA_CITIZEN_CLIENT_TOKEN} - NAGDI_NOTARY_URL: http://nagdi-notary:8081 - NAGDI_NOTARY_TOKEN: ${NAGDI_NOTARY_TOKEN} - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)\""] - interval: 30s - timeout: 10s - retries: 3 - depends_on: - child-benefit-federator: - condition: service_healthy - cra-notary: - condition: service_healthy - sipf-notary: - condition: service_healthy - nagdi-notary: - condition: service_healthy - nia-notary: - condition: service_healthy - - home: - build: - context: . - dockerfile: home/Dockerfile - image: ${SOLMARA_HOME_IMAGE:-solmara-lab-home:local} - environment: - HOST: 0.0.0.0 - PORT: 4301 - SOLMARA_LAB_ROOT: /app - REPO_URL: ${REPO_URL:-https://github.com/registrystack/solmara-lab} - PORTAL_URL: http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300} - SCENARIO_RUNNER_URL: http://scenario-runner:8080 - STATIC_METADATA_URL: http://static-metadata:8080 - # Compose-internal probe URLs for the trust-strip status grid. The home - # container is on the same network, so it can probe every service directly. - PORTAL_PROBE_URL: http://portal:4000 - CRA_CIVIL_RELAY_URL: http://cra-civil-relay:8080 - NIA_POPULATION_RELAY_URL: http://nia-population-relay:8080 - SRO_SOCIAL_RELAY_URL: http://sro-social-relay:8080 - PROGRAMME_MIS_RELAY_URL: http://programme-mis-relay:8080 - SIPF_PENSIONS_RELAY_URL: http://sipf-pensions-relay:8080 - NAGDI_AGRICULTURE_RELAY_URL: http://nagdi-agriculture-relay:8080 - CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 - CRA_NOTARY_URL: http://cra-notary:8081 - NIA_NOTARY_URL: http://nia-notary:8081 - SRO_NOTARY_URL: http://sro-notary:8081 - PROGRAMME_NOTARY_URL: http://programme-notary:8081 - SIPF_NOTARY_URL: http://sipf-notary:8081 - NAGDI_NOTARY_URL: http://nagdi-notary:8081 - # Deliberate server-side allowlist of synthetic demo tokens the engineer - # door publishes. Only names listed here are ever rendered; the values are - # the same synthetic notary tokens the scenarios use. Never add a token - # that is not meant to be public. - HOME_PUBLISHED_TOKENS: >- - {"child-benefit-federator":"${CHILD_BENEFIT_FEDERATOR_TOKEN}", - "cra-pension-client":"${CRA_PENSION_CLIENT_TOKEN}", - "cra-citizen-client":"${CRA_CITIZEN_CLIENT_TOKEN}", - "nia-citizen-client":"${NIA_CITIZEN_CLIENT_TOKEN}", - "sipf-pension-client":"${SIPF_PENSION_CLIENT_TOKEN}", - "nagdi-notary":"${NAGDI_NOTARY_TOKEN}"} - ports: - - "${SOLMARA_HOME_PORT:-4301}:4301" - volumes: - # Read-only mounts so the trust strip can read live smoke evidence written - # by `just smoke` on the host and the generated data-seed manifest. The - # image bakes a snapshot of the seed; these keep it current and expose the - # smoke artifacts that the image cannot contain. - - ./output/smoke:/app/output/smoke:ro - - ./generator/output:/app/generator/output:ro - healthcheck: - test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4301/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] - interval: 30s - timeout: 10s - retries: 3 - depends_on: - static-metadata: - condition: service_started - scenario-runner: - condition: service_healthy - - cra-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/registry-relay/relay-consultation.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${CRA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${CRA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - ./runtime/registry-projects/local/cra-civil/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nia-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/registry-relay/relay-consultation.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NIA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NIA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_NIA_DATABASE_URL: ${SOLMARA_NIA_DATABASE_URL} - volumes: - - ./runtime/registry-projects/local/nia-population/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - - sro-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/registry-relay/relay-consultation.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SRO_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SRO_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - ./runtime/registry-projects/local/sro-social/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - - programme-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/registry-relay/relay-consultation.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${PROGRAMME_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${PROGRAMME_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - ./runtime/registry-projects/local/mosd-programme/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - - sipf-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/registry-relay/relay-consultation.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SIPF_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SIPF_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_SIPF_DATABASE_URL: ${SOLMARA_SIPF_DATABASE_URL} - volumes: - - ./runtime/registry-projects/local/sipf-pensions/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - - nagdi-relay-state-bootstrap: - <<: *relay-bootstrap-common - command: ["consultation", "bootstrap-state", "--config", "/etc/registry-relay/relay-consultation.yaml", "--migration-database-url-env", "REGISTRY_RELAY_STATE_MIGRATION_URL", "--owner-role", "solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_owner", "--keyring-maintenance-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL", "--keyring-reader-database-url-env", "REGISTRY_RELAY_STATE_KEYRING_READER_URL", "--active-key-id", "epoch-1", "--active-write-deadline-unix-ms", "${SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS}", "--audit-event-retention-ms", "${SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS}"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NAGDI_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NAGDI_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_MIGRATION_URL: "postgresql://${SOLMARA_POSTGRES_USER:-solmara_registry}:${SOLMARA_POSTGRES_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_maintenance:${NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - REGISTRY_RELAY_STATE_KEYRING_READER_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_keyring_reader:${NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - volumes: - - ./runtime/registry-projects/local/nagdi-agriculture/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - - cra-civil-relay: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${CRA_RELAY_AUDIT_HASH_SECRET} - ports: - - "${SOLMARA_CRA_RELAY_PORT:-4311}:8080" - volumes: - - ./runtime/registry-projects/local/cra-civil/relay:/etc/registry-relay:ro - - ./ministries/interior-civil:/var/lib/solmara/ministries/interior-civil:ro - - cra-relay-cache:/var/lib/registry-relay/cache - - nia-population-relay: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NIA_RELAY_AUDIT_HASH_SECRET} - SOLMARA_NIA_DATABASE_URL: ${SOLMARA_NIA_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - ports: - - "${SOLMARA_NIA_RELAY_PORT:-4312}:8080" - volumes: - - ./runtime/registry-projects/local/nia-population/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nia-relay-cache:/var/lib/registry-relay/cache - - sro-social-relay: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SRO_RELAY_AUDIT_HASH_SECRET} - ports: - - "${SOLMARA_SRO_RELAY_PORT:-4313}:8080" - volumes: - - ./runtime/registry-projects/local/sro-social/relay:/etc/registry-relay:ro - - ./ministries/social-development:/var/lib/solmara/ministries/social-development:ro - - sro-relay-cache:/var/lib/registry-relay/cache - - programme-mis-relay: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${PROGRAMME_RELAY_AUDIT_HASH_SECRET} - ports: - - "${SOLMARA_PROGRAMME_RELAY_PORT:-4314}:8080" - volumes: - - ./runtime/registry-projects/local/mosd-programme/relay:/etc/registry-relay:ro - - ./ministries/social-development:/var/lib/solmara/ministries/social-development:ro - - programme-relay-cache:/var/lib/registry-relay/cache - - sipf-pensions-relay: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SIPF_RELAY_AUDIT_HASH_SECRET} - SOLMARA_SIPF_DATABASE_URL: ${SOLMARA_SIPF_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - ports: - - "${SOLMARA_SIPF_RELAY_PORT:-4315}:8080" - volumes: - - ./runtime/registry-projects/local/sipf-pensions/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sipf-relay-cache:/var/lib/registry-relay/cache - - nagdi-agriculture-relay: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay.yaml"] - environment: - <<: *relay-environment - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NAGDI_RELAY_AUDIT_HASH_SECRET} - ports: - - "${SOLMARA_NAGDI_RELAY_PORT:-4316}:8080" - volumes: - - ./runtime/registry-projects/local/nagdi-agriculture/relay:/etc/registry-relay:ro - - ./ministries/agriculture-nagdi:/var/lib/solmara/ministries/agriculture-nagdi:ro - - nagdi-relay-cache:/var/lib/registry-relay/cache - - cra-civil-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay-consultation.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${CRA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${CRA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_cra_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - ports: - - "${SOLMARA_CRA_NOTARY_PORT:-4325}:8081" - networks: - default: - aliases: [cra-notary] - volumes: - - ./runtime/registry-projects/local/cra-civil/relay:/etc/registry-relay:ro - - ./ministries/interior-civil:/var/lib/solmara/ministries/interior-civil:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - cra-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - cra-relay-state-bootstrap: - condition: service_completed_successfully - - nia-population-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay-consultation.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NIA_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NIA_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nia_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_NIA_DATABASE_URL: ${SOLMARA_NIA_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - ports: - - "${SOLMARA_NIA_NOTARY_PORT:-4326}:8081" + volumes: ["./metadata/public:/srv/static:ro"] + ports: ["${SOLMARA_METADATA_PORT:-4331}:8080"] + networks: [runtime] + + cra-relay: + <<: *relay + environment: {SOLMARA_RELAY_AUDIT_KEY: "${CRA_RELAY_AUDIT_KEY:?run just gen-secrets}", SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt} + command: ["serve", "--runtime", "/etc/relay/cra/runtime.yaml"] + volumes: ["cra-relay-runtime:/etc/relay/cra:ro", "cra-relay-source:/var/lib/relay/source:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "cra-relay-audit:/var/lib/relay/audit"] + depends_on: {relay-audit-init: {condition: service_completed_successfully}, relay-issuer-readiness: {condition: service_completed_successfully}, cra-source-publisher: {condition: service_completed_successfully}, cra-relay-runtime-stager: {condition: service_completed_successfully}} + ports: ["${SOLMARA_CRA_RELAY_PORT:-4311}:8080"] + + nia-relay: + <<: *relay + environment: {SOLMARA_RELAY_AUDIT_KEY: "${NIA_RELAY_AUDIT_KEY:?run just gen-secrets}", SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt} + command: ["serve", "--runtime", "/etc/relay/nia/runtime.yaml"] + volumes: ["nia-relay-runtime:/etc/relay/nia:ro", "nia-relay-source:/var/lib/relay/source:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "nia-relay-audit:/var/lib/relay/audit"] + depends_on: {relay-audit-init: {condition: service_completed_successfully}, relay-issuer-readiness: {condition: service_completed_successfully}, nia-source-publisher: {condition: service_completed_successfully}, nia-relay-runtime-stager: {condition: service_completed_successfully}} + ports: ["${SOLMARA_NIA_RELAY_PORT:-4312}:8080"] + + mosd-relay: + <<: *relay + environment: {SOLMARA_RELAY_AUDIT_KEY: "${MOSD_RELAY_AUDIT_KEY:?run just gen-secrets}", SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt} + command: ["serve", "--runtime", "/etc/relay/mosd/runtime.yaml"] + volumes: ["mosd-relay-runtime:/etc/relay/mosd:ro", "mosd-relay-source:/var/lib/relay/source:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "mosd-relay-audit:/var/lib/relay/audit"] + depends_on: {relay-audit-init: {condition: service_completed_successfully}, relay-issuer-readiness: {condition: service_completed_successfully}, mosd-source-publisher: {condition: service_completed_successfully}, mosd-relay-runtime-stager: {condition: service_completed_successfully}} + ports: ["${SOLMARA_MOSD_RELAY_PORT:-4314}:8080"] + + sipf-relay: + <<: *relay + environment: + SOLMARA_RELAY_AUDIT_KEY: ${SIPF_RELAY_AUDIT_KEY:?run just gen-secrets} + SOLMARA_RELAY_CURSOR_KEY: ${SIPF_RELAY_CURSOR_KEY:?run just gen-secrets} + SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt + command: ["serve", "--runtime", "/etc/relay/sipf/runtime.yaml"] + volumes: ["sipf-relay-runtime:/etc/relay/sipf:ro", "sipf-relay-source:/var/lib/relay/source:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "sipf-relay-audit:/var/lib/relay/audit"] + depends_on: {relay-audit-init: {condition: service_completed_successfully}, relay-issuer-readiness: {condition: service_completed_successfully}, sipf-source-publisher: {condition: service_completed_successfully}, sipf-relay-runtime-stager: {condition: service_completed_successfully}} + ports: ["${SOLMARA_SIPF_RELAY_PORT:-4315}:8080"] + + nagdi-relay: + <<: *relay + environment: + SOLMARA_RELAY_AUDIT_KEY: ${NAGDI_RELAY_AUDIT_KEY:?run just gen-secrets} + SOLMARA_RELAY_CURSOR_KEY: ${NAGDI_RELAY_CURSOR_KEY:?run just gen-secrets} + SSL_CERT_FILE: /etc/solmara-evidence/tls/ca.crt + command: ["serve", "--runtime", "/etc/relay/nagdi/runtime.yaml"] + volumes: ["nagdi-relay-runtime:/etc/relay/nagdi:ro", "nagdi-relay-source:/var/lib/relay/source:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "nagdi-relay-audit:/var/lib/relay/audit"] + depends_on: {relay-audit-init: {condition: service_completed_successfully}, relay-issuer-readiness: {condition: service_completed_successfully}, nagdi-source-publisher: {condition: service_completed_successfully}, nagdi-relay-runtime-stager: {condition: service_completed_successfully}} + ports: ["${SOLMARA_NAGDI_RELAY_PORT:-4316}:8080"] + + mint: + image: ${SOLMARA_MINT_IMAGE:?versions.env must name the digest-pinned official Registry Stack Mint image} + platform: linux/amd64 + command: ["serve", "--config", "/etc/registry-mint/mint.yaml"] + restart: unless-stopped + user: "0:0" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp] + volumes: + - ./runtime/evidence-cells/mint:/etc/registry-mint:ro + - mint-runtime-secrets:/run/secrets/registry-mint:ro + - mint-transit:/run/registry-mint:ro + - mint-v2-audit:/var/lib/registry-mint networks: - default: - aliases: [nia-notary] - volumes: - - ./runtime/registry-projects/local/nia-population/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nia-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - nia-relay-state-bootstrap: - condition: service_completed_successfully - - sro-social-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay-consultation.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SRO_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SRO_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sro_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - ports: - - "${SOLMARA_SRO_NOTARY_PORT:-4327}:8081" + runtime: {ipv4_address: 172.29.1.20} + depends_on: {mint-signer: {condition: service_healthy}, mint-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}} + + cra-evidence: + <<: *evidence + command: ["--runtime", "/etc/registry-evidence/cra/runtime.yaml", "serve"] + volumes: ["./runtime/evidence-cells/cells/cra:/etc/registry-evidence/cra:ro", "cra-evidence-runtime-secrets:/run/secrets/registry-evidence/cra:ro", "cra-transit:/run/registry-evidence/cra:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "./output/sqlite/evidence:/var/lib/registry-evidence/cra/extracts:ro", "cra-evidence-audit:/var/lib/registry-evidence/cra/audit"] + networks: {runtime: {ipv4_address: 172.29.1.21}, issuer-validation: {}} + depends_on: {mint: {condition: service_started}, cra-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}, cra-signer: {condition: service_healthy}, cra-relay: {condition: service_healthy}} + + nia-evidence: + <<: *evidence + command: ["--runtime", "/etc/registry-evidence/nia/runtime.yaml", "serve"] + volumes: ["./runtime/evidence-cells/cells/nia:/etc/registry-evidence/nia:ro", "nia-evidence-runtime-secrets:/run/secrets/registry-evidence/nia:ro", "nia-transit:/run/registry-evidence/nia:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "./output/sqlite/evidence:/var/lib/registry-evidence/nia/extracts:ro", "nia-evidence-audit:/var/lib/registry-evidence/nia/audit"] + networks: {runtime: {ipv4_address: 172.29.1.22}, issuer-validation: {}} + depends_on: {mint: {condition: service_started}, nia-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}, nia-signer: {condition: service_healthy}} + + sro-evidence: + <<: *evidence + command: ["--runtime", "/etc/registry-evidence/sro/runtime.yaml", "serve"] + volumes: ["./runtime/evidence-cells/cells/sro:/etc/registry-evidence/sro:ro", "sro-evidence-runtime-secrets:/run/secrets/registry-evidence/sro:ro", "sro-transit:/run/registry-evidence/sro:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "./output/sqlite/evidence:/var/lib/registry-evidence/sro/extracts:ro", "sro-evidence-audit:/var/lib/registry-evidence/sro/audit"] + networks: {runtime: {ipv4_address: 172.29.1.23}, issuer-validation: {}} + depends_on: {mint: {condition: service_started}, sro-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}, sro-signer: {condition: service_healthy}} + + mosd-programme-evidence: + <<: *evidence + command: ["--runtime", "/etc/registry-evidence/mosd-programme/runtime.yaml", "serve"] + volumes: ["./runtime/evidence-cells/cells/mosd-programme:/etc/registry-evidence/mosd-programme:ro", "mosd-programme-evidence-runtime-secrets:/run/secrets/registry-evidence/mosd-programme:ro", "mosd-programme-transit:/run/registry-evidence/mosd-programme:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "mosd-evidence-audit:/var/lib/registry-evidence/mosd-programme/audit"] + networks: {runtime: {ipv4_address: 172.29.1.24}, issuer-validation: {}} + depends_on: {mint: {condition: service_started}, mosd-programme-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}, mosd-programme-signer: {condition: service_healthy}, mosd-relay: {condition: service_healthy}} + + sipf-evidence: + <<: *evidence + command: ["--runtime", "/etc/registry-evidence/sipf/runtime.yaml", "serve"] + volumes: ["./runtime/evidence-cells/cells/sipf:/etc/registry-evidence/sipf:ro", "sipf-evidence-runtime-secrets:/run/secrets/registry-evidence/sipf:ro", "sipf-transit:/run/registry-evidence/sipf:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "sipf-evidence-audit:/var/lib/registry-evidence/sipf/audit"] + networks: {runtime: {ipv4_address: 172.29.1.25}, issuer-validation: {}} + depends_on: {mint: {condition: service_started}, sipf-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}, sipf-signer: {condition: service_healthy}, sipf-relay: {condition: service_healthy}} + + nagdi-evidence: + <<: *evidence + command: ["--runtime", "/etc/registry-evidence/nagdi/runtime.yaml", "serve"] + volumes: ["./runtime/evidence-cells/cells/nagdi:/etc/registry-evidence/nagdi:ro", "nagdi-evidence-runtime-secrets:/run/secrets/registry-evidence/nagdi:ro", "nagdi-transit:/run/registry-evidence/nagdi:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro", "nagdi-evidence-audit:/var/lib/registry-evidence/nagdi/audit"] + networks: {runtime: {ipv4_address: 172.29.1.26}, issuer-validation: {}} + depends_on: {mint: {condition: service_started}, nagdi-secret-stager: {condition: service_completed_successfully}, authority-audit-init: {condition: service_completed_successfully}, nagdi-signer: {condition: service_healthy}, nagdi-relay: {condition: service_healthy}} + + evidence-gateway: + image: ${EVIDENCE_GATEWAY_IMAGE:?versions.env must pin Caddy} + user: "0:0" + restart: unless-stopped + ports: ["${SOLMARA_EVIDENCE_GATEWAY_PORT:-4341}:443"] + volumes: ["./config/evidence/Caddyfile:/etc/caddy/Caddyfile:ro", "./config/evidence/local/tls:/etc/solmara-evidence/tls:ro", "caddy-data:/data", "caddy-config:/config"] networks: - default: - aliases: [sro-notary] - volumes: - - ./runtime/registry-projects/local/sro-social/relay:/etc/registry-relay:ro - - ./ministries/social-development:/var/lib/solmara/ministries/social-development:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sro-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - sro-relay-state-bootstrap: - condition: service_completed_successfully - - programme-mis-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay-consultation.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${PROGRAMME_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${PROGRAMME_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_programme_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - ports: - - "${SOLMARA_PROGRAMME_NOTARY_PORT:-4328}:8081" - networks: - default: - aliases: [programme-notary] - volumes: - - ./runtime/registry-projects/local/mosd-programme/relay:/etc/registry-relay:ro - - ./ministries/social-development:/var/lib/solmara/ministries/social-development:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - programme-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - programme-relay-state-bootstrap: - condition: service_completed_successfully - - sipf-pensions-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay-consultation.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${SIPF_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${SIPF_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_sipf_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - SOLMARA_SIPF_DATABASE_URL: ${SOLMARA_SIPF_DATABASE_URL} - DATA_GATE_POSTGRES_ROOT_CERT_PATH: /etc/solmara/postgres/root.crt - ports: - - "${SOLMARA_SIPF_NOTARY_PORT:-4322}:8081" - networks: - default: - aliases: [sipf-notary] - volumes: - - ./runtime/registry-projects/local/sipf-pensions/relay:/etc/registry-relay:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sipf-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - sipf-relay-state-bootstrap: - condition: service_completed_successfully - - nagdi-agriculture-relay-consultation: - <<: *relay-common - command: ["--config", "/etc/registry-relay/relay-consultation.yaml", "--bind", "127.0.0.1:8080"] - environment: - <<: *relay-environment - REGISTRY_RELAY_BIND: 127.0.0.1:8080 - REGISTRY_RELAY_AUDIT_HASH_SECRET: ${NAGDI_RELAY_AUDIT_HASH_SECRET} - REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${NAGDI_RELAY_AUDIT_PSEUDONYM_EPOCH_1} - REGISTRY_RELAY_CONSULTATION_DATABASE_URL: "postgresql://solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}_runtime:${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_relay_nagdi_consultation_${REGISTRY_RELAY_STATE_EPOCH:-v015}?sslmode=require" - ports: - - "${SOLMARA_NAGDI_NOTARY_PORT:-4323}:8081" - networks: - default: - aliases: [nagdi-notary] - volumes: - - ./runtime/registry-projects/local/nagdi-agriculture/relay:/etc/registry-relay:ro - - ./ministries/agriculture-nagdi:/var/lib/solmara/ministries/agriculture-nagdi:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nagdi-relay-consultation-cache:/var/lib/registry-relay/cache - depends_on: - nagdi-relay-state-bootstrap: - condition: service_completed_successfully - - cra-workload-agent: - <<: *workload-agent-common - network_mode: service:cra-civil-relay - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"cra-notary","subject":"cra-notary","scopes":["registry:consult:cra-child-benefit","registry:consult:cra-citizen-record","registry:consult:cra-pension-death"],"token_file":"/run/secrets/cra-notary-relay-token","private_jwk_env":"CRA_RELAY_WORKLOAD_JWK"}] - CRA_RELAY_WORKLOAD_JWK: ${CRA_RELAY_WORKLOAD_JWK} - volumes: - - cra-workload-token:/run/secrets - depends_on: - cra-civil-relay: - condition: service_started - - nia-workload-agent: - <<: *workload-agent-common - network_mode: service:nia-population-relay - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"nia-notary","subject":"nia-notary","scopes":["registry:consult:nia-child-benefit","registry:consult:nia-citizen-status"],"token_file":"/run/secrets/nia-notary-relay-token","private_jwk_env":"NIA_RELAY_WORKLOAD_JWK"}, - {"audience":"registry-relay","azp":"solmara-esignet","subject":"solmara-esignet","scopes":["population:identity_release"],"token_file":"/run/esignet-secrets/solmara-esignet-relay-token","private_jwk_env":"NIA_ESIGNET_RELAY_WORKLOAD_JWK","token_uid":1001,"token_gid":1001}] - NIA_RELAY_WORKLOAD_JWK: ${NIA_RELAY_WORKLOAD_JWK} - NIA_ESIGNET_RELAY_WORKLOAD_JWK: ${NIA_ESIGNET_RELAY_WORKLOAD_JWK} - volumes: - - nia-workload-token:/run/secrets - - nia-esignet-workload-token:/run/esignet-secrets - depends_on: - nia-population-relay: - condition: service_started - - sro-workload-agent: - <<: *workload-agent-common - network_mode: service:sro-social-relay - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"sro-notary","subject":"sro-notary","scopes":["registry:consult:child-benefit"],"token_file":"/run/secrets/sro-notary-relay-token","private_jwk_env":"SRO_RELAY_WORKLOAD_JWK"}] - SRO_RELAY_WORKLOAD_JWK: ${SRO_RELAY_WORKLOAD_JWK} - volumes: - - sro-workload-token:/run/secrets - depends_on: - sro-social-relay: - condition: service_started - - programme-workload-agent: - <<: *workload-agent-common - network_mode: service:programme-mis-relay - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"programme-notary","subject":"programme-notary","scopes":["registry:consult:child-benefit"],"token_file":"/run/secrets/programme-notary-relay-token","private_jwk_env":"PROGRAMME_RELAY_WORKLOAD_JWK"}] - PROGRAMME_RELAY_WORKLOAD_JWK: ${PROGRAMME_RELAY_WORKLOAD_JWK} - volumes: - - programme-workload-token:/run/secrets - depends_on: - programme-mis-relay: - condition: service_started - - sipf-workload-agent: - <<: *workload-agent-common - network_mode: service:sipf-pensions-relay - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"sipf-notary","subject":"sipf-notary","scopes":["registry:consult:sipf-pension-payment-review","registry:consult:sipf-survivor-benefit"],"token_file":"/run/secrets/sipf-notary-relay-token","private_jwk_env":"SIPF_RELAY_WORKLOAD_JWK"}] - SIPF_RELAY_WORKLOAD_JWK: ${SIPF_RELAY_WORKLOAD_JWK} - volumes: - - sipf-workload-token:/run/secrets - depends_on: - sipf-pensions-relay: - condition: service_started - - nagdi-workload-agent: - <<: *workload-agent-common - network_mode: service:nagdi-agriculture-relay - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"nagdi-notary","subject":"nagdi-notary","scopes":["registry:consult:livestock","registry:consult:voucher"],"token_file":"/run/secrets/nagdi-notary-relay-token","private_jwk_env":"NAGDI_RELAY_WORKLOAD_JWK"}] - NAGDI_RELAY_WORKLOAD_JWK: ${NAGDI_RELAY_WORKLOAD_JWK} - volumes: - - nagdi-workload-token:/run/secrets - depends_on: - nagdi-agriculture-relay: - condition: service_started - - cra-consultation-workload-agent: - <<: *workload-agent-common - network_mode: service:cra-civil-relay-consultation - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"cra-notary","subject":"cra-notary","scopes":["registry:consult:cra-child-benefit","registry:consult:cra-citizen-record","registry:consult:cra-pension-death"],"token_file":"/run/secrets/cra-notary-relay-token","private_jwk_env":"CRA_RELAY_WORKLOAD_JWK"}] - CRA_RELAY_WORKLOAD_JWK: ${CRA_RELAY_WORKLOAD_JWK} - volumes: - - cra-consultation-workload-token:/run/secrets - depends_on: - cra-civil-relay-consultation: - condition: service_started - - nia-consultation-workload-agent: - <<: *workload-agent-common - network_mode: service:nia-population-relay-consultation - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"nia-notary","subject":"nia-notary","scopes":["registry:consult:nia-child-benefit","registry:consult:nia-citizen-status"],"token_file":"/run/secrets/nia-notary-relay-token","private_jwk_env":"NIA_RELAY_WORKLOAD_JWK"}] - NIA_RELAY_WORKLOAD_JWK: ${NIA_RELAY_WORKLOAD_JWK} - volumes: - - nia-consultation-workload-token:/run/secrets - depends_on: - nia-population-relay-consultation: - condition: service_started - - sro-consultation-workload-agent: - <<: *workload-agent-common - network_mode: service:sro-social-relay-consultation - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"sro-notary","subject":"sro-notary","scopes":["registry:consult:child-benefit"],"token_file":"/run/secrets/sro-notary-relay-token","private_jwk_env":"SRO_RELAY_WORKLOAD_JWK"}] - SRO_RELAY_WORKLOAD_JWK: ${SRO_RELAY_WORKLOAD_JWK} - volumes: - - sro-consultation-workload-token:/run/secrets - depends_on: - sro-social-relay-consultation: - condition: service_started - - programme-consultation-workload-agent: - <<: *workload-agent-common - network_mode: service:programme-mis-relay-consultation - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"programme-notary","subject":"programme-notary","scopes":["registry:consult:child-benefit"],"token_file":"/run/secrets/programme-notary-relay-token","private_jwk_env":"PROGRAMME_RELAY_WORKLOAD_JWK"}] - PROGRAMME_RELAY_WORKLOAD_JWK: ${PROGRAMME_RELAY_WORKLOAD_JWK} - volumes: - - programme-consultation-workload-token:/run/secrets - depends_on: - programme-mis-relay-consultation: - condition: service_started - - sipf-consultation-workload-agent: - <<: *workload-agent-common - network_mode: service:sipf-pensions-relay-consultation - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"sipf-notary","subject":"sipf-notary","scopes":["registry:consult:sipf-pension-payment-review","registry:consult:sipf-survivor-benefit"],"token_file":"/run/secrets/sipf-notary-relay-token","private_jwk_env":"SIPF_RELAY_WORKLOAD_JWK"}] - SIPF_RELAY_WORKLOAD_JWK: ${SIPF_RELAY_WORKLOAD_JWK} - volumes: - - sipf-consultation-workload-token:/run/secrets - depends_on: - sipf-pensions-relay-consultation: - condition: service_started - - nagdi-consultation-workload-agent: - <<: *workload-agent-common - network_mode: service:nagdi-agriculture-relay-consultation - environment: - WORKLOAD_ISSUER: http://127.0.0.1:8090 - WORKLOAD_IDENTITIES_JSON: >- - [{"audience":"registry-relay","azp":"nagdi-notary","subject":"nagdi-notary","scopes":["registry:consult:livestock","registry:consult:voucher"],"token_file":"/run/secrets/nagdi-notary-relay-token","private_jwk_env":"NAGDI_RELAY_WORKLOAD_JWK"}] - NAGDI_RELAY_WORKLOAD_JWK: ${NAGDI_RELAY_WORKLOAD_JWK} - volumes: - - nagdi-consultation-workload-token:/run/secrets - depends_on: - nagdi-agriculture-relay-consultation: - condition: service_started - - cra-notary: - <<: *notary-common - command: ["--config", "/etc/registry-notary/notary.yaml"] - network_mode: service:cra-civil-relay-consultation - environment: &cra-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${CRA_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_cra_runtime:${CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_cra?sslmode=require" - CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - CRA_PENSION_CLIENT_TOKEN_HASH: ${CRA_PENSION_CLIENT_TOKEN_HASH} - CRA_CITIZEN_CLIENT_TOKEN_HASH: ${CRA_CITIZEN_CLIENT_TOKEN_HASH} - volumes: - - ./runtime/registry-projects/local/cra-civil/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - cra-consultation-workload-token:/run/secrets:ro - depends_on: - cra-civil-relay-consultation: - condition: service_healthy - cra-consultation-workload-agent: - condition: service_healthy - cra-notary-state-install: - condition: service_completed_successfully - - cra-notary-state-install: - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/registry-notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_cra_owner", "--runtime-role", "solmara_notary_cra_runtime"] - environment: - <<: *cra-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_cra_migrator:${CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_cra?sslmode=require" - volumes: - - ./runtime/registry-projects/local/cra-civil/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - cra-consultation-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - cra-consultation-workload-agent: - condition: service_healthy - - nia-notary: - <<: *notary-common - command: ["--config", "/etc/registry-notary/notary.yaml"] - network_mode: service:nia-population-relay-consultation - environment: &nia-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${NIA_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_nia_runtime:${NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_nia?sslmode=require" - NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - NIA_CITIZEN_CLIENT_TOKEN_HASH: ${NIA_CITIZEN_CLIENT_TOKEN_HASH} - NIA_NOTARY_ISSUER_JWK: ${NIA_NOTARY_ISSUER_JWK} - volumes: - - ./runtime/registry-projects/local/nia-population/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nia-consultation-workload-token:/run/secrets:ro - depends_on: - nia-population-relay-consultation: - condition: service_healthy - nia-consultation-workload-agent: - condition: service_healthy - nia-notary-state-install: - condition: service_completed_successfully - - nia-notary-state-install: - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/registry-notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_nia_owner", "--runtime-role", "solmara_notary_nia_runtime"] - environment: - <<: *nia-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_nia_migrator:${NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_nia?sslmode=require" - volumes: - - ./runtime/registry-projects/local/nia-population/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nia-consultation-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - nia-consultation-workload-agent: - condition: service_healthy - - sro-notary: - <<: *notary-common - command: ["--config", "/etc/registry-notary/notary.yaml"] - network_mode: service:sro-social-relay-consultation - environment: &sro-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${SRO_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_sro_runtime:${SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_sro?sslmode=require" - SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - volumes: - - ./runtime/registry-projects/local/sro-social/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sro-consultation-workload-token:/run/secrets:ro - depends_on: - sro-social-relay-consultation: - condition: service_healthy - sro-consultation-workload-agent: - condition: service_healthy - sro-notary-state-install: - condition: service_completed_successfully - - sro-notary-state-install: - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/registry-notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_sro_owner", "--runtime-role", "solmara_notary_sro_runtime"] - environment: - <<: *sro-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_sro_migrator:${SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_sro?sslmode=require" - volumes: - - ./runtime/registry-projects/local/sro-social/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sro-consultation-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - sro-consultation-workload-agent: - condition: service_healthy - - programme-notary: - <<: *notary-common - command: ["--config", "/etc/registry-notary/notary.yaml"] - network_mode: service:programme-mis-relay-consultation - environment: &programme-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${PROGRAMME_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_programme_runtime:${PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_programme?sslmode=require" - PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH: ${PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH} - volumes: - - ./runtime/registry-projects/local/mosd-programme/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - programme-consultation-workload-token:/run/secrets:ro - depends_on: - programme-mis-relay-consultation: - condition: service_healthy - programme-consultation-workload-agent: - condition: service_healthy - programme-notary-state-install: - condition: service_completed_successfully - - programme-notary-state-install: - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/registry-notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_programme_owner", "--runtime-role", "solmara_notary_programme_runtime"] - environment: - <<: *programme-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_programme_migrator:${PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_programme?sslmode=require" - volumes: - - ./runtime/registry-projects/local/mosd-programme/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - programme-consultation-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - programme-consultation-workload-agent: - condition: service_healthy + runtime: + ipv4_address: 172.29.1.10 + aliases: + - evidence.solmara.invalid + - cra-relay.solmara.registrystack.org + - mosd-programme-relay.solmara.registrystack.org + - sipf-relay.solmara.registrystack.org + - nagdi-relay.solmara.registrystack.org + # Relay's production OIDC fetch policy rejects private, loopback, and + # metadata destinations. TEST-NET-1 gives the hermetic local gateway a + # non-routable issuer address without weakening that runtime policy. + issuer-validation: + ipv4_address: 192.0.2.62 + aliases: [mint.solmara.registrystack.org] child-benefit-federator: - build: - context: . - dockerfile: scenario-runner/Dockerfile + build: {context: ., dockerfile: scenario-runner/Dockerfile} image: ${SOLMARA_CHILD_BENEFIT_FEDERATOR_IMAGE:-solmara-lab-scenario-runner:local} command: ["python", "scenario-runner/child_benefit_federator.py"] environment: + <<: *evidence-client-environment CHILD_BENEFIT_FEDERATOR_HOST: 0.0.0.0 - CHILD_BENEFIT_FEDERATOR_PORT: 8080 - CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN} - CRA_NOTARY_URL: http://cra-notary:8081 - CRA_CHILD_BENEFIT_CLIENT_TOKEN: ${CRA_CHILD_BENEFIT_CLIENT_TOKEN} - NIA_NOTARY_URL: http://nia-notary:8081 - NIA_CHILD_BENEFIT_CLIENT_TOKEN: ${NIA_CHILD_BENEFIT_CLIENT_TOKEN} - SRO_NOTARY_URL: http://sro-notary:8081 - SRO_CHILD_BENEFIT_CLIENT_TOKEN: ${SRO_CHILD_BENEFIT_CLIENT_TOKEN} - PROGRAMME_NOTARY_URL: http://programme-notary:8081 - PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN: ${PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN} - ports: - - "${SOLMARA_CHILD_BENEFIT_FEDERATOR_PORT:-4321}:8080" - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)\""] - interval: 30s - timeout: 10s - retries: 3 - depends_on: - cra-notary: - condition: service_healthy - nia-notary: - condition: service_healthy - sro-notary: - condition: service_healthy - programme-notary: - condition: service_healthy + CHILD_BENEFIT_FEDERATOR_PORT: "8080" + CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN:?run just gen-secrets} + volumes: ["./config/evidence/local/cells/mint/clients/solmara-demo-client-key:/run/secrets/solmara-evidence-client.jwk:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro"] + networks: [runtime, issuer-validation] - sipf-notary: - <<: *notary-common - command: ["--config", "/etc/registry-notary/notary.yaml"] - network_mode: service:sipf-pensions-relay-consultation - environment: &sipf-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${SIPF_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_sipf_runtime:${SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_sipf?sslmode=require" - SIPF_PENSION_CLIENT_TOKEN_HASH: ${SIPF_PENSION_CLIENT_TOKEN_HASH} - SIPF_NOTARY_ISSUER_JWK: ${SIPF_NOTARY_ISSUER_JWK} - volumes: - - ./runtime/registry-projects/local/sipf-pensions/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sipf-consultation-workload-token:/run/secrets:ro - depends_on: - sipf-pensions-relay-consultation: - condition: service_healthy - sipf-consultation-workload-agent: - condition: service_healthy - sipf-notary-state-install: - condition: service_completed_successfully - - sipf-notary-state-install: - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/registry-notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_sipf_owner", "--runtime-role", "solmara_notary_sipf_runtime"] - environment: - <<: *sipf-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_sipf_migrator:${SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_sipf?sslmode=require" - volumes: - - ./runtime/registry-projects/local/sipf-pensions/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - sipf-consultation-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - sipf-consultation-workload-agent: - condition: service_healthy - - nagdi-notary: - <<: *notary-common - command: ["--config", "/etc/registry-notary/notary.yaml"] - network_mode: service:nagdi-agriculture-relay-consultation - environment: &nagdi-notary-environment - <<: *notary-environment - REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${NAGDI_NOTARY_AUDIT_HASH_SECRET} - REGISTRY_NOTARY_POSTGRES_URL: "postgresql://solmara_notary_nagdi_runtime:${NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD}@postgres:5432/solmara_notary_nagdi?sslmode=require" - NAGDI_CLIENT_TOKEN_HASH: ${NAGDI_CLIENT_TOKEN_HASH} - NAGDI_NOTARY_ISSUER_JWK: ${NAGDI_NOTARY_ISSUER_JWK} - volumes: - - ./runtime/registry-projects/local/nagdi-agriculture/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nagdi-consultation-workload-token:/run/secrets:ro - depends_on: - nagdi-agriculture-relay-consultation: - condition: service_healthy - nagdi-consultation-workload-agent: - condition: service_healthy - nagdi-notary-state-install: - condition: service_completed_successfully - - nagdi-notary-state-install: - image: ${REGISTRY_NOTARY_IMAGE:-ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b} - platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} - restart: "no" - command: ["--config", "/etc/registry-notary/notary.yaml", "state", "install", "--migration-url-env", "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", "--owner-role", "solmara_notary_nagdi_owner", "--runtime-role", "solmara_notary_nagdi_runtime"] + scenario-runner: + build: {context: ., dockerfile: scenario-runner/Dockerfile} + image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:-solmara-lab-scenario-runner:local} environment: - <<: *nagdi-notary-environment - REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL: "postgresql://solmara_notary_nagdi_migrator:${NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD}@postgres:5432/solmara_notary_nagdi?sslmode=require" - volumes: - - ./runtime/registry-projects/local/nagdi-agriculture/notary/notary.yaml:/etc/registry-notary/notary.yaml:ro - - ./config/postgres/ssl/server.crt:/etc/solmara/postgres/root.crt:ro - - nagdi-consultation-workload-token:/run/secrets:ro - depends_on: - registry-postgresql-bootstrap: - condition: service_completed_successfully - nagdi-consultation-workload-agent: - condition: service_healthy + <<: *evidence-client-environment + SCENARIO_RUNNER_HOST: 0.0.0.0 + SCENARIO_RUNNER_PORT: "8080" + SOLMARA_LAB_MODE: local + CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 + CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN:?run just gen-secrets} + volumes: ["./config/evidence/local/cells/mint/clients/solmara-demo-client-key:/run/secrets/solmara-evidence-client.jwk:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro"] + networks: [runtime, issuer-validation] portal: - build: - context: ./portal + build: {context: ./portal} image: ${SOLMARA_PORTAL_IMAGE:-solmara-lab-portal:local} environment: + <<: *evidence-client-environment HOST: 0.0.0.0 - PORT: 4000 + PORT: "4000" PORTAL_PROVIDER: live PORTAL_AUTH_PROVIDER: ${PORTAL_AUTH_PROVIDER:-mock} - PORTAL_SESSION_SECRET: ${PORTAL_SESSION_SECRET} + PORTAL_SESSION_SECRET: ${PORTAL_SESSION_SECRET:?run just gen-secrets} CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 - CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN} - CRA_NOTARY_URL: http://cra-notary:8081 - CRA_PENSION_CLIENT_TOKEN: ${CRA_PENSION_CLIENT_TOKEN} - CRA_CITIZEN_CLIENT_TOKEN: ${CRA_CITIZEN_CLIENT_TOKEN} - NIA_NOTARY_URL: http://nia-notary:8081 - NIA_CITIZEN_CLIENT_TOKEN: ${NIA_CITIZEN_CLIENT_TOKEN} - SIPF_NOTARY_URL: http://sipf-notary:8081 - SIPF_PENSION_CLIENT_TOKEN: ${SIPF_PENSION_CLIENT_TOKEN} - NAGDI_NOTARY_URL: http://nagdi-notary:8081 - NAGDI_NOTARY_TOKEN: ${NAGDI_NOTARY_TOKEN} - ports: - - "${SOLMARA_PORTAL_PORT:-4300}:4000" - healthcheck: - test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] - interval: 30s - timeout: 10s - retries: 3 - depends_on: - child-benefit-federator: - condition: service_healthy - cra-notary: - condition: service_healthy - sipf-notary: - condition: service_healthy - nagdi-notary: - condition: service_healthy - nia-notary: - condition: service_healthy - scenario-runner: - condition: service_healthy + CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN:?run just gen-secrets} + SCENARIO_RUNNER_URL: http://scenario-runner:8080 + ports: ["${SOLMARA_PORTAL_PORT:-4300}:4000"] + volumes: ["./config/evidence/local/cells/mint/clients/solmara-demo-client-key:/run/secrets/solmara-evidence-client.jwk:ro", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro"] + networks: [runtime, issuer-validation] + + home: + build: {context: ., dockerfile: home/Dockerfile} + image: ${SOLMARA_HOME_IMAGE:-solmara-lab-home:local} + environment: + HOST: 0.0.0.0 + PORT: "4301" + SOLMARA_LAB_ROOT: /app + PORTAL_URL: "http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}" + STATIC_METADATA_URL: "http://static-metadata:8080" + CRA_CIVIL_RELAY_URL: "http://cra-relay:8080" + NIA_POPULATION_RELAY_URL: "http://nia-relay:8080" + MOSD_PROGRAMME_RELAY_URL: "http://mosd-relay:8080" + SIPF_PENSIONS_RELAY_URL: "http://sipf-relay:8080" + NAGDI_AGRICULTURE_RELAY_URL: "http://nagdi-relay:8080" + SOLMARA_CRA_EVIDENCE_URL: "http://cra-evidence:8080" + SOLMARA_NIA_EVIDENCE_URL: "http://nia-evidence:8080" + SOLMARA_SRO_EVIDENCE_URL: "http://sro-evidence:8080" + SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL: "http://mosd-programme-evidence:8080" + SOLMARA_SIPF_EVIDENCE_URL: "http://sipf-evidence:8080" + SOLMARA_NAGDI_EVIDENCE_URL: "http://nagdi-evidence:8080" + MINT_URL: "http://mint:8081" + CHILD_BENEFIT_FEDERATOR_URL: "http://child-benefit-federator:8080" + SCENARIO_RUNNER_URL: "http://scenario-runner:8080" + PORTAL_PROBE_URL: "http://portal:4000" + ports: ["${SOLMARA_HOME_PORT:-4301}:4301"] + volumes: ["./output/smoke:/app/output/smoke:ro", "./generator/output:/app/generator/output:ro"] + networks: [runtime] volumes: - postgres-data: - cra-relay-cache: - cra-relay-consultation-cache: - nia-relay-cache: - nia-relay-consultation-cache: - sro-relay-cache: - sro-relay-consultation-cache: - programme-relay-cache: - programme-relay-consultation-cache: - sipf-relay-cache: - sipf-relay-consultation-cache: - nagdi-relay-cache: - nagdi-relay-consultation-cache: - cra-workload-token: - cra-consultation-workload-token: - nia-workload-token: - nia-consultation-workload-token: - nia-esignet-workload-token: - sro-workload-token: - sro-consultation-workload-token: - programme-workload-token: - programme-consultation-workload-token: - sipf-workload-token: - sipf-consultation-workload-token: - nagdi-workload-token: - nagdi-consultation-workload-token: + mint-runtime-secrets: + cra-evidence-runtime-secrets: + nia-evidence-runtime-secrets: + sro-evidence-runtime-secrets: + mosd-programme-evidence-runtime-secrets: + sipf-evidence-runtime-secrets: + nagdi-evidence-runtime-secrets: + cra-relay-runtime: + nia-relay-runtime: + mosd-relay-runtime: + sipf-relay-runtime: + nagdi-relay-runtime: + cra-relay-source: + nia-relay-source: + mosd-relay-source: + sipf-relay-source: + nagdi-relay-source: + mint-transit: + cra-transit: + nia-transit: + sro-transit: + mosd-programme-transit: + sipf-transit: + nagdi-transit: + cra-relay-audit: + nia-relay-audit: + mosd-relay-audit: + sipf-relay-audit: + nagdi-relay-audit: + cra-evidence-audit: + nia-evidence-audit: + sro-evidence-audit: + mosd-evidence-audit: + sipf-evidence-audit: + nagdi-evidence-audit: + mint-v2-audit: + caddy-data: + caddy-config: + +networks: + runtime: + ipam: {config: [{subnet: 172.29.1.0/24}]} + issuer-validation: + internal: true + ipam: {config: [{subnet: 192.0.2.0/26, ip_range: 192.0.2.0/27}]} diff --git a/config/esignet/nginx-hosted.conf b/config/esignet/nginx-hosted.conf index 03d503c..b0fd9c0 100644 --- a/config/esignet/nginx-hosted.conf +++ b/config/esignet/nginx-hosted.conf @@ -1,4 +1,5 @@ worker_processes 1; +pid /tmp/nginx.pid; events { worker_connections 1024; @@ -7,10 +8,15 @@ events { http { access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; server { listen 3000; - server_name esignet-ui.solmara.registrystack.org; + server_name __ESIGNET_UI_PUBLIC_HOST__; server_tokens off; root /usr/share/nginx/html; @@ -25,10 +31,10 @@ http { location /v1/esignet { proxy_pass http://esignet:8088/v1/esignet; proxy_redirect off; - proxy_set_header Host esignet.solmara.registrystack.org; + proxy_set_header Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host esignet.solmara.registrystack.org; + proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Forwarded-Proto https; add_header Content-Security-Policy "default-src 'none'" always; add_header Referrer-Policy "no-referrer" always; @@ -37,10 +43,10 @@ http { location /.well-known/openid-configuration { proxy_pass http://esignet:8088/v1/esignet/oidc/.well-known/openid-configuration; proxy_redirect off; - proxy_set_header Host esignet.solmara.registrystack.org; + proxy_set_header Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host esignet.solmara.registrystack.org; + proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Forwarded-Proto https; add_header Content-Security-Policy "default-src 'none'" always; add_header Referrer-Policy "no-referrer" always; @@ -49,10 +55,10 @@ http { location /.well-known/jwks.json { proxy_pass http://esignet:8088/v1/esignet/oauth/.well-known/jwks.json; proxy_redirect off; - proxy_set_header Host esignet.solmara.registrystack.org; + proxy_set_header Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host esignet.solmara.registrystack.org; + proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Forwarded-Proto https; add_header Content-Security-Policy "default-src 'none'" always; add_header Referrer-Policy "no-referrer" always; @@ -61,10 +67,10 @@ http { location /.well-known/oauth-authorization-server { proxy_pass http://esignet:8088/v1/esignet/oauth/.well-known/oauth-authorization-server; proxy_redirect off; - proxy_set_header Host esignet.solmara.registrystack.org; + proxy_set_header Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host esignet.solmara.registrystack.org; + proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Forwarded-Proto https; add_header Content-Security-Policy "default-src 'none'" always; add_header Referrer-Policy "no-referrer" always; @@ -73,10 +79,10 @@ http { location /.well-known/openid-credential-issuer { proxy_pass http://esignet:8088/v1/esignet/vci/.well-known/openid-credential-issuer; proxy_redirect off; - proxy_set_header Host esignet.solmara.registrystack.org; + proxy_set_header Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host esignet.solmara.registrystack.org; + proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; proxy_set_header X-Forwarded-Proto https; add_header Content-Security-Policy "default-src 'none'" always; add_header Referrer-Policy "no-referrer" always; @@ -91,7 +97,7 @@ http { img-src 'self' data: https://cdn.jsdelivr.net https://*.mosip.net; script-src 'self' https://www.google.com https://www.gstatic.com; frame-src https://www.google.com; - connect-src 'self' https://esignet.solmara.registrystack.org https://esignet-ui.solmara.registrystack.org; + connect-src 'self' https://__ESIGNET_PUBLIC_HOST__ https://__ESIGNET_UI_PUBLIC_HOST__; " always; add_header Referrer-Policy "no-referrer" always; } diff --git a/config/esignet/nginx.conf b/config/esignet/nginx.conf index 8e7d303..e27cc60 100644 --- a/config/esignet/nginx.conf +++ b/config/esignet/nginx.conf @@ -1,12 +1,18 @@ worker_processes 1; +pid /tmp/nginx.pid; events { worker_connections 1024; } http { - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; + access_log /dev/stdout; + error_log /dev/stderr; + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; server { listen 3000; diff --git a/config/evidence/Caddyfile b/config/evidence/Caddyfile new file mode 100644 index 0000000..232437d --- /dev/null +++ b/config/evidence/Caddyfile @@ -0,0 +1,58 @@ +{ + auto_https off +} + +(solmara_tls) { + tls /etc/solmara-evidence/tls/gateway.crt /etc/solmara-evidence/tls/gateway.key +} + +https://cra-relay.solmara.registrystack.org { + import solmara_tls + reverse_proxy cra-relay:8080 +} + +https://mosd-programme-relay.solmara.registrystack.org { + import solmara_tls + reverse_proxy mosd-relay:8080 +} + +https://sipf-relay.solmara.registrystack.org { + import solmara_tls + reverse_proxy sipf-relay:8080 +} + +https://nagdi-relay.solmara.registrystack.org { + import solmara_tls + reverse_proxy nagdi-relay:8080 +} + +https://localhost, https://mint.solmara.registrystack.org, https://evidence.solmara.invalid { + import solmara_tls + handle_path /evidence/cra/* { + reverse_proxy 172.29.1.21:8080 + } + handle_path /evidence/nia/* { + reverse_proxy 172.29.1.22:8080 + } + handle_path /evidence/sro/* { + reverse_proxy 172.29.1.23:8080 + } + handle_path /evidence/mosd-programme/* { + reverse_proxy 172.29.1.24:8080 + } + handle_path /evidence/sipf/* { + reverse_proxy 172.29.1.25:8080 + } + handle_path /evidence/nagdi/* { + reverse_proxy 172.29.1.26:8080 + } + handle_path /child-benefit/* { + reverse_proxy child-benefit-federator:8080 + } + handle /token* { + reverse_proxy 172.29.1.20:8081 + } + handle /.well-known/* { + reverse_proxy 172.29.1.20:8081 + } +} diff --git a/config/evidence/local/.gitkeep b/config/evidence/local/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/evidence/local/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/hosted-relay-bundles/cra-civil/antirollback-seed.json b/config/hosted-relay-bundles/cra-civil/antirollback-seed.json deleted file mode 100644 index e06d6a0..0000000 --- a/config/hosted-relay-bundles/cra-civil/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-cra-civil" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/cra-civil/bootstrap.yaml b/config/hosted-relay-bundles/cra-civil/bootstrap.yaml deleted file mode 100644 index 3e84b75..0000000 --- a/config/hosted-relay-bundles/cra-civil/bootstrap.yaml +++ /dev/null @@ -1,87 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: [] - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://cra-relay.solmara.registrystack.org - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: cra-civil-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/cra-civil/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/cra-civil/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/cra-civil/bundle/config/relay.yaml b/config/hosted-relay-bundles/cra-civil/bundle/config/relay.yaml deleted file mode 100644 index 3e84b75..0000000 --- a/config/hosted-relay-bundles/cra-civil/bundle/config/relay.yaml +++ /dev/null @@ -1,87 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: [] - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://cra-relay.solmara.registrystack.org - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: cra-civil-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/cra-civil/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/cra-civil/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/cra-civil/bundle/manifest.json b/config/hosted-relay-bundles/cra-civil/bundle/manifest.json deleted file mode 100644 index cdc315a..0000000 --- a/config/hosted-relay-bundles/cra-civil/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-cra-civil", - "instance_id": "cra-civil-relay", - "bundle_id": "solmara-hosted-cra-civil-sequence-2", - "sequence": 2, - "config_hash": "sha256:3d6bdc3437e5c9580539e430ce013d2a807625f2185b58c844fd20f0182340b1", - "files": [ - { - "path": "config/relay.yaml", - "sha256": "sha256:3d6bdc3437e5c9580539e430ce013d2a807625f2185b58c844fd20f0182340b1" - } - ], - "created_at": "2026-07-29T03:29:28.170694Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/bundle/manifest.sig.json b/config/hosted-relay-bundles/cra-civil/bundle/manifest.sig.json deleted file mode 100644 index a69c53e..0000000 --- a/config/hosted-relay-bundles/cra-civil/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "EHImKMJHy-4Xcy4OK32_SlQhf0DPmjAqwyvxUoKL7oPcdwBF1mnbBo3Fud1GPpfzQEwFpOgQO8FkFQAHEhkcDw" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/antirollback-seed.json b/config/hosted-relay-bundles/cra-civil/consultation/antirollback-seed.json deleted file mode 100644 index 47b6862..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-cra-civil-consultation" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bootstrap.yaml b/config/hosted-relay-bundles/cra-civil/consultation/bootstrap.yaml deleted file mode 100644 index ee00efb..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bootstrap.yaml +++ /dev/null @@ -1,142 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - cra-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://cra-relay.solmara.registrystack.org - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/civil-person-by-uin/conformance.json - sha256: sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd - - class: negative_security - path: artifacts/evidence/civil-person-by-uin/negative-security.json - sha256: sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f - - class: minimization - path: artifacts/evidence/civil-person-by-uin/minimization.json - sha256: sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263 - integration_packs: - - hash: sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499 - path: artifacts/integration-packs/civil-person-by-uin.json - sha256: sha256:ad85a4bbcbd9c164264afeb98c11c549cc0c54997b39572261282211ab9104b2 - private_bindings: - - hash: sha256:1fb0b8c2b28da70d6f22675209ced135f4855fcfdc2fd38a32aadaa0c70be0dd - path: artifacts/private-bindings/cra-child-benefit-civil.json - sha256: sha256:087d5ddfc5bbc43620f9bf499cda7343ff0b762427451f30fd3541bb7d230e22 - - hash: sha256:918b533b198e4e45e368db0b3d8737728700f4b6ee428e10a93a1a06460649b1 - path: artifacts/private-bindings/cra-citizen-record-civil.json - sha256: sha256:d564828b5a94041f3bb718c16b40a0a53ea68a94ed917bf96e5ed04f0ec8d086 - - hash: sha256:fbac0434f957c897098a1577a20ae8763258b1a1e2385a577129258dbbe86579 - path: artifacts/private-bindings/cra-pension-death-civil.json - sha256: sha256:f4eea1f1f2df7755c729d0a645778ca9805e15e3875962595c0038cc46142f51 - public_contracts: - - hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - path: artifacts/consultation-contracts/cra-child-benefit-civil.json - sha256: sha256:65bfb5000e186b4e461c392454e01c76149e821551152ec79a22f28b1e15f6b1 - - hash: sha256:c101d11d7fe66efe8400330851d65e4e105be62eae6ae9325eb43a83daae8aed - path: artifacts/consultation-contracts/cra-citizen-record-civil.json - sha256: sha256:ffb8a94778c5d1015c14ecc03fd06d7f2b2d5926aca0654cc80887d930bf74d2 - - hash: sha256:80486f20e013dd4a99af89a22efe3c83638f37f3f79ea7c912a8d5b237db2924 - path: artifacts/consultation-contracts/cra-pension-death-civil.json - sha256: sha256:2fbbfadc074822821239ff13ead71115d7d64c6378e39c4d03a870eb3b6655ea - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: cra-notary - principal_id: cra-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 5222333210205388 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 3104283851243516304 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: cra-civil-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/cra-civil/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/cra-civil/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-child-benefit-civil.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-child-benefit-civil.json deleted file mode 100644 index aad610b..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-child-benefit-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-child-benefit.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:9ec50673e36783484c8e35137bd65396a871ecc12c2e8fdd2cc68b1f0cab108b","id":"relay.cra-child-benefit.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:cra-child-benefit","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-citizen-record-civil.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-citizen-record-civil.json deleted file mode 100644 index f0ef1ff..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-citizen-record-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-citizen-record.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"citizen-self-service","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:ddf7a76ffe3dabacabdc1df18588bb87826a00a061572ad49c5052baf073f576","id":"relay.cra-citizen-record.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/citizen-self-service"],"required_scope":"registry:consult:cra-citizen-record","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-pension-death-civil.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-pension-death-civil.json deleted file mode 100644 index 2f64031..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/consultation-contracts/cra-pension-death-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-pension-death.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:dd59f03e034f68b35706ae6193bdd580081f2b596c6b92673fc7a66961e34fc1","id":"relay.cra-pension-death.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/pension-payment-review"],"required_scope":"registry:consult:cra-pension-death","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/conformance.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/conformance.json deleted file mode 100644 index 9e550c6..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"birth-is-registered":true,"child-age-under-5":false,"civil-record-linked":true,"person-is-deceased":false},"error":null,"outcome":"match","outputs":{"birth_brn":"BRN-2027-0101-00001","birth_date":"2027-03-14","deceased":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"birth_brn":"BRN-2027-0101-00001","birth_date":"2027-03-14","deceased":false},"headers":{},"status":200}}}],"name":"cra-civil-person-future-birth-date","request":{"claims":[{"id":"birth-is-registered"},{"id":"child-age-under-5"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"},"variables":{"as_of_date":"2026-07-14"}},"variables":{"as_of_date":"2026-07-14"}},{"classification":"synthetic","expect":{"claims":{"birth-is-registered":true,"child-age-under-5":true,"civil-record-linked":true,"person-is-deceased":false},"error":null,"outcome":"match","outputs":{"birth_brn":"BRN-2022-0101-00001","birth_date":"2022-03-14","deceased":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"birth_brn":"BRN-2022-0101-00001","birth_date":"2022-03-14","deceased":false},"headers":{},"status":200}}}],"name":"cra-civil-person-match","request":{"claims":[{"id":"civil-record-linked"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/citizen-self-service","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{"as_of_date":"2026-07-14"}},{"classification":"synthetic","expect":{"claims":{"birth-is-registered":false,"child-age-under-5":false,"civil-record-linked":false,"person-is-deceased":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"cra-civil-person-no-match","request":{"claims":[{"id":"person-is-deceased"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/pension-payment-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"9999999999"}],"type":"Person"}},"variables":{"as_of_date":"2026-07-14"}}],"integration":"solmara-cra-civil-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/minimization.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/minimization.json deleted file mode 100644 index 9d92e3c..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-cra-civil-person-by-uin","operations":{},"outputs":{"birth_brn":{"from":"snapshot.record.birth_brn","max_bytes":128,"maximum":null,"minimum":null,"nullable":true,"type":"string"},"birth_date":{"from":"snapshot.record.birth_date","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"date"},"deceased":{"from":"snapshot.record.deceased","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/negative-security.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/negative-security.json deleted file mode 100644 index 18ba42a..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/evidence/civil-person-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-cra-civil-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/integration-packs/civil-person-by-uin.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/integration-packs/civil-person-by-uin.json deleted file mode 100644 index 5533854..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/integration-packs/civil-person-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd"],"minimization":["sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263"],"negative_security":["sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-cra-civil-person-by-uin","output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-child-benefit-civil.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-child-benefit-civil.json deleted file mode 100644 index 329227a..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-child-benefit-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-child-benefit.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-citizen-record-civil.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-citizen-record-civil.json deleted file mode 100644 index ea8fb47..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-citizen-record-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-citizen-record.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-pension-death-civil.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-pension-death-civil.json deleted file mode 100644 index 5b5ec3b..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/artifacts/private-bindings/cra-pension-death-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-pension-death.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/relay.yaml b/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/relay.yaml deleted file mode 100644 index ee00efb..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/config/relay.yaml +++ /dev/null @@ -1,142 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - cra-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://cra-relay.solmara.registrystack.org - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/civil-person-by-uin/conformance.json - sha256: sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd - - class: negative_security - path: artifacts/evidence/civil-person-by-uin/negative-security.json - sha256: sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f - - class: minimization - path: artifacts/evidence/civil-person-by-uin/minimization.json - sha256: sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263 - integration_packs: - - hash: sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499 - path: artifacts/integration-packs/civil-person-by-uin.json - sha256: sha256:ad85a4bbcbd9c164264afeb98c11c549cc0c54997b39572261282211ab9104b2 - private_bindings: - - hash: sha256:1fb0b8c2b28da70d6f22675209ced135f4855fcfdc2fd38a32aadaa0c70be0dd - path: artifacts/private-bindings/cra-child-benefit-civil.json - sha256: sha256:087d5ddfc5bbc43620f9bf499cda7343ff0b762427451f30fd3541bb7d230e22 - - hash: sha256:918b533b198e4e45e368db0b3d8737728700f4b6ee428e10a93a1a06460649b1 - path: artifacts/private-bindings/cra-citizen-record-civil.json - sha256: sha256:d564828b5a94041f3bb718c16b40a0a53ea68a94ed917bf96e5ed04f0ec8d086 - - hash: sha256:fbac0434f957c897098a1577a20ae8763258b1a1e2385a577129258dbbe86579 - path: artifacts/private-bindings/cra-pension-death-civil.json - sha256: sha256:f4eea1f1f2df7755c729d0a645778ca9805e15e3875962595c0038cc46142f51 - public_contracts: - - hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - path: artifacts/consultation-contracts/cra-child-benefit-civil.json - sha256: sha256:65bfb5000e186b4e461c392454e01c76149e821551152ec79a22f28b1e15f6b1 - - hash: sha256:c101d11d7fe66efe8400330851d65e4e105be62eae6ae9325eb43a83daae8aed - path: artifacts/consultation-contracts/cra-citizen-record-civil.json - sha256: sha256:ffb8a94778c5d1015c14ecc03fd06d7f2b2d5926aca0654cc80887d930bf74d2 - - hash: sha256:80486f20e013dd4a99af89a22efe3c83638f37f3f79ea7c912a8d5b237db2924 - path: artifacts/consultation-contracts/cra-pension-death-civil.json - sha256: sha256:2fbbfadc074822821239ff13ead71115d7d64c6378e39c4d03a870eb3b6655ea - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: cra-notary - principal_id: cra-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 5222333210205388 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 3104283851243516304 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: cra-civil-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/cra-civil/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/cra-civil/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/manifest.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/manifest.json deleted file mode 100644 index 875d75b..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/manifest.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-cra-civil-consultation", - "instance_id": "cra-civil-relay-consultation", - "bundle_id": "solmara-hosted-cra-civil-consultation-sequence-2", - "sequence": 2, - "config_hash": "sha256:f644dd93ffc7e04b60e75b6800a5750b472756a15eb2707875a8f68b48cf0224", - "files": [ - { - "path": "config/artifacts/consultation-contracts/cra-child-benefit-civil.json", - "sha256": "sha256:65bfb5000e186b4e461c392454e01c76149e821551152ec79a22f28b1e15f6b1" - }, - { - "path": "config/artifacts/consultation-contracts/cra-citizen-record-civil.json", - "sha256": "sha256:ffb8a94778c5d1015c14ecc03fd06d7f2b2d5926aca0654cc80887d930bf74d2" - }, - { - "path": "config/artifacts/consultation-contracts/cra-pension-death-civil.json", - "sha256": "sha256:2fbbfadc074822821239ff13ead71115d7d64c6378e39c4d03a870eb3b6655ea" - }, - { - "path": "config/artifacts/evidence/civil-person-by-uin/conformance.json", - "sha256": "sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd" - }, - { - "path": "config/artifacts/evidence/civil-person-by-uin/minimization.json", - "sha256": "sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263" - }, - { - "path": "config/artifacts/evidence/civil-person-by-uin/negative-security.json", - "sha256": "sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f" - }, - { - "path": "config/artifacts/integration-packs/civil-person-by-uin.json", - "sha256": "sha256:ad85a4bbcbd9c164264afeb98c11c549cc0c54997b39572261282211ab9104b2" - }, - { - "path": "config/artifacts/private-bindings/cra-child-benefit-civil.json", - "sha256": "sha256:087d5ddfc5bbc43620f9bf499cda7343ff0b762427451f30fd3541bb7d230e22" - }, - { - "path": "config/artifacts/private-bindings/cra-citizen-record-civil.json", - "sha256": "sha256:d564828b5a94041f3bb718c16b40a0a53ea68a94ed917bf96e5ed04f0ec8d086" - }, - { - "path": "config/artifacts/private-bindings/cra-pension-death-civil.json", - "sha256": "sha256:f4eea1f1f2df7755c729d0a645778ca9805e15e3875962595c0038cc46142f51" - }, - { - "path": "config/relay.yaml", - "sha256": "sha256:f644dd93ffc7e04b60e75b6800a5750b472756a15eb2707875a8f68b48cf0224" - } - ], - "created_at": "2026-07-29T03:29:35.443551Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/bundle/manifest.sig.json b/config/hosted-relay-bundles/cra-civil/consultation/bundle/manifest.sig.json deleted file mode 100644 index 1982d12..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "j_966nNcyaqBMkE85fz05JksRXBxgO3DY6N76U3GQJzW4-DUOzLkaDnzLxAst46__fNdMKNboVr0QKuoMOrZAQ" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/consultation/trust-anchor.json b/config/hosted-relay-bundles/cra-civil/consultation/trust-anchor.json deleted file mode 100644 index 20c3a04..0000000 --- a/config/hosted-relay-bundles/cra-civil/consultation/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-cra-civil-consultation", - "instance_id": "cra-civil-relay-consultation", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/cra-civil/trust-anchor.json b/config/hosted-relay-bundles/cra-civil/trust-anchor.json deleted file mode 100644 index 4bb249a..0000000 --- a/config/hosted-relay-bundles/cra-civil/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-cra-civil", - "instance_id": "cra-civil-relay", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/antirollback-seed.json b/config/hosted-relay-bundles/mosd-programme/antirollback-seed.json deleted file mode 100644 index 818f505..0000000 --- a/config/hosted-relay-bundles/mosd-programme/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-mosd-programme" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/mosd-programme/bootstrap.yaml b/config/hosted-relay-bundles/mosd-programme/bootstrap.yaml deleted file mode 100644 index 04b530c..0000000 --- a/config/hosted-relay-bundles/mosd-programme/bootstrap.yaml +++ /dev/null @@ -1,80 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://mosd-programme-relay.solmara.registrystack.org - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: programme-mis-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/mosd-programme/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/mosd-programme/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/mosd-programme/bundle/config/relay.yaml b/config/hosted-relay-bundles/mosd-programme/bundle/config/relay.yaml deleted file mode 100644 index 04b530c..0000000 --- a/config/hosted-relay-bundles/mosd-programme/bundle/config/relay.yaml +++ /dev/null @@ -1,80 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://mosd-programme-relay.solmara.registrystack.org - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: programme-mis-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/mosd-programme/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/mosd-programme/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/mosd-programme/bundle/manifest.json b/config/hosted-relay-bundles/mosd-programme/bundle/manifest.json deleted file mode 100644 index 1b048de..0000000 --- a/config/hosted-relay-bundles/mosd-programme/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-mosd-programme", - "instance_id": "programme-mis-relay", - "bundle_id": "solmara-hosted-mosd-programme-sequence-2", - "sequence": 2, - "config_hash": "sha256:a925577919f4a3bdfaf0a629059bd678dd90c95a0534a80c38c935d0aa5c6b05", - "files": [ - { - "path": "config/relay.yaml", - "sha256": "sha256:a925577919f4a3bdfaf0a629059bd678dd90c95a0534a80c38c935d0aa5c6b05" - } - ], - "created_at": "2026-07-29T03:29:43.72312Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/bundle/manifest.sig.json b/config/hosted-relay-bundles/mosd-programme/bundle/manifest.sig.json deleted file mode 100644 index 3753d46..0000000 --- a/config/hosted-relay-bundles/mosd-programme/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "gEPcY8gbE7QY2T-OdRoGhNR66X8wDCILxnoQriv8o_PlknCJs5E49i--qkiPH8OYggySKTDfOsBhtG1DZo9NDQ" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/antirollback-seed.json b/config/hosted-relay-bundles/mosd-programme/consultation/antirollback-seed.json deleted file mode 100644 index fb5e40a..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-mosd-programme-consultation" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bootstrap.yaml b/config/hosted-relay-bundles/mosd-programme/consultation/bootstrap.yaml deleted file mode 100644 index c85f15d..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bootstrap.yaml +++ /dev/null @@ -1,122 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://mosd-programme-relay.solmara.registrystack.org - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/enrollment-by-uin/minimization.json - sha256: sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83 - - class: negative_security - path: artifacts/evidence/enrollment-by-uin/negative-security.json - sha256: sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282 - - class: conformance - path: artifacts/evidence/enrollment-by-uin/conformance.json - sha256: sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8 - integration_packs: - - hash: sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8 - path: artifacts/integration-packs/enrollment-by-uin.json - sha256: sha256:40ac75a61c3346b282e3abadd16654b634ebe0ac60c77a7b4c2ac98cf5f3af50 - private_bindings: - - hash: sha256:ae3674a401a41daa3325d73cd22363dba2d4c5145ae22cdc64587a6e0a293654 - path: artifacts/private-bindings/child-benefit-enrollment.json - sha256: sha256:fd8fc27359128d39dbed4cd6842d7db21f3b5eef21359f0b6bec93c0f6822f18 - public_contracts: - - hash: sha256:9da47bc7171991c51e098b44cb0667595b344d5f4c2b994a67f0c3b401d4b5b3 - path: artifacts/consultation-contracts/child-benefit-enrollment.json - sha256: sha256:253ee934a9c9bbf725e233f90c195d057893eeea14bc9bc12330c0957245ff10 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: programme-notary - principal_id: programme-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 9069287079765840165 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 891105660075992367 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: programme-mis-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/mosd-programme/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/mosd-programme/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/consultation-contracts/child-benefit-enrollment.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/consultation-contracts/child-benefit-enrollment.json deleted file mode 100644 index bc41921..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/consultation-contracts/child-benefit-enrollment.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-mosd-programme.child-benefit.enrollment","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:50a4f9fd40526b837aa90b02d27e13e98f66d9de1362eb06fb2465afbae31c53","id":"relay.child-benefit.enrollment","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:child-benefit","workload":"programme-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-mosd-programme.enrollment-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["duplicate_flag"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":67108864,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/conformance.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/conformance.json deleted file mode 100644 index 6910195..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"not-already-enrolled":true},"error":null,"outcome":"match","outputs":{"duplicate_flag":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"duplicate_flag":false},"headers":{},"status":200}}}],"name":"enrollment-match","request":{"claims":[{"id":"not-already-enrolled"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"not-already-enrolled":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"2300999997"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"enrollment-no-match","request":null,"variables":{}}],"integration":"enrollment-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/minimization.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/minimization.json deleted file mode 100644 index 1d539e7..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"enrollment-by-uin","operations":{},"outputs":{"duplicate_flag":{"from":"snapshot.record.duplicate_flag","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/negative-security.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/negative-security.json deleted file mode 100644 index 0b38f83..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/evidence/enrollment-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"enrollment-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/integration-packs/enrollment-by-uin.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/integration-packs/enrollment-by-uin.json deleted file mode 100644 index 729f6a0..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/integration-packs/enrollment-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-mosd-programme.enrollment-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8"],"minimization":["sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83"],"negative_security":["sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"enrollment-by-uin","output":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/private-bindings/child-benefit-enrollment.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/private-bindings/child-benefit-enrollment.json deleted file mode 100644 index 078b94e..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/artifacts/private-bindings/child-benefit-enrollment.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8","id":"solmara-mosd-programme.enrollment-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"duplicate_flag":"duplicate_flag"}},"table_provider":"mosd_enroll__materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf"},"profile":{"id":"solmara-mosd-programme.child-benefit.enrollment","version":"1"},"registry_instance":"solmara-mosd-programme","source_instance":"materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf","tenant":"solmara-mosd-programme"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/relay.yaml b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/relay.yaml deleted file mode 100644 index c85f15d..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/config/relay.yaml +++ /dev/null @@ -1,122 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://mosd-programme-relay.solmara.registrystack.org - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/enrollment-by-uin/minimization.json - sha256: sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83 - - class: negative_security - path: artifacts/evidence/enrollment-by-uin/negative-security.json - sha256: sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282 - - class: conformance - path: artifacts/evidence/enrollment-by-uin/conformance.json - sha256: sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8 - integration_packs: - - hash: sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8 - path: artifacts/integration-packs/enrollment-by-uin.json - sha256: sha256:40ac75a61c3346b282e3abadd16654b634ebe0ac60c77a7b4c2ac98cf5f3af50 - private_bindings: - - hash: sha256:ae3674a401a41daa3325d73cd22363dba2d4c5145ae22cdc64587a6e0a293654 - path: artifacts/private-bindings/child-benefit-enrollment.json - sha256: sha256:fd8fc27359128d39dbed4cd6842d7db21f3b5eef21359f0b6bec93c0f6822f18 - public_contracts: - - hash: sha256:9da47bc7171991c51e098b44cb0667595b344d5f4c2b994a67f0c3b401d4b5b3 - path: artifacts/consultation-contracts/child-benefit-enrollment.json - sha256: sha256:253ee934a9c9bbf725e233f90c195d057893eeea14bc9bc12330c0957245ff10 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: programme-notary - principal_id: programme-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 9069287079765840165 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 891105660075992367 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: programme-mis-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/mosd-programme/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/mosd-programme/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/manifest.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/manifest.json deleted file mode 100644 index afcf21f..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-mosd-programme-consultation", - "instance_id": "programme-mis-relay-consultation", - "bundle_id": "solmara-hosted-mosd-programme-consultation-sequence-2", - "sequence": 2, - "config_hash": "sha256:393a7050b3c3f1481413edb752e04ee61506080409874afba78e76f39a1384b6", - "files": [ - { - "path": "config/artifacts/consultation-contracts/child-benefit-enrollment.json", - "sha256": "sha256:253ee934a9c9bbf725e233f90c195d057893eeea14bc9bc12330c0957245ff10" - }, - { - "path": "config/artifacts/evidence/enrollment-by-uin/conformance.json", - "sha256": "sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8" - }, - { - "path": "config/artifacts/evidence/enrollment-by-uin/minimization.json", - "sha256": "sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83" - }, - { - "path": "config/artifacts/evidence/enrollment-by-uin/negative-security.json", - "sha256": "sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282" - }, - { - "path": "config/artifacts/integration-packs/enrollment-by-uin.json", - "sha256": "sha256:40ac75a61c3346b282e3abadd16654b634ebe0ac60c77a7b4c2ac98cf5f3af50" - }, - { - "path": "config/artifacts/private-bindings/child-benefit-enrollment.json", - "sha256": "sha256:fd8fc27359128d39dbed4cd6842d7db21f3b5eef21359f0b6bec93c0f6822f18" - }, - { - "path": "config/relay.yaml", - "sha256": "sha256:393a7050b3c3f1481413edb752e04ee61506080409874afba78e76f39a1384b6" - } - ], - "created_at": "2026-07-29T03:29:45.373059Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/manifest.sig.json b/config/hosted-relay-bundles/mosd-programme/consultation/bundle/manifest.sig.json deleted file mode 100644 index 3677cc9..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "Tl3EqK7QL_JwEGJPXl18vIYuOtwHCH40KTiW7VhegU8nk4L10y9EyQIghFQJaPu5WztZDShXG5Ma-jmtkGyGAA" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/consultation/trust-anchor.json b/config/hosted-relay-bundles/mosd-programme/consultation/trust-anchor.json deleted file mode 100644 index 4dd7475..0000000 --- a/config/hosted-relay-bundles/mosd-programme/consultation/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-mosd-programme-consultation", - "instance_id": "programme-mis-relay-consultation", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/mosd-programme/trust-anchor.json b/config/hosted-relay-bundles/mosd-programme/trust-anchor.json deleted file mode 100644 index b37d865..0000000 --- a/config/hosted-relay-bundles/mosd-programme/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-mosd-programme", - "instance_id": "programme-mis-relay", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/antirollback-seed.json b/config/hosted-relay-bundles/nagdi-agriculture/antirollback-seed.json deleted file mode 100644 index 69a1383..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nagdi-agriculture" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/bootstrap.yaml b/config/hosted-relay-bundles/nagdi-agriculture/bootstrap.yaml deleted file mode 100644 index 9e3798a..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/bootstrap.yaml +++ /dev/null @@ -1,178 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nagdi-relay.solmara.registrystack.org - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nagdi-agriculture-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nagdi-agriculture/bundle/config/relay.yaml b/config/hosted-relay-bundles/nagdi-agriculture/bundle/config/relay.yaml deleted file mode 100644 index 9e3798a..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/bundle/config/relay.yaml +++ /dev/null @@ -1,178 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nagdi-relay.solmara.registrystack.org - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nagdi-agriculture-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nagdi-agriculture/bundle/manifest.json b/config/hosted-relay-bundles/nagdi-agriculture/bundle/manifest.json deleted file mode 100644 index 69b5860..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nagdi-agriculture", - "instance_id": "nagdi-agriculture-relay", - "bundle_id": "solmara-hosted-nagdi-agriculture-sequence-2", - "sequence": 2, - "config_hash": "sha256:27bae9688c1930d04fcf8c061671ff969c990e2df8a36ee6b023ea05b6df6672", - "files": [ - { - "path": "config/relay.yaml", - "sha256": "sha256:27bae9688c1930d04fcf8c061671ff969c990e2df8a36ee6b023ea05b6df6672" - } - ], - "created_at": "2026-07-29T03:29:50.278735Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/bundle/manifest.sig.json b/config/hosted-relay-bundles/nagdi-agriculture/bundle/manifest.sig.json deleted file mode 100644 index 41562a7..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "UEqJogDN2u3wsIUgKM897LFauTP5O0_PX5FGcDNq0aht_eFR5LHcPfGc3_yk8aQWEHTvXpjrbGBAJhCPG_rPBw" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/antirollback-seed.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/antirollback-seed.json deleted file mode 100644 index 2218e9a..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nagdi-agriculture-consultation" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bootstrap.yaml b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bootstrap.yaml deleted file mode 100644 index 8795fce..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bootstrap.yaml +++ /dev/null @@ -1,238 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nagdi-relay.solmara.registrystack.org - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/livestock-movement-by-farmer/conformance.json - sha256: sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910 - - class: minimization - path: artifacts/evidence/livestock-movement-by-farmer/minimization.json - sha256: sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5 - - class: conformance - path: artifacts/evidence/farmer-voucher-by-farmer/conformance.json - sha256: sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52 - - class: negative_security - path: artifacts/evidence/farmer-voucher-by-farmer/negative-security.json - sha256: sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d - - class: negative_security - path: artifacts/evidence/livestock-movement-by-farmer/negative-security.json - sha256: sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce - - class: minimization - path: artifacts/evidence/farmer-voucher-by-farmer/minimization.json - sha256: sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b - integration_packs: - - hash: sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd - path: artifacts/integration-packs/farmer-voucher-by-farmer.json - sha256: sha256:ed6f77b36df0d70ad9ffea72fdfedc21cc0a3621db3cdf4ea70d18f8960db12c - - hash: sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092 - path: artifacts/integration-packs/livestock-movement-by-farmer.json - sha256: sha256:7af1198e4ba477344621f4361613c5ba1d0d30bf1a2bc94e94b7a7c5d8e8ca16 - private_bindings: - - hash: sha256:f319d52859657a0a1acaabba01034558012eeeb11e36e406d9def07a72814416 - path: artifacts/private-bindings/livestock-herd.json - sha256: sha256:33efab2ec578e7baf3dc70c7a6c139eb76a5cd80f4a2c753fc474b795477d306 - - hash: sha256:04ea387eadcc2279e7c791241c46b77f41dcbb02bd15919a3e246370f3443714 - path: artifacts/private-bindings/voucher-farmer.json - sha256: sha256:75a3c0379a33f5c7029cf91ebb87a42c661a5e8ddd93c5fb7edf37f0f5f51d9b - public_contracts: - - hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - path: artifacts/consultation-contracts/livestock-herd.json - sha256: sha256:a1f4045c36462881aec560fcf103c1f78252bf0e2acf81ddd6b75ce21ccfd84c - - hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - path: artifacts/consultation-contracts/voucher-farmer.json - sha256: sha256:8ea73ba2779ef33a85cc5f34988fe0d14f13f1892baa185d0d5c67e76dcbe1ad - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nagdi-notary - principal_id: nagdi-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4554467022379962639 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 7450858971948805253 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nagdi-agriculture-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/consultation-contracts/livestock-herd.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/consultation-contracts/livestock-herd.json deleted file mode 100644 index adf39dd..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/consultation-contracts/livestock-herd.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.livestock.herd","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"agricultural-data-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:05746b9128c73f3a079b88d4a6a5c392ea4d317755aa8e0771e0c83325a16b28","id":"relay.livestock.herd","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/livestock-movement-control"],"required_scope":"registry:consult:livestock","workload":"nagdi-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"integration":{"id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["destination_district_open","herd_vaccination_current","no_conflicting_open_movement_permit","origin_district_not_quarantined_for_species","registered_herd"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":134217728,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":604800000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/consultation-contracts/voucher-farmer.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/consultation-contracts/voucher-farmer.json deleted file mode 100644 index 3f414ad..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/consultation-contracts/voucher-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.voucher.farmer","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"agricultural-data-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:6c9b13e328828747f1d32991be3ab0681112c283dae9f614a7187c8ca0d72a01","id":"relay.voucher.farmer","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"],"required_scope":"registry:consult:voucher","workload":"nagdi-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"integration":{"id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["active_farm_parcel","active_smallholder_farmer","crop_declared_for_season","data_use_authorized","district_climate_risk_active","farmer_registered","voucher_entitlement_current","voucher_not_redeemed"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":134217728,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":604800000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/conformance.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/conformance.json deleted file mode 100644 index 4743085..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"data-use-authorized-for-purpose":true,"eligible-for-climate-smart-input-voucher":true,"farmer-registered":true},"error":null,"outcome":"match","outputs":{"active_farm_parcel":true,"active_smallholder_farmer":true,"crop_declared_for_season":true,"data_use_authorized":true,"district_climate_risk_active":true,"farmer_registered":true,"voucher_entitlement_current":true,"voucher_not_redeemed":true}},"input":{"farmer_id":"FR-1001"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"active_farm_parcel":true,"active_smallholder_farmer":true,"crop_declared_for_season":true,"data_use_authorized":true,"district_climate_risk_active":true,"farmer_registered":true,"voucher_entitlement_current":true,"voucher_not_redeemed":true},"headers":{},"status":200}}}],"name":"farmer-voucher-match","request":{"claims":[{"id":"farmer-registered"},{"id":"data-use-authorized-for-purpose"},{"id":"eligible-for-climate-smart-input-voucher"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/voucher-eligibility-review","target":{"identifiers":[{"scheme":"farmer_id","value":"FR-1001"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"data-use-authorized-for-purpose":false,"eligible-for-climate-smart-input-voucher":false,"farmer-registered":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"farmer_id":"FR-9999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"farmer-voucher-no-match","request":null,"variables":{}}],"integration":"farmer-voucher-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/minimization.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/minimization.json deleted file mode 100644 index 59e808a..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"farmer-voucher-by-farmer","operations":{},"outputs":{"active_farm_parcel":{"from":"snapshot.record.active_farm_parcel","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"from":"snapshot.record.active_smallholder_farmer","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"crop_declared_for_season":{"from":"snapshot.record.crop_declared_for_season","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"data_use_authorized":{"from":"snapshot.record.data_use_authorized","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"district_climate_risk_active":{"from":"snapshot.record.district_climate_risk_active","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"farmer_registered":{"from":"snapshot.record.farmer_registered","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"from":"snapshot.record.voucher_entitlement_current","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"from":"snapshot.record.voucher_not_redeemed","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json deleted file mode 100644 index 7dfbf8c..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"farmer-voucher-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/conformance.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/conformance.json deleted file mode 100644 index 25a9c51..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"farmer_id":"FR-8888"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true},{"destination_district_open":false,"herd_vaccination_current":false,"no_conflicting_open_movement_permit":false,"origin_district_not_quarantined_for_species":false,"registered_herd":false}],"headers":{},"status":200}}}],"name":"livestock-movement-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"eligible-for-livestock-movement-permit":true,"origin-district-not-quarantined-for-species":true,"registered-herd":true},"error":null,"outcome":"match","outputs":{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true}},"input":{"farmer_id":"FR-1001"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true},"headers":{},"status":200}}}],"name":"livestock-movement-match","request":{"claims":[{"id":"registered-herd"},{"id":"origin-district-not-quarantined-for-species"},{"id":"eligible-for-livestock-movement-permit"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/livestock-movement-control","target":{"identifiers":[{"scheme":"farmer_id","value":"FR-1001"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"eligible-for-livestock-movement-permit":false,"origin-district-not-quarantined-for-species":false,"registered-herd":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"farmer_id":"FR-9999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"livestock-movement-no-match","request":null,"variables":{}}],"integration":"livestock-movement-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/minimization.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/minimization.json deleted file mode 100644 index 6768a09..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"livestock-movement-by-farmer","operations":{},"outputs":{"destination_district_open":{"from":"snapshot.record.destination_district_open","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"herd_vaccination_current":{"from":"snapshot.record.herd_vaccination_current","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"from":"snapshot.record.no_conflicting_open_movement_permit","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"from":"snapshot.record.origin_district_not_quarantined_for_species","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"registered_herd":{"from":"snapshot.record.registered_herd","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/negative-security.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/negative-security.json deleted file mode 100644 index a823e60..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/evidence/livestock-movement-by-farmer/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"livestock-movement-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/integration-packs/farmer-voucher-by-farmer.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/integration-packs/farmer-voucher-by-farmer.json deleted file mode 100644 index ff96512..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/integration-packs/farmer-voucher-by-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52"],"minimization":["sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b"],"negative_security":["sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d"]},"input_slots":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"logical_operation":"farmer-voucher-by-farmer","output":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":604800000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"farmer_id":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/integration-packs/livestock-movement-by-farmer.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/integration-packs/livestock-movement-by-farmer.json deleted file mode 100644 index 043c50a..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/integration-packs/livestock-movement-by-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910"],"minimization":["sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5"],"negative_security":["sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce"]},"input_slots":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"logical_operation":"livestock-movement-by-farmer","output":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":604800000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"farmer_id":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/private-bindings/livestock-herd.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/private-bindings/livestock-herd.json deleted file mode 100644 index 7db8adf..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/private-bindings/livestock-herd.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092","id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"farmer_id":{"comparison":"binary_equality","input":"farmer_id","physical_field":"farmer_id","physical_type":"utf8"}},"projection":{"destination_district_open":"destination_district_open","herd_vaccination_current":"herd_vaccination_current","no_conflicting_open_movement_permit":"no_conflicting_open_movement_permit","origin_district_not_quarantined_for_species":"origin_district_not_quarantined_for_species","registered_herd":"registered_herd"}},"table_provider":"nagdi_herd__materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b"},"profile":{"id":"solmara-nagdi-agriculture.livestock.herd","version":"1"},"registry_instance":"solmara-nagdi-agriculture","source_instance":"materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b","tenant":"solmara-nagdi-agriculture"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/private-bindings/voucher-farmer.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/private-bindings/voucher-farmer.json deleted file mode 100644 index eddcecb..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/artifacts/private-bindings/voucher-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd","id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"farmer_id":{"comparison":"binary_equality","input":"farmer_id","physical_field":"farmer_id","physical_type":"utf8"}},"projection":{"active_farm_parcel":"active_farm_parcel","active_smallholder_farmer":"active_smallholder_farmer","crop_declared_for_season":"crop_declared_for_season","data_use_authorized":"data_use_authorized","district_climate_risk_active":"district_climate_risk_active","farmer_registered":"farmer_registered","voucher_entitlement_current":"voucher_entitlement_current","voucher_not_redeemed":"voucher_not_redeemed"}},"table_provider":"nagdi_farmer__materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7"},"profile":{"id":"solmara-nagdi-agriculture.voucher.farmer","version":"1"},"registry_instance":"solmara-nagdi-agriculture","source_instance":"materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7","tenant":"solmara-nagdi-agriculture"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/relay.yaml b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/relay.yaml deleted file mode 100644 index 8795fce..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/config/relay.yaml +++ /dev/null @@ -1,238 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nagdi-relay.solmara.registrystack.org - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/livestock-movement-by-farmer/conformance.json - sha256: sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910 - - class: minimization - path: artifacts/evidence/livestock-movement-by-farmer/minimization.json - sha256: sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5 - - class: conformance - path: artifacts/evidence/farmer-voucher-by-farmer/conformance.json - sha256: sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52 - - class: negative_security - path: artifacts/evidence/farmer-voucher-by-farmer/negative-security.json - sha256: sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d - - class: negative_security - path: artifacts/evidence/livestock-movement-by-farmer/negative-security.json - sha256: sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce - - class: minimization - path: artifacts/evidence/farmer-voucher-by-farmer/minimization.json - sha256: sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b - integration_packs: - - hash: sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd - path: artifacts/integration-packs/farmer-voucher-by-farmer.json - sha256: sha256:ed6f77b36df0d70ad9ffea72fdfedc21cc0a3621db3cdf4ea70d18f8960db12c - - hash: sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092 - path: artifacts/integration-packs/livestock-movement-by-farmer.json - sha256: sha256:7af1198e4ba477344621f4361613c5ba1d0d30bf1a2bc94e94b7a7c5d8e8ca16 - private_bindings: - - hash: sha256:f319d52859657a0a1acaabba01034558012eeeb11e36e406d9def07a72814416 - path: artifacts/private-bindings/livestock-herd.json - sha256: sha256:33efab2ec578e7baf3dc70c7a6c139eb76a5cd80f4a2c753fc474b795477d306 - - hash: sha256:04ea387eadcc2279e7c791241c46b77f41dcbb02bd15919a3e246370f3443714 - path: artifacts/private-bindings/voucher-farmer.json - sha256: sha256:75a3c0379a33f5c7029cf91ebb87a42c661a5e8ddd93c5fb7edf37f0f5f51d9b - public_contracts: - - hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - path: artifacts/consultation-contracts/livestock-herd.json - sha256: sha256:a1f4045c36462881aec560fcf103c1f78252bf0e2acf81ddd6b75ce21ccfd84c - - hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - path: artifacts/consultation-contracts/voucher-farmer.json - sha256: sha256:8ea73ba2779ef33a85cc5f34988fe0d14f13f1892baa185d0d5c67e76dcbe1ad - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nagdi-notary - principal_id: nagdi-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4554467022379962639 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 7450858971948805253 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nagdi-agriculture-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nagdi-agriculture/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/manifest.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/manifest.json deleted file mode 100644 index b12e436..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/manifest.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nagdi-agriculture-consultation", - "instance_id": "nagdi-agriculture-relay-consultation", - "bundle_id": "solmara-hosted-nagdi-agriculture-consultation-sequence-2", - "sequence": 2, - "config_hash": "sha256:a50a3d90d19e12ec9f8304c207f3a81829d28c4f7765b13f1a68a883c4dd5207", - "files": [ - { - "path": "config/artifacts/consultation-contracts/livestock-herd.json", - "sha256": "sha256:a1f4045c36462881aec560fcf103c1f78252bf0e2acf81ddd6b75ce21ccfd84c" - }, - { - "path": "config/artifacts/consultation-contracts/voucher-farmer.json", - "sha256": "sha256:8ea73ba2779ef33a85cc5f34988fe0d14f13f1892baa185d0d5c67e76dcbe1ad" - }, - { - "path": "config/artifacts/evidence/farmer-voucher-by-farmer/conformance.json", - "sha256": "sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52" - }, - { - "path": "config/artifacts/evidence/farmer-voucher-by-farmer/minimization.json", - "sha256": "sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b" - }, - { - "path": "config/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json", - "sha256": "sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d" - }, - { - "path": "config/artifacts/evidence/livestock-movement-by-farmer/conformance.json", - "sha256": "sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910" - }, - { - "path": "config/artifacts/evidence/livestock-movement-by-farmer/minimization.json", - "sha256": "sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5" - }, - { - "path": "config/artifacts/evidence/livestock-movement-by-farmer/negative-security.json", - "sha256": "sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce" - }, - { - "path": "config/artifacts/integration-packs/farmer-voucher-by-farmer.json", - "sha256": "sha256:ed6f77b36df0d70ad9ffea72fdfedc21cc0a3621db3cdf4ea70d18f8960db12c" - }, - { - "path": "config/artifacts/integration-packs/livestock-movement-by-farmer.json", - "sha256": "sha256:7af1198e4ba477344621f4361613c5ba1d0d30bf1a2bc94e94b7a7c5d8e8ca16" - }, - { - "path": "config/artifacts/private-bindings/livestock-herd.json", - "sha256": "sha256:33efab2ec578e7baf3dc70c7a6c139eb76a5cd80f4a2c753fc474b795477d306" - }, - { - "path": "config/artifacts/private-bindings/voucher-farmer.json", - "sha256": "sha256:75a3c0379a33f5c7029cf91ebb87a42c661a5e8ddd93c5fb7edf37f0f5f51d9b" - }, - { - "path": "config/relay.yaml", - "sha256": "sha256:a50a3d90d19e12ec9f8304c207f3a81829d28c4f7765b13f1a68a883c4dd5207" - } - ], - "created_at": "2026-07-29T03:29:51.897077Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/manifest.sig.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/manifest.sig.json deleted file mode 100644 index 01e09d1..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "n_O7Jr0g9EVajnDXNlFqKalsWHpB889Nkk4N4eWgQ6S13I1gl2rZUBNSEm3qXFn2Iz65zob9vUmaH0_L93e6Ag" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/consultation/trust-anchor.json b/config/hosted-relay-bundles/nagdi-agriculture/consultation/trust-anchor.json deleted file mode 100644 index 0090379..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/consultation/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nagdi-agriculture-consultation", - "instance_id": "nagdi-agriculture-relay-consultation", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nagdi-agriculture/trust-anchor.json b/config/hosted-relay-bundles/nagdi-agriculture/trust-anchor.json deleted file mode 100644 index f2541b5..0000000 --- a/config/hosted-relay-bundles/nagdi-agriculture/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nagdi-agriculture", - "instance_id": "nagdi-agriculture-relay", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/antirollback-seed.json b/config/hosted-relay-bundles/nia-population/antirollback-seed.json deleted file mode 100644 index eced2eb..0000000 --- a/config/hosted-relay-bundles/nia-population/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nia-population" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/nia-population/bootstrap.yaml b/config/hosted-relay-bundles/nia-population/bootstrap.yaml deleted file mode 100644 index 8dd29ae..0000000 --- a/config/hosted-relay-bundles/nia-population/bootstrap.yaml +++ /dev/null @@ -1,207 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - solmara-esignet - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nia-relay.solmara.registrystack.org - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nia-population-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nia-population/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nia-population/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nia-population/bundle/config/relay.yaml b/config/hosted-relay-bundles/nia-population/bundle/config/relay.yaml deleted file mode 100644 index 8dd29ae..0000000 --- a/config/hosted-relay-bundles/nia-population/bundle/config/relay.yaml +++ /dev/null @@ -1,207 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - solmara-esignet - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nia-relay.solmara.registrystack.org - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nia-population-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nia-population/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nia-population/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nia-population/bundle/manifest.json b/config/hosted-relay-bundles/nia-population/bundle/manifest.json deleted file mode 100644 index 96f3235..0000000 --- a/config/hosted-relay-bundles/nia-population/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nia-population", - "instance_id": "nia-population-relay", - "bundle_id": "solmara-hosted-nia-population-sequence-2", - "sequence": 2, - "config_hash": "sha256:6588003f187ef0c748e416d81f30b7622fa3828520718b38a1d0037585c00c0b", - "files": [ - { - "path": "config/relay.yaml", - "sha256": "sha256:6588003f187ef0c748e416d81f30b7622fa3828520718b38a1d0037585c00c0b" - } - ], - "created_at": "2026-07-29T03:29:37.148851Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/bundle/manifest.sig.json b/config/hosted-relay-bundles/nia-population/bundle/manifest.sig.json deleted file mode 100644 index 2a6964d..0000000 --- a/config/hosted-relay-bundles/nia-population/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "USI_qKV0mc8bRwH9aTTA1CcA02YZLHIcTrnNbwcKdo7kKZtPH0i9wRrcrkbZemNAC2CxMrutjjaOi-dOJFQ4CQ" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/antirollback-seed.json b/config/hosted-relay-bundles/nia-population/consultation/antirollback-seed.json deleted file mode 100644 index 9a32f97..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nia-population-consultation" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/nia-population/consultation/bootstrap.yaml b/config/hosted-relay-bundles/nia-population/consultation/bootstrap.yaml deleted file mode 100644 index 893a7d4..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bootstrap.yaml +++ /dev/null @@ -1,255 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nia-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nia-relay.solmara.registrystack.org - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/population-person-by-uin/minimization.json - sha256: sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407 - - class: conformance - path: artifacts/evidence/population-person-by-uin/conformance.json - sha256: sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd - - class: negative_security - path: artifacts/evidence/population-person-by-uin/negative-security.json - sha256: sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab - integration_packs: - - hash: sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9 - path: artifacts/integration-packs/population-person-by-uin.json - sha256: sha256:83e0e332c81c331e2329535fdf1983be1427359a0e1bdf16986b8bc4a2f24b3e - private_bindings: - - hash: sha256:fa33b620ee699baa8efb7f233dc46884826b4e4b64940a0fa27cca5b1871bb40 - path: artifacts/private-bindings/nia-child-benefit-population.json - sha256: sha256:41dd7eba725d043adbdfbfcc7a95178364970c5b8f76525fa75db87c31d228b6 - - hash: sha256:286382e48365cf203f3b0b5712bb6c8f94d18d33657078dc2522e42afb76aa8f - path: artifacts/private-bindings/nia-citizen-status-population.json - sha256: sha256:89e52d120d4c5ab0fc451e74e2fe0bb11966e7e1e4ab59505713d78f567ae8b7 - public_contracts: - - hash: sha256:2b34d7466e4f45126055cdb3ec18026a6f0cf3051a863ad951eedfe6ed729958 - path: artifacts/consultation-contracts/nia-child-benefit-population.json - sha256: sha256:43e56a4eaff600b7fbb8ca382f6d00bee299dcd9e528f50de89143ad9f3502f3 - - hash: sha256:9433a86942bdfbb1add5e45e2c3462eb224a2e8097be0267a52eeaaecfde0c69 - path: artifacts/consultation-contracts/nia-citizen-status-population.json - sha256: sha256:3ecdd494bdec2c8b591c24385a3c808d49a9b9562f9809e1a7dd24dcc66693a1 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nia-notary - principal_id: nia-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 1016450306001272100 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6539803953464966811 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nia-population-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nia-population/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nia-population/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/consultation-contracts/nia-child-benefit-population.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/consultation-contracts/nia-child-benefit-population.json deleted file mode 100644 index 90ad104..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/consultation-contracts/nia-child-benefit-population.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.nia-child-benefit.population","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:7af974a445cf4ebaec93647f7d6aa80732d70829150702ab0891c06f91850359","id":"relay.nia-child-benefit.population","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:nia-child-benefit","workload":"nia-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["alive","identity_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":536870912,"max_source_records":10000000},"immutable_generation":true,"max_snapshot_age_ms":300000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/consultation-contracts/nia-citizen-status-population.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/consultation-contracts/nia-citizen-status-population.json deleted file mode 100644 index 08977c4..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/consultation-contracts/nia-citizen-status-population.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.nia-citizen-status.population","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"citizen-self-service","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:0f648848f4934f37c2dbce9e58040e2ce7b43d39a5d1c8d0391cdd4aa07b9834","id":"relay.nia-citizen-status.population","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/citizen-self-service"],"required_scope":"registry:consult:nia-citizen-status","workload":"nia-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["alive","identity_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":536870912,"max_source_records":10000000},"immutable_generation":true,"max_snapshot_age_ms":300000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/conformance.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/conformance.json deleted file mode 100644 index 752267e..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"citizen-population-record-active":true,"population-record-active":true},"error":null,"outcome":"match","outputs":{"alive":true,"identity_status":"active"}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"alive":true,"identity_status":"active"},"headers":{},"status":200}}}],"name":"nia-population-person-match","request":{"claims":[{"id":"population-record-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"citizen-population-record-active":false,"population-record-active":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"nia-population-person-no-match","request":{"claims":[{"id":"citizen-population-record-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/citizen-self-service","target":{"identifiers":[{"scheme":"solmara_uin","value":"9999999999"}],"type":"Person"}},"variables":{}}],"integration":"solmara-nia-population-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/minimization.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/minimization.json deleted file mode 100644 index 223b391..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-nia-population-person-by-uin","operations":{},"outputs":{"alive":{"from":"snapshot.record.alive","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"identity_status":{"from":"snapshot.record.identity_status","max_bytes":128,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/negative-security.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/negative-security.json deleted file mode 100644 index 53dcfa4..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/evidence/population-person-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-nia-population-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/integration-packs/population-person-by-uin.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/integration-packs/population-person-by-uin.json deleted file mode 100644 index 7430026..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/integration-packs/population-person-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd"],"minimization":["sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407"],"negative_security":["sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-nia-population-person-by-uin","output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":300000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/private-bindings/nia-child-benefit-population.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/private-bindings/nia-child-benefit-population.json deleted file mode 100644 index 9d14494..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/private-bindings/nia-child-benefit-population.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9","id":"solmara-nia-population.solmara-nia-population-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"alive":"alive","identity_status":"identity_status"}},"table_provider":"population__materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d"},"profile":{"id":"solmara-nia-population.nia-child-benefit.population","version":"1"},"registry_instance":"solmara-nia-population","source_instance":"materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d","tenant":"solmara-nia-population"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/private-bindings/nia-citizen-status-population.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/private-bindings/nia-citizen-status-population.json deleted file mode 100644 index 735159f..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/artifacts/private-bindings/nia-citizen-status-population.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9","id":"solmara-nia-population.solmara-nia-population-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"alive":"alive","identity_status":"identity_status"}},"table_provider":"population__materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d"},"profile":{"id":"solmara-nia-population.nia-citizen-status.population","version":"1"},"registry_instance":"solmara-nia-population","source_instance":"materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d","tenant":"solmara-nia-population"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/relay.yaml b/config/hosted-relay-bundles/nia-population/consultation/bundle/config/relay.yaml deleted file mode 100644 index 893a7d4..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/config/relay.yaml +++ /dev/null @@ -1,255 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nia-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nia-relay.solmara.registrystack.org - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/population-person-by-uin/minimization.json - sha256: sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407 - - class: conformance - path: artifacts/evidence/population-person-by-uin/conformance.json - sha256: sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd - - class: negative_security - path: artifacts/evidence/population-person-by-uin/negative-security.json - sha256: sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab - integration_packs: - - hash: sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9 - path: artifacts/integration-packs/population-person-by-uin.json - sha256: sha256:83e0e332c81c331e2329535fdf1983be1427359a0e1bdf16986b8bc4a2f24b3e - private_bindings: - - hash: sha256:fa33b620ee699baa8efb7f233dc46884826b4e4b64940a0fa27cca5b1871bb40 - path: artifacts/private-bindings/nia-child-benefit-population.json - sha256: sha256:41dd7eba725d043adbdfbfcc7a95178364970c5b8f76525fa75db87c31d228b6 - - hash: sha256:286382e48365cf203f3b0b5712bb6c8f94d18d33657078dc2522e42afb76aa8f - path: artifacts/private-bindings/nia-citizen-status-population.json - sha256: sha256:89e52d120d4c5ab0fc451e74e2fe0bb11966e7e1e4ab59505713d78f567ae8b7 - public_contracts: - - hash: sha256:2b34d7466e4f45126055cdb3ec18026a6f0cf3051a863ad951eedfe6ed729958 - path: artifacts/consultation-contracts/nia-child-benefit-population.json - sha256: sha256:43e56a4eaff600b7fbb8ca382f6d00bee299dcd9e528f50de89143ad9f3502f3 - - hash: sha256:9433a86942bdfbb1add5e45e2c3462eb224a2e8097be0267a52eeaaecfde0c69 - path: artifacts/consultation-contracts/nia-citizen-status-population.json - sha256: sha256:3ecdd494bdec2c8b591c24385a3c808d49a9b9562f9809e1a7dd24dcc66693a1 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nia-notary - principal_id: nia-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 1016450306001272100 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6539803953464966811 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nia-population-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/nia-population/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/nia-population/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/manifest.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/manifest.json deleted file mode 100644 index 2e6f1e3..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/manifest.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nia-population-consultation", - "instance_id": "nia-population-relay-consultation", - "bundle_id": "solmara-hosted-nia-population-consultation-sequence-2", - "sequence": 2, - "config_hash": "sha256:197109f949611154949d7ed2193dce14f308ddbaa155ad94952d4865e8ccc5d2", - "files": [ - { - "path": "config/artifacts/consultation-contracts/nia-child-benefit-population.json", - "sha256": "sha256:43e56a4eaff600b7fbb8ca382f6d00bee299dcd9e528f50de89143ad9f3502f3" - }, - { - "path": "config/artifacts/consultation-contracts/nia-citizen-status-population.json", - "sha256": "sha256:3ecdd494bdec2c8b591c24385a3c808d49a9b9562f9809e1a7dd24dcc66693a1" - }, - { - "path": "config/artifacts/evidence/population-person-by-uin/conformance.json", - "sha256": "sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd" - }, - { - "path": "config/artifacts/evidence/population-person-by-uin/minimization.json", - "sha256": "sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407" - }, - { - "path": "config/artifacts/evidence/population-person-by-uin/negative-security.json", - "sha256": "sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab" - }, - { - "path": "config/artifacts/integration-packs/population-person-by-uin.json", - "sha256": "sha256:83e0e332c81c331e2329535fdf1983be1427359a0e1bdf16986b8bc4a2f24b3e" - }, - { - "path": "config/artifacts/private-bindings/nia-child-benefit-population.json", - "sha256": "sha256:41dd7eba725d043adbdfbfcc7a95178364970c5b8f76525fa75db87c31d228b6" - }, - { - "path": "config/artifacts/private-bindings/nia-citizen-status-population.json", - "sha256": "sha256:89e52d120d4c5ab0fc451e74e2fe0bb11966e7e1e4ab59505713d78f567ae8b7" - }, - { - "path": "config/relay.yaml", - "sha256": "sha256:197109f949611154949d7ed2193dce14f308ddbaa155ad94952d4865e8ccc5d2" - } - ], - "created_at": "2026-07-29T03:29:38.822675Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/bundle/manifest.sig.json b/config/hosted-relay-bundles/nia-population/consultation/bundle/manifest.sig.json deleted file mode 100644 index 949a1a7..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "TC0ZLn8x73num9jNmMa78I0aLBdURn7d9xg1bB9YTndQosrqRSDnGq1wt5syAJZm-GI4pfTvPikmWaNKqFiCDw" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/consultation/trust-anchor.json b/config/hosted-relay-bundles/nia-population/consultation/trust-anchor.json deleted file mode 100644 index 074a049..0000000 --- a/config/hosted-relay-bundles/nia-population/consultation/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nia-population-consultation", - "instance_id": "nia-population-relay-consultation", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/nia-population/trust-anchor.json b/config/hosted-relay-bundles/nia-population/trust-anchor.json deleted file mode 100644 index 7317e64..0000000 --- a/config/hosted-relay-bundles/nia-population/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-nia-population", - "instance_id": "nia-population-relay", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/antirollback-seed.json b/config/hosted-relay-bundles/sipf-pensions/antirollback-seed.json deleted file mode 100644 index bc046ab..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sipf-pensions" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/sipf-pensions/bootstrap.yaml b/config/hosted-relay-bundles/sipf-pensions/bootstrap.yaml deleted file mode 100644 index ab6ca4f..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/bootstrap.yaml +++ /dev/null @@ -1,125 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: [] - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sipf-relay.solmara.registrystack.org - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sipf-pensions-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sipf-pensions/bundle/config/relay.yaml b/config/hosted-relay-bundles/sipf-pensions/bundle/config/relay.yaml deleted file mode 100644 index ab6ca4f..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/bundle/config/relay.yaml +++ /dev/null @@ -1,125 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: [] - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sipf-relay.solmara.registrystack.org - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sipf-pensions-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sipf-pensions/bundle/manifest.json b/config/hosted-relay-bundles/sipf-pensions/bundle/manifest.json deleted file mode 100644 index e4310af..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sipf-pensions", - "instance_id": "sipf-pensions-relay", - "bundle_id": "solmara-hosted-sipf-pensions-sequence-2", - "sequence": 2, - "config_hash": "sha256:e4d55a5834efa30c13090a85fe4e0449d40a5059f0bad3cb12429a318d671c19", - "files": [ - { - "path": "config/relay.yaml", - "sha256": "sha256:e4d55a5834efa30c13090a85fe4e0449d40a5059f0bad3cb12429a318d671c19" - } - ], - "created_at": "2026-07-29T03:29:46.971192Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/bundle/manifest.sig.json b/config/hosted-relay-bundles/sipf-pensions/bundle/manifest.sig.json deleted file mode 100644 index 0faaabf..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "peS7v_icxT4i4TqT-i86h3sShiwwSBohpcp1-r6zVZ-zfXhg2QHq1SVBBWok8eh0X8FmX7_geekH1nqqkKx6Cw" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/antirollback-seed.json b/config/hosted-relay-bundles/sipf-pensions/consultation/antirollback-seed.json deleted file mode 100644 index 7032559..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sipf-pensions-consultation" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bootstrap.yaml b/config/hosted-relay-bundles/sipf-pensions/consultation/bootstrap.yaml deleted file mode 100644 index 192fd58..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bootstrap.yaml +++ /dev/null @@ -1,186 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sipf-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sipf-relay.solmara.registrystack.org - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/pension-case-by-survivor/minimization.json - sha256: sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f - - class: conformance - path: artifacts/evidence/pension-case-by-pensioner/conformance.json - sha256: sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e - - class: negative_security - path: artifacts/evidence/pension-case-by-survivor/negative-security.json - sha256: sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7 - - class: minimization - path: artifacts/evidence/pension-case-by-pensioner/minimization.json - sha256: sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660 - - class: conformance - path: artifacts/evidence/pension-case-by-survivor/conformance.json - sha256: sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059 - - class: negative_security - path: artifacts/evidence/pension-case-by-pensioner/negative-security.json - sha256: sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d - integration_packs: - - hash: sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e - path: artifacts/integration-packs/pension-case-by-pensioner.json - sha256: sha256:6252d74d83d4074d4eee0ae60cb2d1aac357c1a0a56664ab92120933ad0e77b3 - - hash: sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235 - path: artifacts/integration-packs/pension-case-by-survivor.json - sha256: sha256:ca28e59689fa5e842f1435cd8c7bf922117ff2afc7bf3af08788781fcf458168 - private_bindings: - - hash: sha256:928e8a72b68fcdf9fe3ae4381cc71918d78a5c8f8bd138be09df42528a6fdcd3 - path: artifacts/private-bindings/sipf-pension-payment-review-pension.json - sha256: sha256:9499ffd554e9d00acdf62509e3dad500834050c4abff6ce0b1cc299fa35b7828 - - hash: sha256:5b46b40895d8fc69247e473ff117e119a08453121f5ecc9215e1297961ae701d - path: artifacts/private-bindings/sipf-survivor-benefit-survivor.json - sha256: sha256:37a68f42bf4870f69eb4faf6c820049c868951b49edbbe99483e61ae2cbde463 - public_contracts: - - hash: sha256:04deb5f47932e163e61b8f7ce9c4d4adff54a7558128edeb367518962abde1bd - path: artifacts/consultation-contracts/sipf-pension-payment-review-pension.json - sha256: sha256:75211b8be4be68778d4a0aa044fa46778b507017028dfa1b43c9330f9700a167 - - hash: sha256:de1c2295d58905dc34046288edf36263fcccf99cf684deaa885ab76f472ed6d6 - path: artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json - sha256: sha256:6bebf5122b4248dbc3eaefc5e3f7de1705796e30a1b8011b8c0fc3adf2c255f9 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sipf-notary - principal_id: sipf-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4444991830243562918 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6117019989203477116 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sipf-pensions-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json deleted file mode 100644 index ad9440e..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.sipf-pension-payment-review.pension","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:96e56b4b170376860f623777977f7c72a19cd960854a48f568e1e92ca33f85e7","id":"relay.sipf-pension-payment-review.pension","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/pension-payment-review"],"required_scope":"registry:consult:sipf-pension-payment-review","workload":"sipf-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["payment_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json deleted file mode 100644 index a4fd9c0..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.sipf-survivor-benefit.survivor","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:ec6b5310716606dac48159abec1e512757fb9a18bff8a6e0ac634db932b2696b","id":"relay.sipf-survivor-benefit.survivor","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"],"required_scope":"registry:consult:sipf-survivor-benefit","workload":"sipf-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["survivor_eligible"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/conformance.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/conformance.json deleted file mode 100644 index 8babb7e..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"uin":"2300109568"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"payment_status":"active"},{"payment_status":"suspended"}],"headers":{},"status":200}}}],"name":"sipf-pension-case-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"pension-payment-active":true},"error":null,"outcome":"match","outputs":{"payment_status":"active"}},"input":{"uin":"2300109568"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"payment_status":"active"},"headers":{},"status":200}}}],"name":"sipf-pension-case-match","request":{"claims":[{"id":"pension-payment-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/pension-payment-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300109568"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"pension-payment-active":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"sipf-pension-case-no-match","request":null,"variables":{}}],"integration":"solmara-sipf-pension-case-by-pensioner","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/minimization.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/minimization.json deleted file mode 100644 index 27b99c4..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-sipf-pension-case-by-pensioner","operations":{},"outputs":{"payment_status":{"from":"snapshot.record.payment_status","max_bytes":128,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/negative-security.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/negative-security.json deleted file mode 100644 index c62fd58..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-pensioner/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-sipf-pension-case-by-pensioner","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/conformance.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/conformance.json deleted file mode 100644 index e33e4f9..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"uin":"2300118698"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"survivor_eligible":true},{"survivor_eligible":false}],"headers":{},"status":200}}}],"name":"sipf-survivor-case-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"survivor-is-eligible":true},"error":null,"outcome":"match","outputs":{"survivor_eligible":true}},"input":{"uin":"2300118698"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"survivor_eligible":true},"headers":{},"status":200}}}],"name":"sipf-survivor-case-match","request":{"claims":[{"id":"survivor-is-eligible"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/survivor-benefit-determination","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300118698"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"survivor-is-eligible":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"sipf-survivor-case-no-match","request":null,"variables":{}}],"integration":"solmara-sipf-pension-case-by-survivor","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/minimization.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/minimization.json deleted file mode 100644 index e246e22..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-sipf-pension-case-by-survivor","operations":{},"outputs":{"survivor_eligible":{"from":"snapshot.record.survivor_eligible","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/negative-security.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/negative-security.json deleted file mode 100644 index 72f6ab7..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/evidence/pension-case-by-survivor/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-sipf-pension-case-by-survivor","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/integration-packs/pension-case-by-pensioner.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/integration-packs/pension-case-by-pensioner.json deleted file mode 100644 index 7fac5ec..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/integration-packs/pension-case-by-pensioner.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e"],"minimization":["sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660"],"negative_security":["sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-sipf-pension-case-by-pensioner","output":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/integration-packs/pension-case-by-survivor.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/integration-packs/pension-case-by-survivor.json deleted file mode 100644 index a0e0dda..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/integration-packs/pension-case-by-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059"],"minimization":["sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f"],"negative_security":["sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-sipf-pension-case-by-survivor","output":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/private-bindings/sipf-pension-payment-review-pension.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/private-bindings/sipf-pension-payment-review-pension.json deleted file mode 100644 index 2b3b78b..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/private-bindings/sipf-pension-payment-review-pension.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e","id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"pensioner_uin","physical_type":"utf8"}},"projection":{"payment_status":"payment_status"}},"table_provider":"pension__materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21"},"profile":{"id":"solmara-sipf-pensions.sipf-pension-payment-review.pension","version":"1"},"registry_instance":"solmara-sipf-pensions","source_instance":"materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21","tenant":"solmara-sipf-pensions"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/private-bindings/sipf-survivor-benefit-survivor.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/private-bindings/sipf-survivor-benefit-survivor.json deleted file mode 100644 index 8485dcd..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/artifacts/private-bindings/sipf-survivor-benefit-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235","id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"spouse_uin","physical_type":"utf8"}},"projection":{"survivor_eligible":"survivor_eligible"}},"table_provider":"survivor__materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539"},"profile":{"id":"solmara-sipf-pensions.sipf-survivor-benefit.survivor","version":"1"},"registry_instance":"solmara-sipf-pensions","source_instance":"materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539","tenant":"solmara-sipf-pensions"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/relay.yaml b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/relay.yaml deleted file mode 100644 index 192fd58..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/config/relay.yaml +++ /dev/null @@ -1,186 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sipf-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sipf-relay.solmara.registrystack.org - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/pension-case-by-survivor/minimization.json - sha256: sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f - - class: conformance - path: artifacts/evidence/pension-case-by-pensioner/conformance.json - sha256: sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e - - class: negative_security - path: artifacts/evidence/pension-case-by-survivor/negative-security.json - sha256: sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7 - - class: minimization - path: artifacts/evidence/pension-case-by-pensioner/minimization.json - sha256: sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660 - - class: conformance - path: artifacts/evidence/pension-case-by-survivor/conformance.json - sha256: sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059 - - class: negative_security - path: artifacts/evidence/pension-case-by-pensioner/negative-security.json - sha256: sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d - integration_packs: - - hash: sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e - path: artifacts/integration-packs/pension-case-by-pensioner.json - sha256: sha256:6252d74d83d4074d4eee0ae60cb2d1aac357c1a0a56664ab92120933ad0e77b3 - - hash: sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235 - path: artifacts/integration-packs/pension-case-by-survivor.json - sha256: sha256:ca28e59689fa5e842f1435cd8c7bf922117ff2afc7bf3af08788781fcf458168 - private_bindings: - - hash: sha256:928e8a72b68fcdf9fe3ae4381cc71918d78a5c8f8bd138be09df42528a6fdcd3 - path: artifacts/private-bindings/sipf-pension-payment-review-pension.json - sha256: sha256:9499ffd554e9d00acdf62509e3dad500834050c4abff6ce0b1cc299fa35b7828 - - hash: sha256:5b46b40895d8fc69247e473ff117e119a08453121f5ecc9215e1297961ae701d - path: artifacts/private-bindings/sipf-survivor-benefit-survivor.json - sha256: sha256:37a68f42bf4870f69eb4faf6c820049c868951b49edbbe99483e61ae2cbde463 - public_contracts: - - hash: sha256:04deb5f47932e163e61b8f7ce9c4d4adff54a7558128edeb367518962abde1bd - path: artifacts/consultation-contracts/sipf-pension-payment-review-pension.json - sha256: sha256:75211b8be4be68778d4a0aa044fa46778b507017028dfa1b43c9330f9700a167 - - hash: sha256:de1c2295d58905dc34046288edf36263fcccf99cf684deaa885ab76f472ed6d6 - path: artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json - sha256: sha256:6bebf5122b4248dbc3eaefc5e3f7de1705796e30a1b8011b8c0fc3adf2c255f9 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sipf-notary - principal_id: sipf-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4444991830243562918 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6117019989203477116 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sipf-pensions-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sipf-pensions/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/manifest.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/manifest.json deleted file mode 100644 index 8923132..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/manifest.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sipf-pensions-consultation", - "instance_id": "sipf-pensions-relay-consultation", - "bundle_id": "solmara-hosted-sipf-pensions-consultation-sequence-2", - "sequence": 2, - "config_hash": "sha256:08888c3a0b9899b9e84847aedc1dec6db6db941bd8c84c5b9bcf892f752a2f29", - "files": [ - { - "path": "config/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json", - "sha256": "sha256:75211b8be4be68778d4a0aa044fa46778b507017028dfa1b43c9330f9700a167" - }, - { - "path": "config/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json", - "sha256": "sha256:6bebf5122b4248dbc3eaefc5e3f7de1705796e30a1b8011b8c0fc3adf2c255f9" - }, - { - "path": "config/artifacts/evidence/pension-case-by-pensioner/conformance.json", - "sha256": "sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e" - }, - { - "path": "config/artifacts/evidence/pension-case-by-pensioner/minimization.json", - "sha256": "sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660" - }, - { - "path": "config/artifacts/evidence/pension-case-by-pensioner/negative-security.json", - "sha256": "sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d" - }, - { - "path": "config/artifacts/evidence/pension-case-by-survivor/conformance.json", - "sha256": "sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059" - }, - { - "path": "config/artifacts/evidence/pension-case-by-survivor/minimization.json", - "sha256": "sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f" - }, - { - "path": "config/artifacts/evidence/pension-case-by-survivor/negative-security.json", - "sha256": "sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7" - }, - { - "path": "config/artifacts/integration-packs/pension-case-by-pensioner.json", - "sha256": "sha256:6252d74d83d4074d4eee0ae60cb2d1aac357c1a0a56664ab92120933ad0e77b3" - }, - { - "path": "config/artifacts/integration-packs/pension-case-by-survivor.json", - "sha256": "sha256:ca28e59689fa5e842f1435cd8c7bf922117ff2afc7bf3af08788781fcf458168" - }, - { - "path": "config/artifacts/private-bindings/sipf-pension-payment-review-pension.json", - "sha256": "sha256:9499ffd554e9d00acdf62509e3dad500834050c4abff6ce0b1cc299fa35b7828" - }, - { - "path": "config/artifacts/private-bindings/sipf-survivor-benefit-survivor.json", - "sha256": "sha256:37a68f42bf4870f69eb4faf6c820049c868951b49edbbe99483e61ae2cbde463" - }, - { - "path": "config/relay.yaml", - "sha256": "sha256:08888c3a0b9899b9e84847aedc1dec6db6db941bd8c84c5b9bcf892f752a2f29" - } - ], - "created_at": "2026-07-29T03:29:48.663894Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/manifest.sig.json b/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/manifest.sig.json deleted file mode 100644 index 16a7711..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "Ul5q0GvGR3NLnEQijOuX83pBCiO2U3HTBtBcCl-2aiMp7q8VwbDWcAZXHm4oxgNin8j8uId0ffStg_1dO1JzDg" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/consultation/trust-anchor.json b/config/hosted-relay-bundles/sipf-pensions/consultation/trust-anchor.json deleted file mode 100644 index a9c8951..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/consultation/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sipf-pensions-consultation", - "instance_id": "sipf-pensions-relay-consultation", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sipf-pensions/trust-anchor.json b/config/hosted-relay-bundles/sipf-pensions/trust-anchor.json deleted file mode 100644 index a5142bc..0000000 --- a/config/hosted-relay-bundles/sipf-pensions/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sipf-pensions", - "instance_id": "sipf-pensions-relay", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/antirollback-seed.json b/config/hosted-relay-bundles/sro-social/antirollback-seed.json deleted file mode 100644 index d2748e6..0000000 --- a/config/hosted-relay-bundles/sro-social/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sro-social" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/sro-social/bootstrap.yaml b/config/hosted-relay-bundles/sro-social/bootstrap.yaml deleted file mode 100644 index 4c9c25e..0000000 --- a/config/hosted-relay-bundles/sro-social/bootstrap.yaml +++ /dev/null @@ -1,80 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sro-relay.solmara.registrystack.org - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sro-social-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sro-social/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sro-social/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sro-social/bundle/config/relay.yaml b/config/hosted-relay-bundles/sro-social/bundle/config/relay.yaml deleted file mode 100644 index 4c9c25e..0000000 --- a/config/hosted-relay-bundles/sro-social/bundle/config/relay.yaml +++ /dev/null @@ -1,80 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sro-relay.solmara.registrystack.org - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sro-social-relay -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sro-social/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sro-social/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sro-social/bundle/manifest.json b/config/hosted-relay-bundles/sro-social/bundle/manifest.json deleted file mode 100644 index c5650d7..0000000 --- a/config/hosted-relay-bundles/sro-social/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sro-social", - "instance_id": "sro-social-relay", - "bundle_id": "solmara-hosted-sro-social-sequence-2", - "sequence": 2, - "config_hash": "sha256:506108c3be1cb86796f6ac59d2d62dc3083fc77ee5f87795ef0479bace0bffe6", - "files": [ - { - "path": "config/relay.yaml", - "sha256": "sha256:506108c3be1cb86796f6ac59d2d62dc3083fc77ee5f87795ef0479bace0bffe6" - } - ], - "created_at": "2026-07-29T03:29:40.453399Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/bundle/manifest.sig.json b/config/hosted-relay-bundles/sro-social/bundle/manifest.sig.json deleted file mode 100644 index acd46c5..0000000 --- a/config/hosted-relay-bundles/sro-social/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "iesNhkxoxWZ8kYtVJtr14O0VSEq4a3WR2rHgIdvomilgKW-YNSq-7LuHNnZXbmP-qrDHkDUdnd_Rl_lhFqI-Ag" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/consultation/antirollback-seed.json b/config/hosted-relay-bundles/sro-social/consultation/antirollback-seed.json deleted file mode 100644 index 050751f..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/antirollback-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sro-social-consultation" - }, - "last_sequence": 0, - "last_config_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/config/hosted-relay-bundles/sro-social/consultation/bootstrap.yaml b/config/hosted-relay-bundles/sro-social/consultation/bootstrap.yaml deleted file mode 100644 index 2313f8c..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bootstrap.yaml +++ /dev/null @@ -1,122 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sro-relay.solmara.registrystack.org - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -consultation: - artifacts: - evidence: - - class: negative_security - path: artifacts/evidence/child-benefit-household-by-uin/negative-security.json - sha256: sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95 - - class: minimization - path: artifacts/evidence/child-benefit-household-by-uin/minimization.json - sha256: sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0 - - class: conformance - path: artifacts/evidence/child-benefit-household-by-uin/conformance.json - sha256: sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce - integration_packs: - - hash: sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e - path: artifacts/integration-packs/child-benefit-household-by-uin.json - sha256: sha256:bbf3bfb05e1f6020bdc14457db9549ad084ad421b5c59715bdb51d5ea67dc4d3 - private_bindings: - - hash: sha256:4dd783c0c8bcf6b6fee3aefd511c719dab911053adf3f39874cc99deded3bcfb - path: artifacts/private-bindings/child-benefit-household.json - sha256: sha256:9515fa0b01838a1f07f25b2b86b75843e8ef888438b82989f5ab7d657e2865ba - public_contracts: - - hash: sha256:7c6800ab1eea861c2e2500ba3e93bb9ddcb6d1212d7701756098155faaee4697 - path: artifacts/consultation-contracts/child-benefit-household.json - sha256: sha256:77470bdaa56f3bf30beb1734e0aa0fd676068b09f45d576ce32cae4a51920efb - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sro-notary - principal_id: sro-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 368637434756268187 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 2694379094216255332 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sro-social-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sro-social/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sro-social/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/consultation-contracts/child-benefit-household.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/consultation-contracts/child-benefit-household.json deleted file mode 100644 index e8bbb22..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/consultation-contracts/child-benefit-household.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sro-social.child-benefit.household","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:1f2bc3ace6f53ef9e6aa82974582fdd31113142f164f5afaf8328dafe39d92d3","id":"relay.child-benefit.household","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:child-benefit","workload":"sro-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sro-social.child-benefit-household-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["poverty_band"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":67108864,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":2678400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/conformance.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/conformance.json deleted file mode 100644 index 6a52524..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"household-below-poverty-threshold":true},"error":null,"outcome":"match","outputs":{"poverty_band":"priority"}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"poverty_band":"priority"},"headers":{},"status":200}}}],"name":"child-benefit-household-match","request":{"claims":[{"id":"household-below-poverty-threshold"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"household-below-poverty-threshold":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"2300999997"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"child-benefit-household-no-match","request":null,"variables":{}}],"integration":"child-benefit-household-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/minimization.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/minimization.json deleted file mode 100644 index fc5378a..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"child-benefit-household-by-uin","operations":{},"outputs":{"poverty_band":{"from":"snapshot.record.poverty_band","max_bytes":64,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/negative-security.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/negative-security.json deleted file mode 100644 index 9037e0a..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/evidence/child-benefit-household-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"child-benefit-household-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/integration-packs/child-benefit-household-by-uin.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/integration-packs/child-benefit-household-by-uin.json deleted file mode 100644 index 7133664..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/integration-packs/child-benefit-household-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sro-social.child-benefit-household-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce"],"minimization":["sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0"],"negative_security":["sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"child-benefit-household-by-uin","output":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":2678400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/private-bindings/child-benefit-household.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/private-bindings/child-benefit-household.json deleted file mode 100644 index e46d4f8..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/artifacts/private-bindings/child-benefit-household.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e","id":"solmara-sro-social.child-benefit-household-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"poverty_band":"poverty_band"}},"table_provider":"sro_child__materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d"},"profile":{"id":"solmara-sro-social.child-benefit.household","version":"1"},"registry_instance":"solmara-sro-social","source_instance":"materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d","tenant":"solmara-sro-social"} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/relay.yaml b/config/hosted-relay-bundles/sro-social/consultation/bundle/config/relay.yaml deleted file mode 100644 index 2313f8c..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/config/relay.yaml +++ /dev/null @@ -1,122 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sro-relay.solmara.registrystack.org - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -consultation: - artifacts: - evidence: - - class: negative_security - path: artifacts/evidence/child-benefit-household-by-uin/negative-security.json - sha256: sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95 - - class: minimization - path: artifacts/evidence/child-benefit-household-by-uin/minimization.json - sha256: sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0 - - class: conformance - path: artifacts/evidence/child-benefit-household-by-uin/conformance.json - sha256: sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce - integration_packs: - - hash: sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e - path: artifacts/integration-packs/child-benefit-household-by-uin.json - sha256: sha256:bbf3bfb05e1f6020bdc14457db9549ad084ad421b5c59715bdb51d5ea67dc4d3 - private_bindings: - - hash: sha256:4dd783c0c8bcf6b6fee3aefd511c719dab911053adf3f39874cc99deded3bcfb - path: artifacts/private-bindings/child-benefit-household.json - sha256: sha256:9515fa0b01838a1f07f25b2b86b75843e8ef888438b82989f5ab7d657e2865ba - public_contracts: - - hash: sha256:7c6800ab1eea861c2e2500ba3e93bb9ddcb6d1212d7701756098155faaee4697 - path: artifacts/consultation-contracts/child-benefit-household.json - sha256: sha256:77470bdaa56f3bf30beb1734e0aa0fd676068b09f45d576ce32cae4a51920efb - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sro-notary - principal_id: sro-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 368637434756268187 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 2694379094216255332 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sro-social-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} -config_trust: - trust_anchor_path: /etc/solmara/hosted-relay-bundles/sro-social/consultation/trust-anchor.json - bundle_path: /etc/solmara/hosted-relay-bundles/sro-social/consultation/bundle - antirollback_state_path: /var/lib/registry-relay/cache/config-bundle-antirollback.json diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/manifest.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/manifest.json deleted file mode 100644 index 2e2adb3..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "schema": "registry.platform.config_bundle.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sro-social-consultation", - "instance_id": "sro-social-relay-consultation", - "bundle_id": "solmara-hosted-sro-social-consultation-sequence-2", - "sequence": 2, - "config_hash": "sha256:bdbbc8e2957210edffb72d09ac5fcd1c678e8d5967ff1aa448b57df947adc71f", - "files": [ - { - "path": "config/artifacts/consultation-contracts/child-benefit-household.json", - "sha256": "sha256:77470bdaa56f3bf30beb1734e0aa0fd676068b09f45d576ce32cae4a51920efb" - }, - { - "path": "config/artifacts/evidence/child-benefit-household-by-uin/conformance.json", - "sha256": "sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce" - }, - { - "path": "config/artifacts/evidence/child-benefit-household-by-uin/minimization.json", - "sha256": "sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0" - }, - { - "path": "config/artifacts/evidence/child-benefit-household-by-uin/negative-security.json", - "sha256": "sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95" - }, - { - "path": "config/artifacts/integration-packs/child-benefit-household-by-uin.json", - "sha256": "sha256:bbf3bfb05e1f6020bdc14457db9549ad084ad421b5c59715bdb51d5ea67dc4d3" - }, - { - "path": "config/artifacts/private-bindings/child-benefit-household.json", - "sha256": "sha256:9515fa0b01838a1f07f25b2b86b75843e8ef888438b82989f5ab7d657e2865ba" - }, - { - "path": "config/relay.yaml", - "sha256": "sha256:bdbbc8e2957210edffb72d09ac5fcd1c678e8d5967ff1aa448b57df947adc71f" - } - ], - "created_at": "2026-07-29T03:29:42.099976Z" -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/consultation/bundle/manifest.sig.json b/config/hosted-relay-bundles/sro-social/consultation/bundle/manifest.sig.json deleted file mode 100644 index 3391ec2..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/bundle/manifest.sig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "registry.platform.config_bundle_signatures.v1", - "signatures": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "alg": "EdDSA", - "sig": "goLJG2a662RgI1VIrhIzsi6m3EymC5-2lSCj2v4mWJ06wb76HKQdZvjsRaoH64tA4RsLTOhVzG60NK4rpwgfBw" - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/consultation/trust-anchor.json b/config/hosted-relay-bundles/sro-social/consultation/trust-anchor.json deleted file mode 100644 index d0916d4..0000000 --- a/config/hosted-relay-bundles/sro-social/consultation/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sro-social-consultation", - "instance_id": "sro-social-relay-consultation", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/hosted-relay-bundles/sro-social/trust-anchor.json b/config/hosted-relay-bundles/sro-social/trust-anchor.json deleted file mode 100644 index 12e0ff3..0000000 --- a/config/hosted-relay-bundles/sro-social/trust-anchor.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema": "registry.platform.config_trust_anchor.v1", - "product": "registry-relay", - "environment": "hosted", - "stream_id": "solmara-hosted-sro-social", - "instance_id": "sro-social-relay", - "signers": [ - { - "kid": "ef54I2IpdsgOpT6a7ASs8rBiMc6iK6ThBlTGSKDAnT8", - "jwk": { - "kty": "OKP", - "kid": "solmara-hosted-relay-config-2026-07-29", - "alg": "EdDSA", - "crv": "Ed25519", - "x": "0s7jygHjfzfMWJikV4FYv9Ov7XFEy_C-7Kb_EXvTjOw" - }, - "enabled": true - } - ] -} \ No newline at end of file diff --git a/config/walt/Caddyfile b/config/walt/Caddyfile index 5a24488..998be97 100644 --- a/config/walt/Caddyfile +++ b/config/walt/Caddyfile @@ -10,8 +10,8 @@ # /wallet-api/* to the wallet-api backend on the SAME origin. walt's web wallet # calls the API at a relative /wallet-api path on its own origin, so this # same-origin routing must be preserved (it is what walt's stock Caddyfile does; -# only the demo-wallet site block is kept here, the issuer/verifier/portal -# blocks are dropped because the Notary is the issuer in this lab). +# only the demo-wallet site block is kept here; issuer, verifier, and portal +# services are outside this holder-only profile). :7101 { reverse_proxy http://waltid-demo-wallet:7101 # frontend reverse_proxy /wallet-api/* http://wallet-api:7001 # backend diff --git a/config/walt/README.md b/config/walt/README.md index f411626..330758c 100644 --- a/config/walt/README.md +++ b/config/walt/README.md @@ -17,14 +17,10 @@ compose bind mounts from the repo; see the runbook). `Caddyfile` and this ## Deltas from stock walt -1. **`registration-defaults.conf`** — `defaultKeyConfig.keyType` is `Ed25519` - (stock: `secp256r1`). This keeps holder keys compatible with the Registry - Notary `EdDSA` plus `did:jwk` binding used when an authority project exposes - a reviewed issuance flow. -2. **`auth.conf`** — the login-session `encryptionKey` / `signKey` / `tokenKey` +1. **`auth.conf`** — the login-session `encryptionKey` / `signKey` / `tokenKey` are read from the environment (`WALT_AUTH_*`) instead of walt's public sample values, and fail-closed if unset. -3. **`dev-mode.conf`** — `enableDidWebResolverHttps=true` (stock: `false`) so the +2. **`dev-mode.conf`** — `enableDidWebResolverHttps=true` (stock: `false`) so the wallet resolves authority `did:web` issuers over HTTPS. ## Ingress diff --git a/config/walt/registration-defaults.conf b/config/walt/registration-defaults.conf index a22bbd2..f644166 100644 --- a/config/walt/registration-defaults.conf +++ b/config/walt/registration-defaults.conf @@ -1,12 +1,8 @@ // Setup what key should be generated on registration // -// Solmara Lab change: keyType is Ed25519 (walt's stock default is secp256r1). -// Authority Notary credential profiles use EdDSA and did:jwk holder binding. -// An Ed25519 JWK key with the did:jwk method produces that binding, so the -// wallet can receive credentials issued by those profiles. defaultKeyConfig: { backend: jwk - keyType: Ed25519 + keyType: secp256r1 } // Setup what DID should be generated based on above above defined key on registration diff --git a/docker/esignet-postgres/Dockerfile b/docker/esignet-postgres/Dockerfile index 73b0f45..f1fafb8 100644 --- a/docker/esignet-postgres/Dockerfile +++ b/docker/esignet-postgres/Dockerfile @@ -1,4 +1,4 @@ -ARG POSTGRES_IMAGE=postgres:16 +ARG POSTGRES_IMAGE=postgres@sha256:9479eac93922431e8a512b016d7362ae264f4ba139f840891b51f02931e450db FROM ${POSTGRES_IMAGE} COPY config/esignet/init.sql /docker-entrypoint-initdb.d/init.sql diff --git a/docker/esignet-relay/Dockerfile b/docker/esignet-relay/Dockerfile index f6551ed..f383312 100644 --- a/docker/esignet-relay/Dockerfile +++ b/docker/esignet-relay/Dockerfile @@ -1,20 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 -ARG ESIGNET_BASE_IMAGE=mosipid/esignet-with-plugins:1.8.0 - -FROM maven:3.9.9-eclipse-temurin-21@sha256:3a4ab3276a087bf276f79cae96b1af04f53731bec53fb2e651aca79e4b10211e AS plugin-builder - -WORKDIR /workspace/esignet-relay-authenticator -COPY --from=esignet_relay_authenticator_src . . - -RUN --mount=type=cache,target=/root/.m2 \ - mvn -B -DskipTests clean package && \ - mkdir -p /out && \ - cp target/esignet-relay-authenticator-*.jar /out/esignet-relay-authenticator.jar +ARG ESIGNET_BASE_IMAGE=mosipid/esignet-with-plugins@sha256:47fffdb5a45198b29885a533841129877a7385a12bcb6020c0f6d4335477be39 +ARG ESIGNET_AUTHENTICATOR_JAR_URL +ARG ESIGNET_AUTHENTICATOR_JAR_SHA256 + +FROM alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS authenticator +ARG ESIGNET_AUTHENTICATOR_JAR_URL +ARG ESIGNET_AUTHENTICATOR_JAR_SHA256 +RUN test -n "$ESIGNET_AUTHENTICATOR_JAR_URL" && \ + test -n "$ESIGNET_AUTHENTICATOR_JAR_SHA256" && \ + wget -q -O /esignet-relay-authenticator.jar "$ESIGNET_AUTHENTICATOR_JAR_URL" && \ + printf '%s %s\n' "$ESIGNET_AUTHENTICATOR_JAR_SHA256" /esignet-relay-authenticator.jar | sha256sum -c - FROM ${ESIGNET_BASE_IMAGE} -COPY --from=plugin-builder /out/esignet-relay-authenticator.jar /home/mosip/plugins/esignet-relay-authenticator.jar +COPY --from=authenticator /esignet-relay-authenticator.jar /home/mosip/plugins/esignet-relay-authenticator.jar COPY scripts/start-esignet-relay.sh /usr/local/bin/start-esignet-relay.sh USER root diff --git a/docker/esignet-ui/Dockerfile b/docker/esignet-ui/Dockerfile index 5bc9c0e..6d6b955 100644 --- a/docker/esignet-ui/Dockerfile +++ b/docker/esignet-ui/Dockerfile @@ -1,5 +1,18 @@ -ARG ESIGNET_UI_IMAGE=mosipid/oidc-ui:1.8.0 +ARG ESIGNET_UI_IMAGE=mosipid/oidc-ui@sha256:8a2a6839b4e22be6c967dabc6308190c165c54604a778c2d9b1aae8091db93e7 FROM ${ESIGNET_UI_IMAGE} ARG ESIGNET_NGINX_CONF=config/esignet/nginx-hosted.conf -COPY ${ESIGNET_NGINX_CONF} /etc/nginx/nginx.conf +COPY ${ESIGNET_NGINX_CONF} /home/mosip/nginx-hosted.conf.template +COPY docker/esignet-ui/render-hosted-nginx.sh /home/mosip/render-hosted-nginx.sh +COPY docker/esignet-ui/hosted-entrypoint.sh /home/mosip/hosted-entrypoint.sh + +USER root +RUN mv /home/mosip/configure_start.sh /home/mosip/configure-ui.sh && \ + chmod 0555 /home/mosip/configure-ui.sh \ + /home/mosip/render-hosted-nginx.sh \ + /home/mosip/hosted-entrypoint.sh && \ + chmod 0444 /home/mosip/nginx-hosted.conf.template +USER 1001:1001 + +ENTRYPOINT ["/home/mosip/hosted-entrypoint.sh"] +CMD ["nginx", "-c", "/tmp/solmara-nginx.conf", "-g", "daemon off;"] diff --git a/docker/esignet-ui/hosted-entrypoint.sh b/docker/esignet-ui/hosted-entrypoint.sh new file mode 100755 index 0000000..4d6b2dd --- /dev/null +++ b/docker/esignet-ui/hosted-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +/home/mosip/render-hosted-nginx.sh \ + /home/mosip/nginx-hosted.conf.template \ + /tmp/solmara-nginx.conf + +exec sh /home/mosip/configure-ui.sh "$@" diff --git a/docker/esignet-ui/render-hosted-nginx.sh b/docker/esignet-ui/render-hosted-nginx.sh new file mode 100755 index 0000000..cfed5d4 --- /dev/null +++ b/docker/esignet-ui/render-hosted-nginx.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + exit 64 +fi + +template=$1 +output=$2 + +validate_host() { + candidate=$1 + [ -n "$candidate" ] || return 1 + [ "${#candidate}" -le 253 ] || return 1 + printf '%s\n' "$candidate" | awk -F. ' + NF < 2 { exit 1 } + { + for (i = 1; i <= NF; i++) { + if (length($i) < 1 || length($i) > 63 || + $i !~ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/) { + exit 1 + } + } + } + ' +} + +# Only the hosted template carries host placeholders. The local template binds +# loopback ports instead, so it renders unchanged and needs no public hosts. +if ! grep -q '__ESIGNET_' "$template"; then + cat "$template" >"$output" + exit 0 +fi + +if ! validate_host "${SOLMARA_ESIGNET_PUBLIC_HOST:-}" || + ! validate_host "${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-}"; then + echo "eSignet hosted nginx host configuration is invalid" >&2 + exit 78 +fi + +# The validated values contain only lower-case DNS hostname characters. This +# makes these two literal substitutions safe in nginx directive and CSP slots. +sed \ + -e "s/__ESIGNET_PUBLIC_HOST__/${SOLMARA_ESIGNET_PUBLIC_HOST}/g" \ + -e "s/__ESIGNET_UI_PUBLIC_HOST__/${SOLMARA_ESIGNET_UI_PUBLIC_HOST}/g" \ + "$template" >"$output" diff --git a/docker/hosted-authority-provisioner/Dockerfile b/docker/hosted-authority-provisioner/Dockerfile new file mode 100644 index 0000000..0d62d10 --- /dev/null +++ b/docker/hosted-authority-provisioner/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.7 + +ARG PYTHON_IMAGE=python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +ARG ASSET_PYTHON_IMAGE=python:3.12-slim-trixie@sha256:229a2c5bfa27522db7815ea81f9bed70af17ccb9de9fc7ad142b1877b5830d36 +ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:440fd6477af86a2f1b38080c539f1672cd22acb1b1a47e321dba5158ab08864d +ARG ALPINE_IMAGE=alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc +ARG REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL +ARG REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256 + +FROM ${ALPINE_IMAGE} AS relayctl-download +ARG REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL +ARG REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256 +RUN test -n "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL" && \ + test -n "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256" && \ + wget -q -O /relayctl "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL" && \ + printf '%s %s\n' "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256" /relayctl | sha256sum -c - && \ + chmod 0755 /relayctl + +FROM ${UV_IMAGE} AS uv + +FROM ${ASSET_PYTHON_IMAGE} AS assets +ENV PYTHONDONTWRITEBYTECODE=1 +COPY --from=uv /uv /usr/local/bin/uv +COPY --from=relayctl-download /relayctl /usr/local/bin/relayctl +WORKDIR /workspace +COPY pyproject.toml uv.lock ./ +RUN uv export --frozen --no-dev --no-hashes -o /requirements.txt && \ + pip install --no-cache-dir -r /requirements.txt && rm /requirements.txt +COPY generator/solmara_lab/__init__.py generator/solmara_lab/generate.py generator/solmara_lab/publisher.py ./generator/solmara_lab/ +COPY relays ./relays +COPY evidence/cells ./evidence/cells +COPY evidence/mint.yaml ./evidence/mint.yaml +COPY scripts/hosted-runtime-assets.py ./scripts/hosted-runtime-assets.py +RUN python scripts/hosted-runtime-assets.py build \ + --root /workspace \ + --output /opt/solmara-hosted-assets \ + --relayctl /usr/local/bin/relayctl + +FROM ${PYTHON_IMAGE} +ENV PYTHONDONTWRITEBYTECODE=1 +COPY --from=uv /uv /usr/local/bin/uv +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv export --frozen --no-dev --no-hashes -o /requirements.txt && \ + pip install --no-cache-dir -r /requirements.txt && rm /requirements.txt +RUN install -d -o 65532 -g 65532 -m 0755 \ + /provisioned/runtime /provisioned/source /provisioned/extracts && \ + install -d -o 65532 -g 65532 -m 0700 /provisioned/secrets +COPY --from=assets /opt/solmara-hosted-assets /opt/solmara-hosted-assets +COPY scripts/hosted-runtime-assets.py /app/scripts/hosted-runtime-assets.py +COPY scripts/provision-hosted-runtime.py /app/scripts/provision-hosted-runtime.py +USER 65532:65532 +ENTRYPOINT ["python", "/app/scripts/provision-hosted-runtime.py"] diff --git a/docker/hosted-transit-signer/Dockerfile b/docker/hosted-transit-signer/Dockerfile new file mode 100644 index 0000000..f761e74 --- /dev/null +++ b/docker/hosted-transit-signer/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1.7 + +FROM ghcr.io/astral-sh/uv@sha256:440fd6477af86a2f1b38080c539f1672cd22acb1b1a47e321dba5158ab08864d AS uv + +FROM python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df AS dependencies +WORKDIR /build +COPY --from=uv /uv /usr/local/bin/uv +COPY pyproject.toml uv.lock ./ +RUN uv export --frozen --no-dev --no-hashes -o requirements.txt \ + && sed -i '/^pyyaml==/d' requirements.txt \ + && pip install --no-cache-dir --prefix=/runtime -r requirements.txt + +FROM python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +COPY --from=dependencies /runtime /usr/local +COPY --chmod=0555 scripts/hosted-transit-signer.py /opt/solmara-transit-signer/hosted-transit-signer.py +COPY --chmod=0555 scripts/local-transit-proxy.py /opt/solmara-transit-signer/local-transit-proxy.py +USER 65532:65532 +ENTRYPOINT ["python", "/opt/solmara-transit-signer/hosted-transit-signer.py"] diff --git a/docker/notary/Dockerfile b/docker/notary/Dockerfile deleted file mode 100644 index a009a3e..0000000 --- a/docker/notary/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -ARG REGISTRY_NOTARY_IMAGE=ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b -FROM ${REGISTRY_NOTARY_IMAGE} - -COPY runtime/registry-projects/hosted/cra-civil/notary/notary.yaml /etc/solmara/registry-projects/hosted/cra-civil/notary/notary.yaml -COPY runtime/registry-projects/hosted/nia-population/notary/notary.yaml /etc/solmara/registry-projects/hosted/nia-population/notary/notary.yaml -COPY runtime/registry-projects/hosted/sro-social/notary/notary.yaml /etc/solmara/registry-projects/hosted/sro-social/notary/notary.yaml -COPY runtime/registry-projects/hosted/mosd-programme/notary/notary.yaml /etc/solmara/registry-projects/hosted/mosd-programme/notary/notary.yaml -COPY runtime/registry-projects/hosted/sipf-pensions/notary/notary.yaml /etc/solmara/registry-projects/hosted/sipf-pensions/notary/notary.yaml -COPY runtime/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml /etc/solmara/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml -COPY config/postgres/ssl/server.crt /etc/solmara/postgres/root.crt diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile deleted file mode 100644 index dc53fa3..0000000 --- a/docker/postgres/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -ARG POSTGRES_IMAGE=postgres:16-alpine -FROM ${POSTGRES_IMAGE} - -COPY ministries/interior-population/fixtures /docker-entrypoint-initdb.d -COPY ministries/labour-pensions/fixtures /docker-entrypoint-initdb.d -COPY scripts/init-notary-postgresql.sh /docker-entrypoint-initdb.d/010-init-notary-postgresql.sh -COPY scripts/postgres-entrypoint.sh /usr/local/bin/solmara-postgres-entrypoint.sh -COPY config/postgres/ssl /run/solmara-postgres-ssl - -RUN chmod 0755 /usr/local/bin/solmara-postgres-entrypoint.sh \ - /docker-entrypoint-initdb.d/010-init-notary-postgresql.sh diff --git a/docker/registry-stack-release-binary/Dockerfile b/docker/registry-stack-release-binary/Dockerfile new file mode 100644 index 0000000..6ddf53f --- /dev/null +++ b/docker/registry-stack-release-binary/Dockerfile @@ -0,0 +1,5 @@ +# syntax=docker/dockerfile:1.7 + +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS relayctl +COPY --chown=65532:65532 --chmod=0755 relayctl /usr/local/bin/relayctl +ENTRYPOINT ["/usr/local/bin/relayctl"] diff --git a/docker/relay-runtime/Dockerfile b/docker/relay-runtime/Dockerfile deleted file mode 100644 index 999130e..0000000 --- a/docker/relay-runtime/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -ARG REGISTRY_RELAY_IMAGE - -FROM rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 AS builder -WORKDIR /workspace/registry-stack - -COPY . . - -RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/workspace/registry-stack/target \ - cargo build --release --locked \ - --package registry-relay \ - && cp target/release/registry-relay /usr/local/bin/registry-relay \ - && cp target/release/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker - -FROM ${REGISTRY_RELAY_IMAGE} - -ARG REGISTRY_RELAY_IMAGE -ARG REGISTRY_STACK_SOURCE_COMMIT -LABEL org.opencontainers.image.base.name="${REGISTRY_RELAY_IMAGE}" -LABEL org.opencontainers.image.source="https://github.com/registrystack/solmara-lab" -LABEL org.opencontainers.image.revision="${REGISTRY_STACK_SOURCE_COMMIT}" - -COPY --from=builder /usr/local/bin/registry-relay /usr/local/bin/registry-relay -COPY --from=builder /usr/local/bin/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker diff --git a/docker/relay/Dockerfile b/docker/relay/Dockerfile deleted file mode 100644 index 81ab4c3..0000000 --- a/docker/relay/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -ARG REGISTRY_RELAY_IMAGE=ghcr.io/registrystack/registry-relay@sha256:71082176f00107e420ffe79471f0bd3375c3dd1ae1c3c8c3a24100c602fa82b7 -ARG VOLUME_INIT_IMAGE=busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662 -FROM ${VOLUME_INIT_IMAGE} AS volume-init -FROM ${REGISTRY_RELAY_IMAGE} - -COPY --from=volume-init /bin/busybox /bin/busybox -COPY ministries /var/lib/solmara/ministries -COPY config/hosted-relay-bundles /etc/solmara/hosted-relay-bundles -COPY config/postgres/ssl/server.crt /etc/solmara/postgres/root.crt diff --git a/docker/static-metadata/Dockerfile b/docker/static-metadata/Dockerfile index bd12ba9..7163b27 100644 --- a/docker/static-metadata/Dockerfile +++ b/docker/static-metadata/Dockerfile @@ -1,4 +1,4 @@ -ARG STATIC_METADATA_IMAGE=python:3.12-alpine +ARG STATIC_METADATA_IMAGE=python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df FROM ${STATIC_METADATA_IMAGE} COPY metadata/public /srv/static diff --git a/docs/changelog.md b/docs/changelog.md index 877d4b5..cbae640 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,67 @@ A small dated log of what changed in the visitor center and the lab topology. Newest entry first. +## 2026-08-19 Registry Stack v0.22.0 + +Solmara pins Registry Stack v0.22.0 across `versions.env`, the release-pin +gate, and the home release-identity surface. The Relay, Evidence, and Mint OCI +references move to the v0.22.0 digests, and the Relayctl authoring helper is +built from the v0.22.0 `linux-amd64` release asset and its checksum. The +topology is unchanged; v0.22.0 adds a container runtime deployment preflight, +a strict Mint readiness probe, Mint support for compatible OAuth clients, +eager validation of Evidence trusted public keys, and root-owned Relay +configuration. + +## 2026-08-13 Official Registry Stack runtime images + +Solmara no longer assembles or publishes local Evidence and Mint runtime +wrappers. Relay, Evidence, and Mint are now closed to their official Registry +Stack v0.21.0 GHCR repositories and immutable digests from `versions.env`; +only the Relayctl authoring helper is assembled locally from a +checksum-verified release asset. The release-candidate handoff carries the +upstream Evidence and Mint +references unchanged and publishes only Solmara-owned deployment images. Relay +now follows the same closed handoff: hosted Compose consumes the canonical +`REGISTRY_RELAY_IMAGE` reference rather than rebuilding it from a separate +digest input. + +## 2026-08-12 Authority-owned Relay and Evidence reset + +Solmara now authors six independent Evidence cells and five Relay V2 projects. +CRA, NIA, and SRO publish immutable SQLite extracts for direct Evidence use; +CRA, MoSD, SIPF, and NAgDI expose only named exact Relay lookups, with NIA's +Relay reserved for the optional eSignet UserInfo profile. The programme code +routes all 11 preserved requirements to their owning authority, verifies each +ES256 JWS against that authority's JWKS, and composes application outcomes. + +The reset replaces the old singleton Evidence, Records API, ambient purpose +header, and authority decision-service topology. Mutable Relay publications and +versioned immutable extracts now have deliberately different lifecycle proof. +Registry Stack v0.19.0 was found incapable of issuing Relay-compatible Mint +claims and of representing Relay's concealed unresolved outcome in Evidence +fixtures. v0.20.1 added those capabilities but did not publish official +Evidence and Mint images. The completed reset therefore pins Registry Stack +v0.21.0 by exact release source, Relayctl checksum, and official Relay, +Evidence, and Mint OCI digests. The separately released +`esignet-relay-authenticator` v0.2.0 JAR is pinned by SHA-256. + +## 2026-08-06 Registry Stack main Evidence migration + +The local lab now builds Relay, Registry Evidence, and Registry Mint from the +exact Registry Stack `main` commit pinned in `versions.env`. Six authority +Relays expose current Records APIs to one Evidence service through scoped, +short-lived workload credentials. Mint uses `private_key_jwt` client +authentication, and the application journeys consume flattened signed JWS +Evidence assertions instead of authority Notary evaluations. + +All six Registry projects and their committed runtime closures are regenerated +with Registryctl 0.17.0. Eleven Evidence requirements cover child benefit, +pension and survivor workflows, farmer and livestock controls, and citizen +self-service. The active local quality gates validate the paired Mint config, +the Evidence bundle, and 89 Evidence fixture cases. Hosted and Coolify files +remain on the earlier released Notary topology and are explicitly unsupported +on this source-only branch. + ## 2026-07-29 Registry Stack v0.15.2 adoption The lab now consumes the canonical Registry Stack Relay image directly. The diff --git a/docs/esignet.md b/docs/esignet.md new file mode 100644 index 0000000..e8258cd --- /dev/null +++ b/docs/esignet.md @@ -0,0 +1,24 @@ +# Optional eSignet profile + +eSignet is an optional authentication profile, not an Evidence authority. The +v0.2.0 authenticator validates the challenge and consent first, then obtains a +short-lived Mint token using the `nia-esignet` private-key JWT client and calls +`POST /v2/resources/population-person/lookups/esignet-userinfo`. + +The request contains only the selected UIN and consented `fields`. The adapter +parses only `data.domainData`, preserves PSUT, KYC-token and JWS behaviour, and +collapses unresolved, concealed, denied, authentication, and dependency +failures to generic subject-facing results. It never logs selectors, tokens, or +source values. + +The deployment verifies the v0.2.0 JAR against its published SHA-256 before +building the eSignet image. The NIA Relay, Mint client, and authenticator private +JWK remain independent operator-owned runtime material. + +For hosted deployment, `compose.coolify.esignet.yaml` is applied as an overlay +on `compose.coolify.yaml`. It switches the existing Portal to the exact hosted +issuer, authorization, token, UserInfo, private-key-JWT client, and callback +configuration. The Portal client private key is provided only to Portal and the +one-time eSignet client seeder. It is separate from both the `nia-esignet` Mint +client key held by the authenticator and the `solmara-demo` Evidence client key +used by the programme application. diff --git a/docs/hosted-deployment.md b/docs/hosted-deployment.md index 1a690d8..cbc98ff 100644 --- a/docs/hosted-deployment.md +++ b/docs/hosted-deployment.md @@ -1,285 +1,204 @@ -# Solmara Lab hosted deployment - -Status: operational runbook for the public Solmara Lab deployment. - -This guide describes the public Coolify topology without publishing private -control-plane coordinates or secret values. Keep those values in the private -operations store for the target environment. - -## Deployment model - -The hosted lab has one edge application and four authority applications. Each -authority runs one public Relay, one private consultation Relay, and one -Notary. Only the consultation Relay and Notary share a network namespace. The -public Relay remains independently routable, while the consultation Relay -binds only to loopback and is not published. Their PostgreSQL -correctness-state databases and roles remain distinct, and each Relay process -owns a persistent snapshot-cache volume. - -| Coolify app | Compose file | Authority services | -|---|---|---| -| `solmara-lab` | `compose.coolify.yaml` | Visitor Center, portal, scenario runner, static metadata, child-benefit evidence composer | -| `solmara-lab-interior` | `compose.coolify.interior.yaml` | CRA and NIA public Relay + private consultation Relay + Notary, PostgreSQL | -| `solmara-lab-social-development` | `compose.coolify.social-development.yaml` | SRO and Programme public Relay + private consultation Relay + Notary, PostgreSQL | -| `solmara-lab-labour-pensions` | `compose.coolify.labour-pensions.yaml` | SIPF public Relay + private consultation Relay + Notary, PostgreSQL | -| `solmara-lab-agriculture` | `compose.coolify.agriculture.yaml` | NAgDI public Relay + private consultation Relay + Notary, PostgreSQL | -| `solmara-lab-esignet` | `compose.coolify.esignet.yaml` | eSignet, eSignet UI and edge, eSignet PostgreSQL and Redis, seed jobs | -| `solmara-lab-wallet` | `compose.coolify.walt.yaml` | Walt holder wallet demonstrator and its backing services | - -The child-benefit evidence composer retains the service identifier -`child-benefit-federator` for existing routes. It calls the CRA, NIA, SRO, and -Programme authority Notaries and combines their responses. It is not a Notary, -does not own Notary correctness state, and does not change the six-pair -topology. - -Hosted Compose files follow these rules: - -- Coolify owns public routing, so services have no host port bindings. -- Runtime configuration is checked into the repository and mounted read-only. - Secret files are not checked in, and no configuration mount is writable. -- Cross-application calls use public HTTPS endpoints. The applications do not - share a custom Docker network. -- Hosted services run digest-pinned images and do not use `build:` blocks. -- Every authority Notary owns one PostgreSQL database, owner, migrator, and - runtime role. -- Every public and consultation Relay mounts its own named volume at - `/var/lib/registry-relay/cache`. A durable materialization publication - pointer and its immutable Parquet snapshot must survive the same restart. -- Registry Notary has no Redis service or Redis volume. `esignet-redis` belongs - only to eSignet. - -## Authority pairs and endpoints - -| Authority | Relay service and endpoint | Notary service and endpoint | -|---|---|---| -| CRA | `cra-civil-relay`, `https://cra-relay.solmara.registrystack.org` | `cra-notary`, `https://cra-notary.solmara.registrystack.org` | -| NIA | `nia-population-relay`, `https://nia-relay.solmara.registrystack.org` | `nia-notary`, `https://nia-notary.solmara.registrystack.org` | -| SRO | `sro-social-relay`, `https://sro-relay.solmara.registrystack.org` | `sro-notary`, `https://sro-notary.solmara.registrystack.org` | -| Programme | `programme-mis-relay`, `https://mosd-programme-relay.solmara.registrystack.org` | `programme-notary`, `https://programme-notary.solmara.registrystack.org` | -| SIPF | `sipf-pensions-relay`, `https://sipf-relay.solmara.registrystack.org` | `sipf-notary`, `https://sipf-notary.solmara.registrystack.org` | -| NAgDI | `nagdi-agriculture-relay`, `https://nagdi-relay.solmara.registrystack.org` | `nagdi-notary`, `https://nagdi-notary.solmara.registrystack.org` | - -The other public endpoints are: - -| Service | Endpoint | -|---|---| -| Visitor Center | `https://solmara.registrystack.org` | -| Portal | `https://portal.solmara.registrystack.org` | -| Static metadata | `https://metadata.solmara.registrystack.org` | -| Child-benefit evidence composer | `https://child-benefit-federator.solmara.registrystack.org` | -| eSignet | `https://esignet.solmara.registrystack.org` | -| eSignet UI | `https://esignet-ui.solmara.registrystack.org` | -| Walt holder wallet | `https://wallet.solmara.registrystack.org` | - -## Image model - -Registry Stack Relay and Notary image refs are inputs to the Solmara wrapper -builds. The `release-candidate` workflow requires a Registry Stack candidate -or release tag and accepts only Relay and Notary input digests that match the -committed `versions.env` pins. It also checks out the exact Registry Stack -source commit declared there for release and contract verification. Governed -attribute release is part of the canonical Registry Stack v0.15.2 Relay -image, which the workflow uses directly as the base of the deployable Solmara -Relay wrapper. Solmara does not compile or publish a feature-specific Relay -runtime. The workflow reports immutable digest refs for these Coolify -variables: - -- `SOLMARA_RELAY_IMAGE` -- `SOLMARA_NOTARY_IMAGE` -- `SOLMARA_POSTGRES_IMAGE` -- `SOLMARA_STATIC_METADATA_IMAGE` -- `SOLMARA_SCENARIO_RUNNER_IMAGE` -- `SOLMARA_HOME_IMAGE` -- `SOLMARA_PORTAL_IMAGE` -- `SOLMARA_ESIGNET_RELAY_IMAGE` -- `SOLMARA_ESIGNET_POSTGRES_IMAGE` -- `SOLMARA_ESIGNET_UI_IMAGE` -- `SOLMARA_ESIGNET_SEED_IMAGE` - -Before dispatching the workflow, run -`just review-release ` with that same tag. `just review` -remains the no-argument contributor and CI gate. - -Use `image@sha256:` values in Coolify. Do not deploy mutable tags. -`REGISTRY_STACK_PLATFORM` defaults to `linux/amd64`; override it only when the -selected Registry Stack release publishes another platform. - -## Configuration and secrets - -The six authority projects are the source of Relay and Notary runtime -configuration. Regenerate both local and hosted closures after changing a -project: - -```bash -just registry-projects-sync -just registry-projects-runtime-check +# Hosted deployment + +Deploy the authority-cell reset only from the exact Registry Stack release +recorded in `versions.env`. Relay, Evidence, and Mint must use their official +Registry Stack OCI images pinned by digest. Solmara-owned images are also +digest-pinned, while the local Relayctl helper is assembled from its +checksum-verified release asset. A missing, floating, or mismatched pin stops +deployment. + +The sanitized hosted image manifest carries `REGISTRY_RELAY_IMAGE`, +`SOLMARA_EVIDENCE_IMAGE`, and `SOLMARA_MINT_IMAGE` unchanged from +`versions.env`. Compose consumes those exact full references. The separate +Relay digest field remains release-verification evidence only and is not a +deployment input. + +Registry Stack v0.20.0 remains immutable and does not contain the Evidence +capability required by this lab. v0.20.1 contains the required runtime +capabilities but does not publish official Evidence and Mint OCI images. +Registry Stack v0.21.0 is the first coherent release with all three official +runtime images; the lab currently pins v0.22.0 from that line. Do not move a +release, substitute a floating source reference, or recreate those runtime +images in Solmara. + +## Release package precondition + +Before the first release-candidate build, an organization owner must provision +these two public, anonymously pullable GitHub Container Registry packages and +grant the repository's GitHub Actions workflow write access: + +- `ghcr.io/registrystack/solmara-lab-authority-provisioner` +- `ghcr.io/registrystack/solmara-lab-transit-signer` + +The Registry Stack release owns the public `relay`, `evidence`, and `mint` +packages. Solmara only reads those upstream digest references from +`versions.env`; its workflow neither rebuilds nor republishes them. The +authority provisioner contains the reviewed contracts and deterministic +publications. The Transit signer contains only the signer runtime. Release +handoff records the immutable digest of every upstream and Solmara-owned image. +Do not reuse an unrelated package or deploy a mutable tag. + +## Authority topology + +The hosted topology contains five Relay V2 services, six independently signed +Evidence cells, one shared lab Mint, and the programme application. The optional +eSignet profile uses the NIA Relay lookup. + +Evidence hosts are: + +- `cra-evidence.solmara.registrystack.org` +- `nia-evidence.solmara.registrystack.org` +- `sro-evidence.solmara.registrystack.org` +- `mosd-programme-evidence.solmara.registrystack.org` +- `sipf-evidence.solmara.registrystack.org` +- `nagdi-evidence.solmara.registrystack.org` + +`compose.coolify.provision.yaml` is a dedicated operator-only application. It +owns 34 fixed-name active volumes and runs only the one-shot target +provisioners: + +| Owner | Volumes | Contents | +|---|---:|---| +| Shared Mint | 3 | Runtime, secrets, and Transit socket | +| Five Relays | 10 | One runtime and one mutable source volume per authority | +| Six Evidence cells | 21 | Runtime, secrets, and Transit socket per cell, plus the CRA, NIA, and SRO immutable-extract volumes | + +`compose.coolify.signers.yaml` is a separate operator-only application. It +attaches only the seven fixed Transit volumes as external volumes and runs one +isolated signer for Mint and one for each Evidence cell. The provision +application never receives a private issuer signing key. Runtime applications +attach the fixed-name runtime, source, secret, extract, and Transit volumes as +external read-only volumes. Each runtime application owns the writable audit +volumes for its services and initializes their permissions without reading or +replacing existing audit records. + +Docker Compose injects an environment-backed secret after creating its target +container and rejects that operation when the root filesystem is read-only. +Relay provisioners and Transit initializers remain read-only. The one-shot +Evidence and Mint provisioners and the non-root signer processes instead mount +`/run/secrets` as tmpfs. They remain networkless with no new privileges; signer +processes also run with every Linux capability dropped. Secret values are not +placed in container environment variables. The provisioner copies only the +required non-signing runtime secrets into each authority's isolated secret +volume; private issuer signing keys remain confined to signer tmpfs. + +## Mint clients + +The shared Mint registers nine clients under the common lab audience. Eight are +least-authority source clients, each limited to its named operation: + +- `cra-pension-evidence` +- `cra-citizen-evidence` +- `mosd-child-benefit-evidence` +- `sipf-pension-evidence` +- `sipf-survivor-evidence` +- `nagdi-voucher-evidence` +- `nagdi-livestock-evidence` +- `nia-esignet` + +The ninth client, `solmara-demo`, belongs to the programme application and is +used to request assertions from the authority Evidence cells. The shared Mint +is a lab convenience, not production tenancy guidance. + +## Secret boundary + +Create deployment secrets for these classes without placing their values in a +Compose file, repository file, build log, or delivery record: + +- one private and public signing JWK pair for Mint and for each of the six + Evidence issuers; +- one private and public client JWK pair for each of the nine Mint clients; +- authority-specific Relay audit HMAC keys, plus cursor HMAC keys for the Relay + contracts that require cursors; +- Mint and Evidence audit HMAC keys; +- one subject-binding HMAC key for each Evidence cell; +- the programme federator token; +- when eSignet is enabled, its database credential, KYC-token and PSUT HMAC + secrets, KYC keystore credentials, and portal OIDC client key. + +Each signing private JWK is projected only to its dedicated signer. That signer +also receives the matching public half and refuses startup unless it is the +exact projection of the private key. A Mint +client private JWK is installed only for its client owner, and an +authority-scoped one-shot provisioner may write it only into that owner's +secret volume. Inject public JWK halves into the provisioner and their matching +signers only; the provisioner writes +the issuer projections and Mint client registrations into the generated +runtime material. No runtime receives another authority's client private key. + +Never commit a private key, token, generated database, generated Relay package, +runtime bundle, or audit log. + +## Immutable extract lifecycle + +CRA birth, NIA population, and SRO poverty use immutable SQLite extracts. The +hosted provisioner creates the initial checked publication and reuses the exact +active filename on a restart. It never overwrites an active extract in place. + +To publish a later checkpoint, override the matching direct-cell provisioner +service command with: + +```text +publish-extract --target -evidence --assets /opt/solmara-hosted-assets --runtime-output /provisioned/runtime --extract-output /provisioned/extracts ``` -Hosted Relay does not boot those compiler outputs as unsigned non-local -configuration. Each public and consultation Relay carries its own -instance-bound signed Config Bundle, public trust anchor, and anti-rollback -state, with no private signing key. The public bundle excludes private -consultation artifacts; the consultation bundle contains the complete -consultation closure. Verify that every signed closure still projects exactly -to the compiler output: - -```bash -just hosted-relay-bundles-check -``` - -When a hosted Relay config or artifact changes, keep the private JWK in -1Password and pass its `op://` secret reference, together with the public-only -JWK file, to `scripts/generate-hosted-relay-bundles.py`. Registryctl reads the -private member directly through 1Password CLI without writing it into the -repository or a working-tree file. When multiple 1Password accounts are signed -in, select the account with `OP_ACCOUNT` and use vault and item IDs in the -secret reference so the lookup is unambiguous. Increment -`SOLMARA_RELAY_BUNDLE_SEQUENCE` in `versions.env`, generate into a new staging -directory, review the config and manifest diffs, then replace the committed -bundle set. The generator creates separate public and consultation streams for -each authority, and the verification gates require all twelve bundles to use -that exact sequence. Never commit the private JWK. A first deployment seeds -sequence zero only into an empty matching Relay cache volume; successful Relay -startup audits and persists the signed sequence. Later image rollback cannot -lower that durable sequence. Publish a higher signed sequence for a normal -rollback, or use Registry Relay's reviewed break-glass procedure. - -Each authority application needs only the variables referenced by its Compose -file. At minimum, provide: - -- The required digest-pinned `SOLMARA_*_IMAGE` refs. -- `SOLMARA_POSTGRES_PASSWORD` and the source database URL used by NIA or SIPF - when that authority owns a PostgreSQL-backed source projection. -- Three Relay consultation-state credentials per authority: runtime, keyring - maintenance, and keyring reader. -- Two Notary state credentials per authority: migrator and runtime. -- Separate Relay and Notary audit hash secrets for every authority. -- The client token hashes and signing keys named by that authority's generated - runtime configuration. -- The Relay audit pseudonym key and consultation-state retention values named - by the Compose file. - -For example, the CRA app uses `CRA_RELAY_POSTGRES_RUNTIME_PASSWORD`, -`CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD`, -`CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD`, -`CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD`, and -`CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD`. Other authorities use the same suffixes -with `NIA`, `SRO`, `PROGRAMME`, `SIPF`, or `NAGDI`. - -The core application holds only the client tokens it needs to call the six -authority services. It does not receive authority database credentials. -eSignet and Walt credentials stay in their own applications. - -Do not print full environment dumps while deploying. Coolify responses can -include secret values when the caller has sensitive read access. - -## Workload identity prerequisite - -Each hosted authority application runs a loopback-only workload agent that -rotates a five-minute Relay token into its authority-owned named volume. The -Notary runtime and state installer mount that volume read-only and wait for the -agent to become healthy. Private workload JWKs enter only as Coolify production -secrets; they are not present in images, public metadata, or Compose defaults. -A missing, malformed, or expired token keeps the dependent workload unready. - -The public half of every hosted workload key is committed at -`metadata/public/.well-known/jwks.json` and served separately at -`https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json`. -Relay tokens use that HTTPS origin as `iss`, while the token agents remain -bound to container loopback. Keep the public JWKS and all corresponding -Coolify private JWKs in one reviewed rotation. - -The eSignet application has a separate NIA identity with -`azp=solmara-esignet`, `sub=solmara-esignet`, and exactly the -`population:identity_release` scope. Its agent writes -`solmara-esignet-relay-token` as UID/GID `1001:1001` into an eSignet-owned -volume. The eSignet plugin rereads that file for each Relay request, so normal -rotation does not require an eSignet restart. Do not copy the token itself into -a Coolify environment variable or reuse the NIA Notary identity. - -## Coolify application setup - -For each application: - -1. Select this repository and the exact commit being deployed. -2. Use Docker Compose as the build pack. -3. Select the application's `compose.coolify*.yaml` file. -4. Disable generated domains and shared custom networks. -5. Add only the production variables referenced by that Compose file. -6. Attach domains to the exact service names in the endpoint tables. -7. Deploy authority applications before the edge application. - -Run the eSignet seed job once before expecting portal login to pass. The -authority PostgreSQL bootstrap and state-install jobs are idempotent and run as -part of their Compose dependency graph. - -## Deployment verification - -From a trusted shell with the demo client tokens available through `.env` or -the process environment: - -```bash -just hosted-smoke -``` - -The smoke checks all six Relay and Notary endpoints, authority evidence -journeys, purpose denial, the Visitor Center proxy, and the portal backend. -Set `SOLMARA_HOSTED_SMOKE_BROWSER=1` to add hosted browser coverage. - -For each authority, also verify: - -1. Relay `/ready` returns success. -2. Notary `/ready` returns success only after its Relay and database checks. -3. One representative authority evidence request succeeds. -4. A wrong-purpose request is denied without returning the prohibited field. -5. Restarting the Notary preserves correctness state and readiness. - -## PostgreSQL operations - -The bootstrap container creates only the authority keys listed in -`SOLMARA_RELAY_DATABASES` and `SOLMARA_NOTARY_DATABASES`. The serving Relay and -Notary receive runtime credentials only. Schema installation uses dedicated -jobs and never gives migration credentials to a serving process. - -Registry Stack v0.15.2 uses the `v015` Relay state epoch from `versions.env`. -The retained consultation-result schema does not change, but the hosted -deployment now persists every Relay cache that backs a PostgreSQL -materialization publication pointer. Earlier `v013` deployments did not -persist those immutable snapshot files, so reusing their pointers after -container replacement can leave consultation profiles permanently -unavailable. Quiesce old Relay writers, keep the `v013` databases for -rollback, and follow the complete stopped-writer procedure in -[`notary-postgresql-state.md`](notary-postgresql-state.md). - -Back up, restore, and upgrade each Notary database independently. See -[`notary-postgresql-state.md`](notary-postgresql-state.md) for the database map -and recovery sequence. Preserve the eSignet Redis volume separately because it -is outside the Notary state boundary. - -## Troubleshooting - -### Notary stays unready - -Check the matching Relay `/ready`, PostgreSQL health, state-installer exit -status, and workload-token freshness. A Notary intentionally remains unready -when a required Relay profile cannot be verified. - -### State installer fails - -Confirm that the authority key appears in `SOLMARA_NOTARY_DATABASES`, that both -authority Notary passwords are set, and that the wrapper and product images -come from the same release. Do not pass the migrator URL to the serving Notary. - -### Hosted Notary calls a private service name - -Regenerate the hosted project closure with `just registry-projects-sync`. -Hosted Relay source URLs must use the public HTTPS domains in the authority -table. A URL such as `http://cra-civil-relay:8080` cannot cross Coolify -applications. - -### Coolify routes the wrong container port - -Route the Relay hostname to port `8080` on the public Relay service. Route the -Notary hostname to port `8081` in the private consultation Relay namespace. -Never route consultation Relay port `8080`; it is explicitly bound to -`127.0.0.1` for Notary-only access. +The operation validates the current binding, appends the checked publication +under a fresh immutable filename, preserves the old file, and atomically +rebinds only that cell's runtime configuration. Restart only the matching +Evidence cell after the operation succeeds. The running cell continues to read +the old mounted extract until that restart. A malformed, metadata-mismatched, +or non-newer publication fails closed. + +## Deployment order + +Deploy the reset alongside the existing deployment in this order: + +1. Record the old deployment's exact image references, routes, and retained + volume names. Do not attach an old writer to a new source volume. +2. Deploy `compose.coolify.provision.yaml`. Require every one-shot provisioner + to complete successfully. +3. Deploy `compose.coolify.signers.yaml`. Require all seven Transit initializers + to complete successfully and all seven signers to become healthy. +4. Deploy the shared Mint, programme services, portal, Visitor Center, and + static metadata from `compose.coolify.yaml`. Require Mint health, discovery, + and JWKS to pass before starting a Relay, because every Relay validates the + permanent Mint issuer during startup. Do not send programme requests yet. +5. Deploy the authority runtime applications from + `compose.coolify.interior.yaml`, + `compose.coolify.social-development.yaml`, + `compose.coolify.labour-pensions.yaml`, and + `compose.coolify.agriculture.yaml`. Confirm all five Relays and all six + Evidence cells are ready on the private routes. +6. Run the hosted programme, denial, JWKS, source-label, redaction, and UI smoke + against the new routes before changing public routing. +7. If required for this deployment, add `compose.coolify.esignet.yaml` and run + the citizen-login smoke through the NIA Relay V2 lookup. +8. Switch programme authority URLs and public metadata routing only after every + required smoke passes. Disable the superseded services, but retain their + volumes and exact deployment references for recovery. + +Rollback restores routing to the old services and their retained volumes. It +does not reuse a new Relay V2 database with an older binary. Removing a +superseded service or volume is a separate approved cleanup. + +## Hosted acceptance + +Acceptance is based on live hosted behavior, not inferred from local tests. It +requires: + +- child benefit to compose four independently signed assertions and return the + expected five positive concepts; +- pension to compose CRA and SIPF assertions without disclosing cause of death + or unrelated civil data; +- both agriculture journeys to succeed through their NAgDI lookups; +- wrong-purpose and unauthorized calls to fail generically; +- the portal to prove authority URL selection, per-authority JWKS verification, + source-type labels, and redaction; +- the Visitor Center to show the publisher, six Evidence cells, five Relays, + shared Mint, and programme application; +- when the optional profile is deployed, eSignet login to complete through the + NIA Relay V2 lookup. + +Attach only sanitized pass or fail results and public artifact identities to +the delivery record. Do not attach selectors, source rows, tokens, private +audit material, secrets, deployment logs, or private release evidence. diff --git a/docs/notary-postgresql-state.md b/docs/notary-postgresql-state.md deleted file mode 100644 index 9842748..0000000 --- a/docs/notary-postgresql-state.md +++ /dev/null @@ -1,192 +0,0 @@ -# Notary PostgreSQL state - -Solmara Lab runs exactly one Registry Notary beside each authority Relay. Each -Notary owns an independent PostgreSQL database and role set. PostgreSQL servers -may be shared within a local or hosted Compose application, but databases, -owners, migrators, and runtime roles are never shared between Notaries. - -| Authority | Public Relay / private consultation Relay | Notary service | Relay state database | Notary database | Local Relay / Notary | -|---|---|---|---|---|---| -| Civil Registration Authority (CRA) | `cra-civil-relay` / `cra-civil-relay-consultation` | `cra-notary` | `solmara_relay_cra_consultation_v015` | `solmara_notary_cra` | `4311` / `4325` | -| National Identity Agency (NIA) | `nia-population-relay` / `nia-population-relay-consultation` | `nia-notary` | `solmara_relay_nia_consultation_v015` | `solmara_notary_nia` | `4312` / `4326` | -| Social Registry Office (SRO) | `sro-social-relay` / `sro-social-relay-consultation` | `sro-notary` | `solmara_relay_sro_consultation_v015` | `solmara_notary_sro` | `4313` / `4327` | -| Programme MIS | `programme-mis-relay` / `programme-mis-relay-consultation` | `programme-notary` | `solmara_relay_programme_consultation_v015` | `solmara_notary_programme` | `4314` / `4328` | -| Social Insurance and Pensions Fund (SIPF) | `sipf-pensions-relay` / `sipf-pensions-relay-consultation` | `sipf-notary` | `solmara_relay_sipf_consultation_v015` | `solmara_notary_sipf` | `4315` / `4322` | -| National Agricultural Data Institute (NAgDI) | `nagdi-agriculture-relay` / `nagdi-agriculture-relay-consultation` | `nagdi-notary` | `solmara_relay_nagdi_consultation_v015` | `solmara_notary_nagdi` | `4316` / `4323` | - -The local topology shares one PostgreSQL server for developer convenience. -Hosted authority applications keep the same database boundaries within their -own PostgreSQL volume. The runtime role for an authority follows the form -`solmara_notary__runtime`; the owner and migrator roles use the same -authority key. - -The named `postgres-data` volume is mounted directly at PostgreSQL's -`/var/lib/postgresql/data` data directory. Keep that exact mount target while -the topology uses PostgreSQL 16. Mounting the parent directory allows the -image's declared data-directory volume to become anonymous, which would discard -authority state when Compose removes the PostgreSQL container. `just down` -followed by `just up` preserves the named data volume; only `just reset` -deliberately deletes it. - -Run `just notary-state-restart-proof` after a representative live smoke. The -gate records the PostgreSQL system identifier and every correctness-table row -count in all six authority databases, performs the exact `just down` and -`just up` lifecycle, rejects anonymous PGDATA mounts, compares the state before -any new requests are sent, and reruns `state doctor` for every Notary. - -## Startup and readiness - -The startup order is intentional: - -1. `registry-postgresql-bootstrap` creates or attests only the databases and - roles listed by that Compose application. -2. Each private consultation Relay bootstraps its PostgreSQL state before its - serving process starts. -3. The authority workload issuer publishes the verification keys and writes a - short-lived Notary token. Local public and consultation Relay namespaces - have separate loopback-only issuer processes; hosted Relays validate against - the separately served HTTPS JWKS. -4. Each `-notary-state-install` job applies or attests the released - Notary schema with the migrator role once its database and token file are - ready. -5. The matching Notary starts with only its runtime database role and a - read-only workload-token mount. The public Relay starts independently from - the consultation state plane. - -Each Notary shares only its private consultation Relay's network namespace. -The consultation Relay binds `127.0.0.1:8080` and Notary binds port `8081`, so -the consultation path has a direct loopback trust boundary. The separately -routable public Relay never receives Notary consultation traffic. Readiness -remains unavailable until PostgreSQL state and required Relay source profiles -are usable. - -Relay snapshot caches are not Notary correctness state, but they are durable -Relay restart data. Each public and consultation Relay mounts a distinct -`/var/lib/registry-relay/cache` volume so one process's materialization pointer -never outlives its referenced immutable snapshot. - -Inspect one local pair without exposing credentials: - -```bash -curl --fail http://127.0.0.1:4311/ready -curl --fail http://127.0.0.1:4325/ready -docker compose run --rm --no-deps cra-notary \ - --config /etc/registry-notary/notary.yaml state doctor -``` - -Substitute the ports and service name from the table for another authority. - -## Backup and restore - -Back up each Notary database as a complete unit. Do not dump individual tables -or merge databases from different authorities. - -Before an upgrade or recovery drill: - -1. Record the deployed Registry Notary image digest and config revision. -2. Take a consistent PostgreSQL backup of every authority Notary database. -3. Back up the database role credentials in the secret manager, separately - from the database backup. -4. Verify restore into an isolated PostgreSQL server with the same major - version. -5. Run `state doctor` with the restored runtime configuration before sending - traffic. - -For a restore, stop all writers for that authority, restore the complete -database, restore the matching credentials, deploy the recorded Notary image -and config, and run `state doctor`. Reopen traffic only after readiness and a -representative authority scenario pass. - -## Upgrades and rollback - -Treat the schema installer as a release step, not a serving-container -permission. For each authority: - -1. Stop or drain its Notary replicas. -2. Take and verify a complete database backup. -3. Deploy the target PostgreSQL and Notary images. -4. Let the authority's state installer finish successfully. -5. Start serving replicas, run `state doctor`, and verify `/ready` and an - authority scenario. - -Do not run an older Notary binary against a forward-migrated schema. If an -upgrade cannot be completed, restore the pre-upgrade database and the matching -image and configuration together. The normative product procedure is the -[Registry Notary PostgreSQL state operations guide](https://github.com/registrystack/registry-stack/blob/main/products/notary/docs/postgresql-state-operations.md). - -### Registry Stack v0.15.2 cache-persistence cutover - -The v0.15.2 deployment introduces distinct persistent cache volumes for every -public and consultation Relay. The earlier hosted topology persisted -PostgreSQL materialization publication pointers while keeping their immutable -Parquet snapshots in ephemeral container storage. After container -replacement, a surviving pointer can therefore name a snapshot that no -longer exists. - -Solmara establishes a recoverable boundary with -`REGISTRY_RELAY_STATE_EPOCH=v015` in `versions.env`. The bootstrap creates new -Relay databases and roles without deleting or rewriting the `v013` state. -For the cutover: - -1. Stop new authority traffic and stop every `v013` Relay writer. -2. Back up the `v013` databases and retain the matching images and - configuration. -3. Deploy the v0.15.2 Compose closure. Bootstrap creates the `v015` Relay - databases, then each Relay publishes fresh materializations into its - persistent cache. -4. Require every Relay and Notary `/ready` check, the complete smoke suite, - and the restart-persistence proof before reopening traffic. - -Keep the `v013` databases quiesced during the rollback window. Rollback means -restoring the matching pre-cutover deployment as one unit. Never point the -new cache-backed deployment at a `v013` database whose referenced snapshot -files were not preserved. - -### Registry Stack v0.13.0 cutover - -Registry Stack v0.13.0 removes `provenance.consent` from the exact retained -Relay result contract. Old and v0.13.0 Relay binaries must not share a -consultation state plane. Solmara enforces that boundary with -`REGISTRY_RELAY_STATE_EPOCH=v013` in `versions.env`. The PostgreSQL bootstrap -uses the epoch in every Relay database and role name and runs idempotently on -fresh and existing clusters. - -For the v0.10.0 to v0.13.0 cutover: - -1. Stop new authority traffic and stop every old Relay and Notary writer. -2. Drain retained terminal replay lifetimes, then back up every old Relay and - Notary database with its exact image and configuration refs. -3. Deploy the v0.13.0 Compose closure. The bootstrap creates the new `v013` - Relay databases and roles, and each Relay bootstraps its empty state plane. - The Notary installers migrate or attest the existing Notary databases. -4. Require every Relay and Notary `/ready` check, `state doctor`, the complete - local or hosted smoke, and the PostgreSQL restart-persistence proof before - reopening traffic. - -Keep the old, unsuffixed Relay databases quiesced until the rollback window -closes. A rollback restores the pre-upgrade Notary backups and matching -v0.10.0 images and configs, then reconnects the old Relay binaries only to the -old Relay databases. Never point a v0.13.0 Relay at an unsuffixed database or -an old Relay at a `v013` database. - -The local topology deliberately pins PostgreSQL 16 and its -`/var/lib/postgresql/data` mount layout. Do not change the image tag to 18 in -place. PostgreSQL 18's official container layout mounts the parent -`/var/lib/postgresql` directory and places data under a major-specific child. -The [official PostgreSQL container documentation](https://github.com/docker-library/docs/blob/master/postgres/README.md#pgdata) -defines the exact paths. A move to 18 therefore requires a stopped-writer -`pg_upgrade` or verified dump/restore into a newly created PostgreSQL 18 volume, -followed by all six `state doctor` checks and the restart-persistence gate. -Recreating the container against the PostgreSQL 16 volume without that -procedure is not an upgrade. - -## Redis retirement - -Registry Notary has no production Redis dependency in Solmara Lab. The pre-1.0 -cutover deliberately has no importer or dual-write mode. Old purpose-specific -Notary and citizen-issuer Redis volumes are retired and must not be attached to -the six authority Notaries. - -`esignet-redis` in `compose.esignet.yaml` and -`compose.coolify.esignet.yaml` belongs to eSignet. It is not Notary correctness -state and remains part of the eSignet deployment. diff --git a/docs/purposes.md b/docs/purposes.md index 7e27d8a..2935d6b 100644 --- a/docs/purposes.md +++ b/docs/purposes.md @@ -1,77 +1,33 @@ -# Solmara Purpose Catalogue +# Solmara purpose catalogue -Status: normative for Solmara Lab wave 1. +Status: normative for the authority-owned Evidence reset. -All Solmara purpose IRIs are minted under -`https://id.registrystack.org/solmara/purpose/...`. Manifests advertise these -IRIs, notaries enforce these IRIs, and smoke tests assert these IRIs and stable -problem codes. Smoke tests must not assert denial message text. +Evidence requests carry one closed purpose code in the signed request body. +For Relay-backed requirements, the Evidence cell authenticates to Registry Mint +with a dedicated private-key JWT client. Mint issues a token whose registration +fixes the full purpose IRI, exact Relay scope, and `solmara-runtime` audience. +The caller cannot select or override downstream Relay authority. -## Wave 1 Purposes - -| Purpose IRI | Advertised by | Enforced by | Story | Denial problem codes | -|---|---|---|---|---| -| `https://id.registrystack.org/solmara/purpose/child-benefit-review` | Civil Registration Authority, National Identity Agency, Social Registry Office, MoSD programme MIS | CRA, NIA, SRO, and Programme authority Notaries | Birth to child benefit | `pdp.purpose_not_permitted` | -| `https://id.registrystack.org/solmara/purpose/pension-payment-review` | Civil Registration Authority, Social Insurance and Pensions Fund | CRA and SIPF authority Notaries | Death to pension stop | `pdp.purpose_not_permitted` | -| `https://id.registrystack.org/solmara/purpose/survivor-benefit-determination` | Social Insurance and Pensions Fund | SIPF authority Notary | Survivor benefit | `pdp.purpose_not_permitted` | -| `https://id.registrystack.org/solmara/purpose/voucher-eligibility-review` | National Agricultural Data Institute | NAgDI authority Notary | Farmer climate-smart voucher | `pdp.purpose_not_permitted` | -| `https://id.registrystack.org/solmara/purpose/livestock-movement-control` | National Agricultural Data Institute | NAgDI authority Notary | Livestock movement permit companion | `pdp.purpose_not_permitted` | -| `https://id.registrystack.org/solmara/purpose/citizen-self-service` | Civil Registration Authority and National Identity Agency | CRA and NIA authority Notaries | Citizen portal | `pdp.purpose_not_permitted` | - -The two NAgDI purpose identifiers are canonical for wave 1 docs. WP4 must still -confirm that the ported NAgDI claim configs use these exact identifiers. - -## Purpose Rules - -`child-benefit-review` permits evidence needed to determine whether a child may -be enrolled in child support: registered birth, age under 5, child life status, -household eligibility band, and duplicate enrollment status. It does not permit -raw poverty scores, complete household profiles, or unrelated civil events. - -`pension-payment-review` permits the SIPF to determine whether an active pension -payment should continue, be held, or stop. It permits the fact of death and the -death registration number where needed. It does not permit cause of death or -medical details. - -`survivor-benefit-determination` permits the SIPF to determine whether a linked -spouse or dependent qualifies for survivor benefits. It permits spouse linkage, -marriage status, death fact, and pension membership predicates. It does not -permit cause of death, complete contribution history, or unrelated benefits. - -`voucher-eligibility-review` permits NAgDI to evaluate farmer registration, -data-use authorization, eligible crop or holding records, district risk band, -and prior voucher status. It does not permit unrelated livestock movements or -raw market-sizing tables. - -`livestock-movement-control` permits NAgDI to evaluate owner, animal, premises, -vaccination, quarantine, and movement predicates for a livestock permit. It does -not permit farmer voucher budget, unrelated crop records, or household poverty -data. - -`citizen-self-service` permits the citizen portal to request preview evidence -for the selected persona and to show proof traces for consented service -journeys. It does not permit bulk reads, administrative-only fields, or evidence -for a different selected persona. - -## Credential And Offering Names - -| Story | Evidence offering | Credential `vct` | +| Purpose code | Authority requirements | Evidence source | |---|---|---| -| Birth to child benefit | Four authority predicate responses composed by the child-benefit orchestration service | No credential issued | -| Death to pension stop | `cra-pension-death` and `sipf-pension-payment-review` | No credential issued | -| Survivor benefit | `sipf-survivor-benefit` | `https://id.registrystack.org/solmara/vct/survivor-benefit-status` | -| Farmer climate-smart voucher | NAgDI `voucher` | `https://id.registrystack.org/solmara/vct/climate-smart-voucher-eligibility` | -| Livestock movement permit | NAgDI `livestock` | `https://id.registrystack.org/solmara/vct/livestock-movement-permit` | - -## Denial Assertions - -Purpose-denial smoke tests assert: - -1. The denied request used a purpose IRI outside the permitted catalogue or a - permitted purpose against a prohibited field. -2. The response problem code is `pdp.purpose_not_permitted`. -3. The response does not include the prohibited source field. - -The child-benefit orchestration smoke verifies that four independently governed -authority responses can be composed without creating a seventh Notary or a -shared correctness-state owner. +| `child-benefit-review` | CRA, NIA, SRO, MoSD | three immutable extracts, one Relay lookup | +| `pension-payment-review` | CRA death, SIPF payment | two Relay lookups | +| `survivor-benefit-determination` | SIPF survivor | Relay lookup | +| `voucher-eligibility-review` | NAgDI voucher | Relay lookup | +| `livestock-movement-control` | NAgDI livestock | Relay lookup | +| `citizen-self-service` | CRA link, NIA status | one Relay lookup, one immutable extract | + +Child benefit permits only registered-birth, under-five, active-population, +poverty-priority, and not-already-enrolled concepts. Pension permits death and +active-payment concepts but not cause of death, payment amount, or history. +Survivor review permits the reviewed eligibility value, not a marriage record. +Agriculture purposes are isolated from one another. Citizen self-service permits +only CRA linkage and NIA active-population concepts. + +Wrong-purpose or unauthorized requests fail generically. Relay no-match, +ambiguous match, and concealed records collapse to unresolved consultation. +Responses and logs never include selectors, tokens, source rows, secrets, +private keys, audit material, or sensitive dependency details. + +The programme application verifies every authority JWS and owns the final +cross-authority outcome. No Evidence cell returns an application decision. diff --git a/docs/solmara.md b/docs/solmara.md index a9a70a0..2ce0691 100644 --- a/docs/solmara.md +++ b/docs/solmara.md @@ -1,173 +1,75 @@ -# Republic of Solmara +# Solmara lab architecture -Status: normative for Solmara Lab wave 1. +Solmara is a fictional institutional lab. Its core boundary is an authority +cell: an authority publishes a governed source, operates its Evidence service, +signs its assertions, and owns its audit trail. A programme consumes assertions +from several cells and owns the programme decision. -Solmara is a fictional country created for Registry Stack demonstrations. Any -person, place, ministry, registry, identifier, service, and government story in -this lab is synthetic unless explicitly labelled as an external partner system. -No Solmara address, person, ministry, or registry represents a real authority. +## Runtime topology -## Nation - -The Republic of Solmara is a small cosmopolitan island republic in the tropical -South Indian Ocean, roughly 9.5 to 11 degrees south and 79 to 80.5 degrees east. -It has one main island plus smaller islands, about 24,000 square km, and a -notional population of 2.4 million. - -The nearest real land is about 900 km away in every direction, including Diego -Garcia, Addu Atoll, Sri Lanka, and the Cocos Islands. Country-scale basemaps -therefore show open water around Solmara, and no real address should ever fall -inside the lab bounding box. - -Solmara uses the ISO 3166 user-assigned codes `XS` and `XSO`. Lab currency uses -the ISO 4217 testing code `XTS`, displayed as the Solmara sol. - -## Administrative Areas - -Solmara has two administrative levels: 4 provinces and 12 districts. Province -P-codes use `XS-01` through `XS-04`; district P-codes use `XS-0101` style. - -| Province code | Province | Position | District code | District | -|---|---|---|---|---| -| `XS-01` | Anvela | North | `XS-0101` | Ketterin | -| `XS-01` | Anvela | North | `XS-0102` | Ovasse | -| `XS-01` | Anvela | North | `XS-0103` | Brenholm | -| `XS-02` | Tolara | South | `XS-0201` | Salvet | -| `XS-02` | Tolara | South | `XS-0202` | Marindi | -| `XS-02` | Tolara | South | `XS-0203` | Velcor | -| `XS-03` | Mendira | Central, capital province | `XS-0301` | Lydessa | -| `XS-03` | Mendira | Central, capital province | `XS-0302` | Orivale | -| `XS-03` | Mendira | Central, capital province | `XS-0303` | Carrowen | -| `XS-04` | Corvala | East | `XS-0401` | Eastmere | -| `XS-04` | Corvala | East | `XS-0402` | Navaro | -| `XS-04` | Corvala | East | `XS-0403` | Vestrel | - -These names replace legacy compass districts and any real-country agriculture -geography from the previous NAgDI lab material. - -## Coordinate Reference System - -The hand-authored Solmara geography uses one hemisphere and one UTM zone. Source -GeoJSON is authored in WGS 84 longitude and latitude (`EPSG:4326`). Metric -derivations for area, centroids, and tiling checks use WGS 84 / UTM zone 44S -(`EPSG:32744`). - -The country, provinces, and districts are maintained as one authoritative geo -source. Districts exactly tile provinces, provinces exactly tile the country, -and point fixtures must fall inside their declared district. Relay serves -district geometries only when a story needs boundaries; default story surfaces -prefer names, codes, and minimized predicates. - -## Identifier Formats - -| Scheme | Format | Example | +| Authority | Evidence source | Relay operation | |---|---|---| -| SolmaraID UIN | 10 digits, MOSIP-style Verhoeff checksum, no leading 0 or 1, no long runs or repeats, excluding sequences 786 and 666 | `2300010248` | -| Birth registration | `BRN---` | `BRN-2016-0101-00213` | -| Death registration | `DRN---` | `DRN-2026-0301-00042` | -| Marriage registration | `MRN---` | `MRN-1988-0301-00117` | -| Taxpayer | 9 digits plus check letter | `104238756K` | -| Business | org-id style jurisdiction-list code | `XS-SBRS-0042317` | -| Cadastre parcel | `XS-CAD--` | `XS-CAD-0101-0087` | -| Health facility | `XS-MOH-` | `XS-MOH-0231` | -| Farmer | `FR-` | `FR-1001` | -| Household | `HH-` | `HH-002317` | - -Registry-local row-id prefixes are normative when used by the owning registry -model: `CP-`, `BE-`, `DE-`, `ME-`, `MT-`, `CSR-`, `SEP-`, `SCOR-`, `ENR-`, -`ENT-`, `PAY-`, `SIPF-`, `SIPF-AWD-`, `XS-LP-`, `XS-BAU-`, `XS-DLG-`, -`XS-SRA-TCC-`, `XS-DR-`, `XS-SCH-`, `XS-LRN-`, and `XS-MOH-ORG-`. - -Legacy national-id aliases remain only for migration and story porting. They -use the old population prefix and are not new primary identifiers. - -## Domain Scheme - -Solmara uses three domain layers: - -| Layer | Pattern | Use | -|---|---|---| -| Story domains | `*.gov.solmara.example` | Fictional ministry and service names in UI copy, docs, fixtures, and tutorials. These do not resolve. | -| Machine identifiers | `https://id.registrystack.org/solmara/...` | Purpose IRIs, credential `vct` values, schema `$id` values, and issuer identifiers. | -| Service endpoints | `.solmara.registrystack.org` | Real TLS endpoints for hosted lab services. | - -Notary node identifiers use `did:web` at their authority service host, for -example `did:web:cra-notary.solmara.registrystack.org`. Credential holder -binding uses `did:jwk`. - -## Ministries And Registries - -Solmara names 17 registries, but only builds registries when a shipped story -needs them. Wave 1 runs seven registries. - -| # | Registry | Owner | Tier | Wave | -|---|---|---|---|---| -| 1 | Civil registration: births, deaths, marriages | Ministry of Interior, Civil Registration Authority | Rows, Relay, Notary source | 1 | -| 2 | Population register / national ID | Ministry of Interior, National Identity Agency | Rows, Relay, Notary source | 1 | -| 3 | Social registry: households | Ministry of Social Development, Social Registry Office | Rows, Relay, Notary source | 1 | -| 4 | Integrated beneficiary registry | Ministry of Social Development, programme MISes | Rows, Relay, Notary source | 1 | -| 5 | Pensions / social insurance | Ministry of Labour, Social Insurance and Pensions Fund | Rows, Relay, Notary source | 1 | -| 6 | Farmer registry | Ministry of Agriculture, National Agricultural Data Institute | Rows, Relay, Notary source | 1 | -| 7 | Livestock registry | Ministry of Agriculture, National Agricultural Data Institute | Rows, Relay, Notary source | 1 | -| 8 | Land registry and cadastre | Ministry of Lands and Survey | Rows, Relay, Notary source | 2 | -| 9 | Taxpayer registry | Ministry of Finance, Solmara Revenue Authority | Rows, Relay, Notary source | 2 | -| 10 | Business / company registry | Ministry of Justice and Commerce, Solmara Business Registration Service | Rows, Relay, Notary source | 2 | -| 11 | Beneficial ownership register | Solmara Business Registration Service | Thin fixtures, evidence only | 2 | -| 12 | Disability registry | Ministry of Social Development, Disability Assessment Board | Thin fixtures, evidence only | 3 | -| 13 | Education learner registry | Ministry of Education | Thin fixtures, evidence only | 3 | -| 14 | Health facility registry | Ministry of Health | Thin fixtures, evidence only | 3 | -| 15 | Patient / immunization registry | Ministry of Health | World bible only | None | -| 16 | Vehicle and driving licence registry | Ministry of Transport | World bible only | None | -| 17 | Customs trader registry | Ministry of Finance, Customs Service | World bible only | None | - -Wave 1 runs one authority Notary beside each Relay: `cra-notary`, `nia-notary`, -`sro-notary`, `programme-notary`, `sipf-notary`, and `nagdi-notary`. An -authority Notary exposes every reviewed evidence workflow owned by that -authority. It is not duplicated per purpose. The child-benefit orchestration -service composes the four required authority responses but does not own Notary -correctness state or make the final eligibility decision. - -## Persona Roster - -This roster is the normative named cast for wave 1 docs and smoke expectations. -Identifiers may be regenerated later, but the persona role and expected outcome -must stay stable. - -| Persona | Primary id | District | Wave 1 expected outcome | -|---|---|---|---| -| Mateo Santos | `2300010248` | Ketterin | Child benefit positive child: registered birth, active population record, under 5, eligible household, not enrolled. | -| Elena Dela Cruz | `2300018263` | Ketterin | Mateo guardian: may request the source-owned predicate bundle for programme review. | -| Luis Okafor | `2300027390` | Ketterin | Household head for child benefit positive path. | -| Hana Aquino | `2300036523` | Lydessa | Child benefit denied: household above poverty threshold. | -| Priya Mensah | `2300045650` | Lydessa | Guardian for above-threshold child household. | -| Tomas Bello | `2300054788` | Orivale | Child benefit denied: duplicate enrollment already active. | -| Joana Bello | `2300063915` | Orivale | Guardian for duplicate-enrollment control. | -| Karim Kone | `2300073046` | Marindi | Child benefit routed to birth registration first: UIN exists, BRN is null. | -| Aisha Kone | `2300082172` | Marindi | Guardian for unregistered-birth inclusion path. | -| Esteban Cruz | `2300091305` | Velcor | Deceased child-benefit control: eligibility must fail on life status. | -| Miriam Cruz | `2300100431` | Velcor | Guardian for deceased control path. | -| Rafael Nkomo | `2300109568` | Lydessa | Pension positive deceased member: payments should stop. | -| Imani Nkomo | `2300118698` | Lydessa | Survivor benefit positive spouse linked by MRN. | -| Otto Ferreira | `2300127827` | Orivale | Pension stale-data failure: death not yet registered. | -| Lucia Ferreira | `2300136959` | Orivale | Survivor claim waits for death reconciliation. | -| Mina Rahman | `2300146081` | Carrowen | Survivor denied: marriage dissolved. | -| Pavel Rahman | `2300155218` | Carrowen | Deceased former spouse for dissolved-marriage control. | -| Amina Kone | `FR-1001` | Brenholm | Farmer voucher positive path and livestock movement positive owner. | -| Diego Santos | `FR-1002` | Ovasse | Farmer voucher denied: no data-use authorization on file. | -| Noor Patel | `FR-1003` | Navaro | Farmer voucher denied: already redeemed this season. | -| Beatriz Okafor | `FR-1004` | Eastmere | Livestock movement denied: species-specific quarantine. | -| Sefu Dela Cruz | `FR-1005` | Vestrel | Livestock movement denied: incomplete vaccination evidence. | - -## Registry Landscape - -The wave 1 registry landscape demonstrates cross-registry life-event services: - -| Story | Registries | Outcome | -|---|---|---| -| Birth to child benefit | Civil registration, population, social registry, beneficiary registry | Four authority predicate responses composed for programme eligibility review. | -| Death to pension stop plus survivor benefit | Civil registration, population, pensions, beneficiary registry | Stop predicate for the deceased member and survivor eligibility VC for the spouse. | -| Farmer climate-smart voucher and livestock movement | Farmer registry, livestock registry | Voucher eligibility credential and livestock movement permit evidence. | - -Every wave 1 story must show metadata discovery, governed evaluation, a -credential or composed evidence moment, a forbidden raw read or wrong-purpose -attempt, and a denial with a stable problem code. +| CRA | immutable birth extract | civil death and citizen-link exact lookups | +| NIA | immutable population extract | eSignet UserInfo exact lookup | +| SRO | immutable poverty extract | none | +| MoSD Programme MIS | live read-only SQLite | beneficiary enrolment exact lookup | +| SIPF | live read-only SQLite | pension payment and survivor exact lookups | +| NAgDI | live read-only SQLite | voucher and livestock exact lookups | + +There are six Evidence cells and five Relays. A Relay exists only for governed +live consultation. It is not a façade over an Evidence cell's immutable local +extract. + +Every Relay operation has one dedicated Mint client registration, exact scope, +fixed purpose claim, access profile, and disclosure profile. Relays do not +offer list, search, cursor, or generic record routes. The shared +`solmara-runtime` audience is deliberately limited to this lab. + +## Evidence authority + +Each cell has a unique provider IRI, authority DID issuer, ES256 signing key and +JWKS, audit destination, subject-binding secret, runtime binding, and hostname. +The application chooses the cell from a closed requirement directory, fetches +only that cell's JWKS, verifies the exact requirement, evidence type, concepts, +purpose, nonce, issuer, provider, audience, and validity window, then retains a +safe presentation projection. + +Direct-source assertions are labelled `immutable extract` and remain valid for +at most 3,600 seconds. Relay-backed assertions are labelled `Relay lookup` and +remain valid for at most 300 seconds. Source rows, selectors, tokens, JWS +payloads, private audit output, and sensitive dependency errors are never UI +presentation data. + +## Source publication + +Authority publishers produce five mutable Relay databases and three immutable +Evidence extracts. Each extract contains exactly one `evidence_extract` row +with `published_at`, `publisher`, and `extract_id`. A publication is written +under a new name and made read-only. Active extracts are never overwritten. + +Relay views expose authority-owned stable record identifiers, revisions, +lifecycle state, recorded timestamps, selector columns, and governed domain +columns. Relay releases only the properties declared by the selected disclosure +profile. + +## Failure semantics + +Wrong scope, wrong purpose, malformed selectors, invalid source rows, +unavailable source, and unavailable audit fail closed. No-match, ambiguous +match, and policy-hidden matches use the same data-free unresolved class. An +Evidence cell does not infer a negative assertion from an unresolved Relay +consultation. + +## Programme stories + +- Child benefit composes CRA, NIA, SRO, and MoSD assertions into five reviewed + concepts. +- Pension composes CRA death and SIPF payment assertions. SIPF survivor evidence + is separately requested for the spouse. +- Agriculture requests NAgDI voucher or livestock assertions under isolated + purposes. +- Optional citizen login uses the NIA Relay V2 lookup through eSignet, while the + application can separately compose CRA and NIA Evidence assertions. + +The preserved `/v1` suffixes in requirement and evidence-type identifiers are +domain identifier versions, not transport routes. diff --git a/docs/source-recovery.md b/docs/source-recovery.md new file mode 100644 index 0000000..33eccf2 --- /dev/null +++ b/docs/source-recovery.md @@ -0,0 +1,20 @@ +# Source and volume recovery + +Relay V2 reads authority-owned SQLite publications and owns no source truth. +Evidence reads either a named immutable extract or a Relay response. Recovery +therefore preserves publication identity and runtime bindings, not a shared +application database. + +For a mutable Relay source, stop its authority publisher before snapshotting the +database. Restore into a new volume, verify the governed schema fingerprint and +read-only binding, then restart only that Relay. For an immutable extract, +restore the exact file under its original name or publish a reviewed replacement +under a new name. Never modify an active extract in place. + +Audit sinks, Mint state, signer state, and source publications are separate +recovery units. Record exact artifact digests, runtime configuration revisions, +public JWKs, and volume identities with each backup. Do not copy private +recovery evidence into this public repository. + +During the reset, superseded service volumes remain detached and recoverable. +Deleting them is outside the reset delivery and requires separate approval. diff --git a/docs/stories/birth-to-child-benefit.md b/docs/stories/birth-to-child-benefit.md index b73f651..91e62dd 100644 --- a/docs/stories/birth-to-child-benefit.md +++ b/docs/stories/birth-to-child-benefit.md @@ -1,92 +1,28 @@ -# Birth To Child Benefit - -Status: normative for Solmara Lab wave 1 story 1. - -## Purpose - -This story demonstrates a canonical CRVS-to-social-protection journey: a -registered birth, a population identity, a household eligibility predicate, and -a programme duplicate check are gathered as source-owned predicates without -exposing raw registry rows. The programme policy layer, not the evidence -collector, decides whether those predicates amount to child benefit -eligibility. - -## Authorities And Registries - -| Authority | Registry | Evidence role | -|---|---|---| -| Civil Registration Authority | Civil registration | Birth registration, child age, child life status | -| National Identity Agency | Population register | UIN, identity status, BRN linkage | -| Social Registry Office | Social registry | Household membership and poverty band predicate | -| MoSD programme MIS | Integrated beneficiary registry | Duplicate enrollment predicate | - -Purpose IRI: -`https://id.registrystack.org/solmara/purpose/child-benefit-review`. - -Evidence offering: `solmara.child-benefit.authority-predicate-collection`. - -Evidence collector endpoint: -`https://child-benefit-federator.solmara.registrystack.org/v1/evaluations`. - -Response media type: `application/json`. - -Credential `vct`: not issued by the child-benefit evidence collector. - -Offering name: Child Benefit Authority Predicate Collection. - -## Positive Path - -Persona: Mateo Santos, `2300010248`. - -Expected claims: - -| Claim | Expected result | -|---|---| -| `birth-is-registered` | Pass: Mateo has a registered BRN. | -| `population-record-active` | Pass: Mateo's population record is active. | -| `child-age-under-5` | Pass: Mateo is under 5 at the lab clock. | -| `household-below-poverty-threshold` | Pass: household score band is eligible; raw score is not disclosed. | -| `not-already-enrolled` | Pass: no active child support enrollment exists. | - -The child-benefit evidence collector calls the CRA, NIA, SRO, and Programme -Notaries through their ordinary evidence APIs and returns the minimized -predicate results to the programme review. Its `source_trace` identifies the -authority service and response status without copying source rows or internal -evaluation state. The collector does not return a composed eligibility -decision. - -## Failure Cases - -| Persona | Case | Expected result | -|---|---|---| -| Esteban Cruz | Deceased control persona | Fails life-status check before enrollment eligibility. | -| Hana Aquino | Household above threshold | Fails `household-below-poverty-threshold`; raw poverty score remains undisclosed. | -| Karim Kone | Unregistered birth | Fails `birth-is-registered` and routes to "register the birth first" rather than a dead end. | -| Tomas Bello | Duplicate enrollment | Fails `not-already-enrolled` because an active enrollment already exists. | - -## Purpose Denial - -The smoke must attempt a request for raw household poverty score or complete -household profile under `child-benefit-review`. The response must deny access -with problem code `pdp.purpose_not_permitted` and must not include the raw -field. - -The smoke must also attempt a request with an unrelated purpose, such as -`pension-payment-review`, against the child benefit offering. The response must -deny access with `pdp.purpose_not_permitted`. - -## Smoke Expectations - -The story smoke asserts: - -1. Metadata discovery returns the child benefit offering and the purpose IRI - from `docs/purposes.md`. -2. Mateo's positive evaluation passes all five source predicates. -3. The response contains an authority `source_trace` and no - `eligible-for-child-benefit` composition from the collector. -4. Each listed failure case returns a failed predicate with no raw protected - source row in the response. -5. A raw household score/profile request returns `403` - `pdp.purpose_not_permitted` without reflecting a protected field. -6. An unrelated-purpose request returns `pdp.purpose_not_permitted`. -7. Message text is not asserted. +# Birth to child benefit + +Purpose: `child-benefit-review`. + +The programme requests four independently signed authority assertions and owns +the final outcome: + +| Authority | Requirement | Source | Concepts | +|---|---|---|---| +| CRA | `cra-child-benefit/v1` | immutable birth extract | `birth-is-registered`, `child-age-under-5` | +| NIA | `nia-child-benefit/v1` | immutable population extract | `population-record-active` | +| SRO | `sro-child-benefit/v1` | immutable poverty extract | `household-below-poverty-threshold` | +| MoSD | `mosd-child-benefit/v1` | Relay lookup | `not-already-enrolled` | + +Mateo Santos, UIN `2300010248`, is the positive synthetic persona. All five +concepts are true. The application may therefore show the positive child +benefit outcome, while each source row remains with its authority. + +Controls cover deceased or aged-out children, an above-threshold household, an +unregistered birth, duplicate enrolment, and wrong purpose. An unregistered +birth is a valid CRA record with no BRN and produces signed false, not an +unresolved consultation. The mutable MoSD source changes on the next request; +the three immutable sources change only after a reviewed replacement is bound +and the owning cell is restarted. + +The response surface contains safe authority, issuer, provider, source type, +and verified concept values. It does not contain selectors, tokens, source +rows, raw poverty measures, private audit output, or JWS bodies. diff --git a/docs/stories/death-to-pension-survivor.md b/docs/stories/death-to-pension-survivor.md index 197eefb..73228ff 100644 --- a/docs/stories/death-to-pension-survivor.md +++ b/docs/stories/death-to-pension-survivor.md @@ -1,85 +1,21 @@ -# Death To Pension Stop And Survivor Benefit - -Status: normative for Solmara Lab wave 1 story 2. - -## Purpose - -This story demonstrates a high-value DPI control: a registered death triggers a -review of an active pension payment and determines whether a spouse is eligible -for survivor benefit evidence. The pension-review application receives -minimized predicates from the CRA and SIPF Notaries, not medical details or full -registry rows. - -## Authorities And Registries - -| Authority | Registry | Evidence role | -|---|---|---| -| Civil Registration Authority | Civil registration | Registered death fact | -| Social Insurance and Pensions Fund | Pensions / social insurance | Active pension payment and survivor eligibility | - -Purpose IRIs: - -- `https://id.registrystack.org/solmara/purpose/pension-payment-review` -- `https://id.registrystack.org/solmara/purpose/survivor-benefit-determination` - -Evidence offerings: - -- `cra-death-registration-offering` -- `sipf-pensions-pension-case-offering` - -Credential `vct` values: - -- `https://id.registrystack.org/solmara/vct/survivor-benefit-status` - -Credential name: Survivor Benefit Eligibility SD-JWT VC. - -## Positive Path - -Personas: - -- Rafael Nkomo, `2300109568`, deceased pension member. -- Imani Nkomo, `2300118698`, surviving spouse. - -Expected claims: - -| Claim | Expected result | -|---|---| -| `person-is-deceased` | Pass: Rafael has a registered death event and DRN. | -| `pension-payment-active` | Pass: Rafael has an active in-payment award that requires review. | -| `survivor-is-eligible` | Pass: SIPF has a verified eligible survivor link. | - -The pension-review application combines CRA death evidence with SIPF payment -evidence. It does not ask either Notary to make the cross-authority stop-payment -decision. SIPF separately returns survivor eligibility evidence and can issue -the survivor benefit credential for Imani. Neither path discloses cause of -death. - -## Failure Cases - -| Persona | Case | Expected result | -|---|---|---| -| Otto Ferreira | Death not yet registered | `person-is-deceased` fails or returns stale-data reconciliation status; payment is not automatically stopped from unregistered evidence. | -| Lucia Ferreira | Survivor waits for reconciliation | Survivor eligibility cannot pass until Otto's death registration is available. | -| Mina Rahman | Survivor relationship no longer eligible | `survivor-is-eligible` fails in SIPF evidence. | -| Pavel Rahman | Former spouse death control | Confirms the SIPF relationship status is used rather than name matching. | - -## Purpose Denial - -The smoke must attempt to request `cause_of_death` or medical death details -under `pension-payment-review`. The response must deny access with problem code -`pdp.purpose_not_permitted`. The SIPF needs the death fact, not the medicine. - -## Smoke Expectations - -The story smoke asserts: - -1. Metadata discovery returns both authority offerings and purpose IRIs from - `docs/purposes.md`. -2. The application combines Rafael's CRA death predicate and SIPF active-payment - predicate without treating either source response as a composed decision. -3. SIPF's survivor eligibility evaluation passes and uses the expected - survivor credential `vct`. -4. Otto and Lucia produce the stale-data or reconciliation path. -5. Mina's survivor claim fails because the marriage is dissolved. -6. Cause-of-death access is denied with `pdp.purpose_not_permitted`. -7. Denial smokes assert stable problem codes, not message text. +# Death to pension stop and survivor benefit + +The pension application combines a CRA death assertion and a SIPF active +payment assertion under `pension-payment-review`. The application, not either +Evidence cell, derives whether payment should stop. + +CRA calls only `civil-person/death-by-uin` and discloses the governed death +status. SIPF calls only `pension-payment/by-pensioner-uin` and discloses active +payment status. Cause of death, unrelated civil events, payment amount, and +payment history are outside the contracts. + +For survivor benefit, the application sends the surviving spouse UIN +`2300118698` to SIPF requirement `sipf-survivor-benefit/v1` under +`survivor-benefit-determination`. SIPF calls only +`survivor-case/by-spouse-uin` and signs `survivor-is-eligible`. + +Controls cover death not yet registered, dissolved relationship, unknown cause +of death requirement, wrong purpose, unresolved lookup, dependency failure, +and audit failure. All dependency and authorization failures are generic and +value-free. CRA and SIPF assertions are independently verified against their +own JWKS before composition. diff --git a/docs/stories/farmer-climate-smart-voucher.md b/docs/stories/farmer-climate-smart-voucher.md index 6e95df9..30b8197 100644 --- a/docs/stories/farmer-climate-smart-voucher.md +++ b/docs/stories/farmer-climate-smart-voucher.md @@ -1,98 +1,24 @@ -# Farmer Climate-Smart Voucher - -Status: normative for Solmara Lab wave 1 story 3. - -## Purpose - -This story ports the NAgDI agriculture demo into Solmara. It demonstrates how a -spreadsheet-backed farmer and livestock registry becomes a governed API with -purpose-limited evidence, stable offerings, and smokeable denial behavior. - -The livestock movement permit companion ships in the same story package. - -## Authorities And Registries - -| Authority | Registry | Evidence role | -|---|---|---| -| National Agricultural Data Institute | Farmer registry | Farmer registration, holding, programme, voucher, data-use authorization | -| National Agricultural Data Institute | Livestock registry | Premises, herd, animal, vaccination, quarantine, movement permit | - -Purpose IRIs: - -- `https://id.registrystack.org/solmara/purpose/voucher-eligibility-review` -- `https://id.registrystack.org/solmara/purpose/livestock-movement-control` - -Evidence offerings: - -- `solmara.nagdi.climate-smart-voucher-eligibility` -- `solmara.nagdi.livestock-movement-permit` - -Credential `vct` values: - -- `https://id.registrystack.org/solmara/vct/climate-smart-voucher-eligibility` -- `https://id.registrystack.org/solmara/vct/livestock-movement-permit` - -Credential names: - -- Climate-Smart Voucher Eligibility SD-JWT VC. -- Livestock Movement Permit SD-JWT VC. - -## Positive Path - -Persona: Amina Kone, `FR-1001`, legacy alias `FARMER-1001`, Brenholm district. - -Expected voucher claims: - -| Claim | Expected result | -|---|---| -| `farmer-is-registered` | Pass: Amina has an active `FR-*` farmer record. | -| `data-use-authorized` | Pass: NAgDI has active authorization for voucher review. | -| `holding-in-eligible-district` | Pass: holding district is eligible for the climate-smart package. | -| `not-already-redeemed` | Pass: no redeemed voucher exists for the same programme cycle. | - -Expected livestock companion claims: - -| Claim | Expected result | -|---|---| -| `movement-applicant-controls-herd` | Pass: Amina controls the herd or premises. | -| `vaccination-current` | Pass: required vaccinations are current for the species. | -| `origin-district-not-quarantined-for-species` | Pass: no species-specific quarantine applies. | -| `destination-permitted` | Pass: destination district allows the movement. | - -The NAgDI notary previews or issues the voucher eligibility credential and the -livestock movement permit credential. Responses disclose predicates and -references, not full workbook rows. - -## Failure Cases - -| Persona | Case | Expected result | -|---|---|---| -| Diego Santos | Missing data-use authorization | Voucher eligibility fails `data-use-authorized`. | -| Noor Patel | Ineligible climate-risk band | Voucher eligibility fails district or package eligibility without dumping market-sizing cells. | -| Beatriz Okafor | Species-specific quarantine | Livestock movement fails `origin-district-not-quarantined-for-species`. | -| Sefu Dela Cruz | Incomplete vaccination evidence | Livestock movement fails `vaccination-current`. | - -## Purpose Denial - -The smoke must attempt to use `livestock-movement-control` to read voucher -budget, market-sizing, or crop programme details. The response must deny access -with `pdp.purpose_not_permitted`. - -The smoke must attempt to use `voucher-eligibility-review` to read unrelated -livestock movement details. The response must deny access with -`pdp.purpose_not_permitted`. - -## Smoke Expectations - -The story smoke asserts: - -1. Metadata discovery returns both NAgDI offerings and purpose IRIs from - `docs/purposes.md`. -2. Amina passes voucher eligibility and receives the expected voucher `vct`. -3. Amina passes the livestock movement companion and receives the expected - livestock movement `vct`. -4. Diego, Noor, Beatriz, and Sefu each fail the listed predicate. -5. Cross-purpose NAgDI access is denied with `pdp.purpose_not_permitted`. -6. The ported claim configs use `FR-*` farmer identifiers and Solmara P-coded - districts, not legacy real-country geography or agriculture national-id - aliases. +# Farmer voucher and livestock movement + +NAgDI owns one live read-only SQLite publication and two isolated Relay V2 +lookups. + +`farmer/voucher-by-farmer-id` supports requirement `nagdi-voucher/v1` under +`voucher-eligibility-review`. It yields the three signed concepts +`farmer-registered`, `data-use-authorized-for-purpose`, and +`eligible-for-climate-smart-input-voucher` after reviewing the minimum governed +voucher facts. + +`livestock-herd/movement-by-farmer-id` supports requirement +`nagdi-livestock/v1` under `livestock-movement-control`. It yields +`registered-herd`, `origin-district-not-quarantined-for-species`, and +`eligible-for-livestock-movement-permit`. + +Each operation has a distinct Mint client, scope, purpose claim, access profile, +and disclosure profile. Voucher authority cannot call the livestock operation +and livestock authority cannot call the voucher operation. Neither route +offers enumeration. + +Positive, unauthorized-data-use, redeemed-voucher, quarantine, wrong-purpose, +unresolved, malformed-row, unavailable-source, and unavailable-audit cases are +tested. UI and logs never render the farmer selector or source row. diff --git a/docs/wallet-oid4vci.md b/docs/wallet-oid4vci.md deleted file mode 100644 index 608f306..0000000 --- a/docs/wallet-oid4vci.md +++ /dev/null @@ -1,21 +0,0 @@ -# Retired wallet issuer topology - -Status: retired before Registry Stack 1.0. - -The former citizen-services deployment added a portal Notary and a separate -OpenID for Verifiable Credential Issuance (OID4VCI) Notary. That -purpose-specific model is not part of the clean Solmara topology. Do not deploy -`citizen-notary`, `citizen-issuer-notary`, or the removed -`compose.coolify.citizen-services.yaml` application. - -Solmara now runs exactly six authority-owned public Relay and Notary pairs: -CRA, NIA, SRO, Programme, SIPF, and NAgDI. Each pair has a private consultation -Relay beside its Notary; that internal process is not another authority or -public endpoint. Citizen portal journeys use evidence exposed by those -authority Notaries. Adding a credential issuance journey must extend the -owning authority project rather than create a purpose-specific Notary. - -The Walt holder wallet application remains available as an isolated UI -demonstrator through `compose.coolify.walt.yaml`, but the current six-authority -topology does not expose an OID4VCI issuer. eSignet remains the portal identity -provider, and its Redis service remains eSignet-owned state. diff --git a/evidence/cells/cra/bundle/adapters/birth-extract.rhai b/evidence/cells/cra/bundle/adapters/birth-extract.rhai new file mode 100644 index 0000000..3a59cd9 --- /dev/null +++ b/evidence/cells/cra/bundle/adapters/birth-extract.rhai @@ -0,0 +1,7 @@ +fn extract(source_response, context) { + let rows = source_response["rows"]; + if rows.len == 0 { return #{outcome: "no_match"}; } + if rows.len > 1 { return #{outcome: "ambiguous"}; } + let row = rows[0]; + #{outcome: "match", facts: #{uin: row["uin"], birth_date: row["birth_date"], birth_registered: !is_missing(row["birth_brn"])}} +} diff --git a/evidence/cells/cra/bundle/adapters/relay-extract.rhai b/evidence/cells/cra/bundle/adapters/relay-extract.rhai new file mode 100644 index 0000000..8009963 --- /dev/null +++ b/evidence/cells/cra/bundle/adapters/relay-extract.rhai @@ -0,0 +1,5 @@ +fn extract(source_response, context) { + let domain = source_response["data"]["domainData"]; + if domain.contains("deceased") { return #{outcome: "match", facts: #{deceased: domain["deceased"]}}; } + #{outcome: "match", facts: #{linkedUin: domain["linkedUin"]}} +} diff --git a/evidence/cells/cra/bundle/adapters/relay-prepare.rhai b/evidence/cells/cra/bundle/adapters/relay-prepare.rhai new file mode 100644 index 0000000..b4d5dc7 --- /dev/null +++ b/evidence/cells/cra/bundle/adapters/relay-prepare.rhai @@ -0,0 +1,5 @@ +fn prepare(selectors, context) { + let parameters = context["parameters"]; + let subject = selectors["subject"]["values"]; + #{query: [#{name: "fields", value: parameters["fields"]}], body: #{selectors: #{uin: subject["uin"]}}} +} diff --git a/evidence/cells/cra/bundle/derivations/child-benefit.rhai b/evidence/cells/cra/bundle/derivations/child-benefit.rhai new file mode 100644 index 0000000..5383a03 --- /dev/null +++ b/evidence/cells/cra/bundle/derivations/child-benefit.rhai @@ -0,0 +1,9 @@ +fn derive(facts, selectors, evaluation_context) { + let birth_date = parse_date(required(facts["birth_date"], "required_fact_missing")); + let fifth_birthday = add_calendar_years(birth_date, 5); + let as_of_date = parse_date(evaluation_context["parameters"]["as_of_date"]); + [ + #{concept_id: "https://id.registrystack.org/solmara/concept/birth-is-registered", value: required(facts["birth_registered"], "required_fact_missing")}, + #{concept_id: "https://id.registrystack.org/solmara/concept/child-age-under-5", value: compare_dates(as_of_date, birth_date) >= 0 && compare_dates(as_of_date, fifth_birthday) < 0} + ] +} diff --git a/evidence/cells/cra/bundle/derivations/deceased.rhai b/evidence/cells/cra/bundle/derivations/deceased.rhai new file mode 100644 index 0000000..df6d5fc --- /dev/null +++ b/evidence/cells/cra/bundle/derivations/deceased.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "https://id.registrystack.org/solmara/concept/person-is-deceased", value: required(facts["deceased"], "required_fact_missing")}] +} diff --git a/evidence/cells/cra/bundle/derivations/linked.rhai b/evidence/cells/cra/bundle/derivations/linked.rhai new file mode 100644 index 0000000..a7323a1 --- /dev/null +++ b/evidence/cells/cra/bundle/derivations/linked.rhai @@ -0,0 +1,4 @@ +fn derive(facts, selectors, evaluation_context) { + required(facts["linkedUin"], "required_fact_missing"); + [#{concept_id: "https://id.registrystack.org/solmara/concept/civil-record-linked", value: true}] +} diff --git a/evidence/cells/cra/bundle/evidence.yaml b/evidence/cells/cra/bundle/evidence.yaml new file mode 100644 index 0000000..487e7b9 --- /dev/null +++ b/evidence/cells/cra/bundle/evidence.yaml @@ -0,0 +1,128 @@ +version: 1 +assuranceProfile: production +service: { providerId: https://id.registrystack.org/solmara/evidence/cra, trustDomain: https://id.registrystack.org/solmara/trust-domain/lab } +issuer: { id: did:web:id.registrystack.org:solmara:authority:cra } +authentication: { kind: oidc-access-token, issuer: https://mint.solmara.registrystack.org, audiences: [solmara-runtime], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://mint.solmara.registrystack.org/.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, activePublicJwkFile: public-keys/cra.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 3600, verifierClockSkewSeconds: 30 } +responseFormats: [signed-jws] +selectorProfiles: + solmara-uin-v1: { maximumAggregateBytes: 16, fields: { uin: { type: string, minimumBytes: 10, maximumBytes: 10 } } } +sources: + cra-child-benefit: + transport: sqlite-extract + extractProfile: cra-birth-extract + posture: source-derived + maximumExtractAgeSeconds: 86400 + request: + statement: queries/birth-evidence.sql + columns: [{ name: uin, type: string }, { name: birth_date, type: string }, { name: birth_brn, type: string }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + parameterBindings: { uin: { kind: selector, role: subject, profile: solmara-uin-v1, field: uin } } + projection: [/rows/*/uin, /rows/*/birth_date, /rows/*/birth_brn] + maximumRows: 2 + maximumCellBytes: 256 + maximumStatementSteps: 100000 + timeoutMilliseconds: 2000 + maximumResponseBytes: 8192 + concurrencyLimit: 8 + responseSchema: schemas/birth-response.schema.yaml + extractScript: adapters/birth-extract.rhai + factSchema: schemas/birth-facts.schema.yaml + cra-pension: + transport: http-json + baseUrl: https://cra-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/cra-pension-evidence-client-id, clientAssertionKeyRef: secret:file/cra-pension-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:cra:death-by-uin', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: &relay-unresolved { status: 404, type: https://id.registrystack.org/problems/registry-relay/consultation/unresolved, code: consultation.unresolved } + request: + method: POST + path: /v2/resources/civil-person/lookups/death-by-uin + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: deceased } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 64, maximumNormalizedBytes: 1024 } + projection: [/data/domainData/deceased] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/deceased-response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/deceased-facts.schema.yaml + cra-citizen: + transport: http-json + baseUrl: https://cra-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/cra-citizen-evidence-client-id, clientAssertionKeyRef: secret:file/cra-citizen-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:cra:citizen-link-by-uin', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: *relay-unresolved + request: + method: POST + path: /v2/resources/civil-person/lookups/citizen-link-by-uin + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: linkedUin } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 64, maximumNormalizedBytes: 1024 } + projection: [/data/domainData/linkedUin] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/linked-response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/linked-facts.schema.yaml +authorityProfiles: + cra-consumers-v1: + kind: statutory + requesterTags: [solmara-demo] + grants: + - { requirement: https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1, purpose: child-benefit-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } + - { requirement: https://id.registrystack.org/solmara/requirement/cra-pension-death/v1, purpose: pension-payment-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } + - { requirement: https://id.registrystack.org/solmara/requirement/cra-citizen-record/v1, purpose: citizen-self-service, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } +requirements: + - id: https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1 + kind: information-requirement + acquisition: { kind: single, source: cra-child-benefit } + purposes: [child-benefit-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/child-benefit/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/cra-child-benefit/v1 + observationTimezone: Asia/Bangkok + validitySeconds: 3600 + derivation: { script: derivations/child-benefit.rhai, parameters: { as_of_date: '2026-07-14' } } + concepts: [{ id: https://id.registrystack.org/solmara/concept/birth-is-registered, form: boolean, required: true, constraints: {} }, { id: https://id.registrystack.org/solmara/concept/child-age-under-5, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/child-benefit.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/cra-child-benefit] } + existenceDisclosure: collapse-unresolved + - id: https://id.registrystack.org/solmara/requirement/cra-pension-death/v1 + kind: information-requirement + acquisition: { kind: single, source: cra-pension } + purposes: [pension-payment-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/social-insurance/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/cra-death-status/v1 + validitySeconds: 300 + derivation: { script: derivations/deceased.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/person-is-deceased, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/pension.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/cra-pension] } + existenceDisclosure: collapse-unresolved + - id: https://id.registrystack.org/solmara/requirement/cra-citizen-record/v1 + kind: information-requirement + acquisition: { kind: single, source: cra-citizen } + purposes: [citizen-self-service] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/citizen-self-service/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/civil-record-linked/v1 + validitySeconds: 300 + derivation: { script: derivations/linked.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/civil-record-linked, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/citizen.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/cra-citizen] } + existenceDisclosure: collapse-unresolved diff --git a/evidence/cells/cra/bundle/fixtures/child-benefit.yaml b/evidence/cells/cra/bundle/fixtures/child-benefit.yaml new file mode 100644 index 0000000..1ac7ac3 --- /dev/null +++ b/evidence/cells/cra/bundle/fixtures/child-benefit.yaml @@ -0,0 +1,42 @@ +fixture: registry.evidence.reference.solmara.cra-child-benefit/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000001' } } } + derivationSelectorInputs: {} + extract: | + CREATE TABLE evidence_extract (published_at TEXT NOT NULL, publisher TEXT NOT NULL, extract_id TEXT NOT NULL); + INSERT INTO evidence_extract VALUES ('2026-07-14T00:00:00Z', 'did:web:id.registrystack.org:solmara:authority:cra', 'cra-birth-2026-07-14'); + CREATE TABLE birth_evidence (uin TEXT NOT NULL, birth_date TEXT NOT NULL, birth_brn TEXT); + INSERT INTO birth_evidence VALUES ('1000000001', '2022-08-01', 'BRN-0001'); + INSERT INTO birth_evidence VALUES ('1000000002', '2021-07-14', NULL); + INSERT INTO birth_evidence VALUES ('1000000006', '2022-08-01', 'BRN-A'); + INSERT INTO birth_evidence VALUES ('1000000006', '2021-08-01', 'BRN-B'); + expectedRequestParts: { parameters: {} } + expectedTransport: { statement: queries/birth-evidence.sql } +cases: + - id: positive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000001' } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { uin: '1000000001', birth_date: '2022-08-01', birth_registered: true }, values: { https://id.registrystack.org/solmara/concept/birth-is-registered: true, https://id.registrystack.org/solmara/concept/child-age-under-5: true } } + - id: negative-unregistered + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000002' } } } + expected: { lookup: match, derivationRuns: true, signed: true, values: { https://id.registrystack.org/solmara/concept/birth-is-registered: false, https://id.registrystack.org/solmara/concept/child-age-under-5: false } } + - id: boundary-fifth-birthday + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000002' } } } + expected: { lookup: match, derivationRuns: true, signed: true, values: { https://id.registrystack.org/solmara/concept/birth-is-registered: false, https://id.registrystack.org/solmara/concept/child-age-under-5: false } } + - id: missing-record + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000098' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: no-match + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000099' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: ambiguous-duplicate + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000006' } } } + expected: { lookup: ambiguous, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/birth-is-registered, https://id.registrystack.org/solmara/concept/child-age-under-5], evidenceExcludes: [uin, birth_date, birth_brn, BRN-0001], diagnosticsExclude: [BRN-0001, '1000000001'] } diff --git a/evidence/cells/cra/bundle/fixtures/citizen.yaml b/evidence/cells/cra/bundle/fixtures/citizen.yaml new file mode 100644 index 0000000..8c44a17 --- /dev/null +++ b/evidence/cells/cra/bundle/fixtures/citizen.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.cra-citizen/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000003' } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: linkedUin }], body: { selectors: { uin: '1000000003' } } } + expectedTransport: { path: /v2/resources/civil-person/lookups/citizen-link-by-uin, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { linkedUin: '1000000003' } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { linkedUin: '1000000003' }, value: true } + - id: negative-linked-other-record + response: { data: { domainData: { linkedUin: '9999999999' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: boundary-linked-record + response: { data: { domainData: { linkedUin: '1000000003' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-fact + response: { data: { domainData: {} } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/civil-record-linked], evidenceExcludes: [birth_date, '1000000003'], diagnosticsExclude: ['1000000003'] } diff --git a/evidence/cells/cra/bundle/fixtures/pension.yaml b/evidence/cells/cra/bundle/fixtures/pension.yaml new file mode 100644 index 0000000..09e4992 --- /dev/null +++ b/evidence/cells/cra/bundle/fixtures/pension.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.cra-pension/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000002' } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: deceased }], body: { selectors: { uin: '1000000002' } } } + expectedTransport: { path: /v2/resources/civil-person/lookups/death-by-uin, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { deceased: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { deceased: true }, value: true } + - id: negative-living + response: { data: { domainData: { deceased: false } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-deceased + response: { data: { domainData: { deceased: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-fact + response: { data: { domainData: {} } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/person-is-deceased], evidenceExcludes: [deceased, '1000000002'], diagnosticsExclude: ['1000000002'] } diff --git a/evidence/cells/cra/bundle/queries/birth-evidence.sql b/evidence/cells/cra/bundle/queries/birth-evidence.sql new file mode 100644 index 0000000..562a8d4 --- /dev/null +++ b/evidence/cells/cra/bundle/queries/birth-evidence.sql @@ -0,0 +1,4 @@ +SELECT uin, birth_date, birth_brn +FROM birth_evidence +WHERE uin = :uin +LIMIT 2; diff --git a/evidence/cells/cra/bundle/schemas/birth-facts.schema.yaml b/evidence/cells/cra/bundle/schemas/birth-facts.schema.yaml new file mode 100644 index 0000000..e5a21ed --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/birth-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [uin, birth_date, birth_registered] +properties: { uin: { type: string, minLength: 10, maxLength: 10 }, birth_date: { type: string, format: date }, birth_registered: { type: boolean } } diff --git a/evidence/cells/cra/bundle/schemas/birth-response.schema.yaml b/evidence/cells/cra/bundle/schemas/birth-response.schema.yaml new file mode 100644 index 0000000..2b98c93 --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/birth-response.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [rows] +properties: + rows: { type: array, maxItems: 2, items: { type: object, additionalProperties: false, required: [uin, birth_date, birth_brn], properties: { uin: { type: string, minLength: 10, maxLength: 10 }, birth_date: { type: string, format: date }, birth_brn: { type: [string, 'null'], maxLength: 128 } } } } diff --git a/evidence/cells/cra/bundle/schemas/deceased-facts.schema.yaml b/evidence/cells/cra/bundle/schemas/deceased-facts.schema.yaml new file mode 100644 index 0000000..d1a0566 --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/deceased-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [deceased] +properties: { deceased: { type: boolean } } diff --git a/evidence/cells/cra/bundle/schemas/deceased-response.schema.yaml b/evidence/cells/cra/bundle/schemas/deceased-response.schema.yaml new file mode 100644 index 0000000..d4ed80c --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/deceased-response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [deceased], properties: { deceased: { type: boolean } } } } } } diff --git a/evidence/cells/cra/bundle/schemas/linked-facts.schema.yaml b/evidence/cells/cra/bundle/schemas/linked-facts.schema.yaml new file mode 100644 index 0000000..ce37feb --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/linked-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [linkedUin] +properties: { linkedUin: { type: string, minLength: 10, maxLength: 10 } } diff --git a/evidence/cells/cra/bundle/schemas/linked-response.schema.yaml b/evidence/cells/cra/bundle/schemas/linked-response.schema.yaml new file mode 100644 index 0000000..961e8e4 --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/linked-response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [linkedUin], properties: { linkedUin: { type: string, minLength: 10, maxLength: 10 } } } } } } diff --git a/evidence/cells/cra/bundle/schemas/relay-adapter-parameters.schema.yaml b/evidence/cells/cra/bundle/schemas/relay-adapter-parameters.schema.yaml new file mode 100644 index 0000000..88c2ff3 --- /dev/null +++ b/evidence/cells/cra/bundle/schemas/relay-adapter-parameters.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [fields] +properties: { fields: { type: string, minLength: 1, maxLength: 512 } } diff --git a/evidence/cells/cra/runtime.yaml b/evidence/cells/cra/runtime.yaml new file mode 100644 index 0000000..838fe3b --- /dev/null +++ b/evidence/cells/cra/runtime.yaml @@ -0,0 +1,9 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/cra/bundle +listener: { bindHost: 172.29.1.21, port: 8080, tlsTermination: operator-controlled-upstream, trustProxyIdentityHeaders: false, maximumRequestBytes: 65536, maximumConcurrentRequests: 64, requestTimeoutMilliseconds: 10000, shutdownGraceMilliseconds: 30000 } +secretProviders: { file: { root: /run/secrets/registry-evidence/cra } } +signer: { kind: transit, unixSocketPath: /run/registry-evidence/cra/transit-proxy.sock, mount: transit, keyName: solmara-evidence-cra, keyVersion: 1, timeoutMilliseconds: 2000 } +auditStorage: { path: /var/lib/registry-evidence/cra/audit/evidence.jsonl, maximumFileBytes: 1073741824 } +outboundTls: { systemRoots: true, trustProfiles: {} } +sourceExtracts: + cra-birth-extract: { path: /var/lib/registry-evidence/cra/extracts/cra-birth-20260704T090000Z.sqlite } diff --git a/evidence/cells/mosd-programme/bundle/adapters/relay-extract.rhai b/evidence/cells/mosd-programme/bundle/adapters/relay-extract.rhai new file mode 100644 index 0000000..d7c045a --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/adapters/relay-extract.rhai @@ -0,0 +1 @@ +fn extract(source_response, context) { #{outcome: "match", facts: #{duplicate_flag: source_response["data"]["domainData"]["duplicateEnrolment"]}} } diff --git a/evidence/cells/mosd-programme/bundle/adapters/relay-prepare.rhai b/evidence/cells/mosd-programme/bundle/adapters/relay-prepare.rhai new file mode 100644 index 0000000..b4d5dc7 --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/adapters/relay-prepare.rhai @@ -0,0 +1,5 @@ +fn prepare(selectors, context) { + let parameters = context["parameters"]; + let subject = selectors["subject"]["values"]; + #{query: [#{name: "fields", value: parameters["fields"]}], body: #{selectors: #{uin: subject["uin"]}}} +} diff --git a/evidence/cells/mosd-programme/bundle/derivations/not-enrolled.rhai b/evidence/cells/mosd-programme/bundle/derivations/not-enrolled.rhai new file mode 100644 index 0000000..a2b73b5 --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/derivations/not-enrolled.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "https://id.registrystack.org/solmara/concept/not-already-enrolled", value: !required(facts["duplicate_flag"], "required_fact_missing")}] +} diff --git a/evidence/cells/mosd-programme/bundle/evidence.yaml b/evidence/cells/mosd-programme/bundle/evidence.yaml new file mode 100644 index 0000000..84a2e8b --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/evidence.yaml @@ -0,0 +1,54 @@ +version: 1 +assuranceProfile: production +service: { providerId: https://id.registrystack.org/solmara/evidence/mosd-programme, trustDomain: https://id.registrystack.org/solmara/trust-domain/lab } +issuer: { id: did:web:id.registrystack.org:solmara:authority:mosd-programme-mis } +authentication: { kind: oidc-access-token, issuer: https://mint.solmara.registrystack.org, audiences: [solmara-runtime], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://mint.solmara.registrystack.org/.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, activePublicJwkFile: public-keys/mosd-programme.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 300, verifierClockSkewSeconds: 30 } +responseFormats: [signed-jws] +selectorProfiles: { solmara-uin-v1: { maximumAggregateBytes: 16, fields: { uin: { type: string, minimumBytes: 10, maximumBytes: 10 } } } } +sources: + mosd-child-benefit: + transport: http-json + baseUrl: https://mosd-programme-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/mosd-child-benefit-evidence-client-id, clientAssertionKeyRef: secret:file/mosd-child-benefit-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:mosd:by-uin', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: { status: 404, type: https://id.registrystack.org/problems/registry-relay/consultation/unresolved, code: consultation.unresolved } + request: + method: POST + path: /v2/resources/beneficiary-enrolment/lookups/by-uin + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: duplicateEnrolment } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 64, maximumNormalizedBytes: 1024 } + projection: [/data/domainData/duplicateEnrolment] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + mosd-consumers-v1: + kind: statutory + requesterTags: [solmara-demo] + grants: [{ requirement: https://id.registrystack.org/solmara/requirement/mosd-child-benefit/v1, purpose: child-benefit-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] }] +requirements: + - id: https://id.registrystack.org/solmara/requirement/mosd-child-benefit/v1 + kind: information-requirement + acquisition: { kind: single, source: mosd-child-benefit } + purposes: [child-benefit-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/child-benefit/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/not-enrolled/v1 + validitySeconds: 300 + derivation: { script: derivations/not-enrolled.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/not-already-enrolled, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/child-benefit.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/mosd-child-benefit] } + existenceDisclosure: collapse-unresolved diff --git a/evidence/cells/mosd-programme/bundle/fixtures/child-benefit.yaml b/evidence/cells/mosd-programme/bundle/fixtures/child-benefit.yaml new file mode 100644 index 0000000..6250f7a --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/fixtures/child-benefit.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.mosd-child-benefit/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000007' } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: duplicateEnrolment }], body: { selectors: { uin: '1000000007' } } } + expectedTransport: { path: /v2/resources/beneficiary-enrolment/lookups/by-uin, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { duplicateEnrolment: false } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { duplicate_flag: false }, value: true } + - id: negative-duplicate + response: { data: { domainData: { duplicateEnrolment: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-not-duplicate + response: { data: { domainData: { duplicateEnrolment: false } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-fact + response: { data: { domainData: {} } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/not-already-enrolled], evidenceExcludes: [duplicate_flag, '1000000007'], diagnosticsExclude: ['1000000007'] } diff --git a/evidence/cells/mosd-programme/bundle/schemas/facts.schema.yaml b/evidence/cells/mosd-programme/bundle/schemas/facts.schema.yaml new file mode 100644 index 0000000..e1858fb --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/schemas/facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [duplicate_flag] +properties: { duplicate_flag: { type: boolean } } diff --git a/evidence/cells/mosd-programme/bundle/schemas/relay-adapter-parameters.schema.yaml b/evidence/cells/mosd-programme/bundle/schemas/relay-adapter-parameters.schema.yaml new file mode 100644 index 0000000..88c2ff3 --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/schemas/relay-adapter-parameters.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [fields] +properties: { fields: { type: string, minLength: 1, maxLength: 512 } } diff --git a/evidence/cells/mosd-programme/bundle/schemas/response.schema.yaml b/evidence/cells/mosd-programme/bundle/schemas/response.schema.yaml new file mode 100644 index 0000000..4a5f161 --- /dev/null +++ b/evidence/cells/mosd-programme/bundle/schemas/response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [duplicateEnrolment], properties: { duplicateEnrolment: { type: boolean } } } } } } diff --git a/evidence/cells/mosd-programme/runtime.yaml b/evidence/cells/mosd-programme/runtime.yaml new file mode 100644 index 0000000..045b719 --- /dev/null +++ b/evidence/cells/mosd-programme/runtime.yaml @@ -0,0 +1,7 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/mosd-programme/bundle +listener: { bindHost: 172.29.1.24, port: 8080, tlsTermination: operator-controlled-upstream, trustProxyIdentityHeaders: false, maximumRequestBytes: 65536, maximumConcurrentRequests: 64, requestTimeoutMilliseconds: 10000, shutdownGraceMilliseconds: 30000 } +secretProviders: { file: { root: /run/secrets/registry-evidence/mosd-programme } } +signer: { kind: transit, unixSocketPath: /run/registry-evidence/mosd-programme/transit-proxy.sock, mount: transit, keyName: solmara-evidence-mosd-programme, keyVersion: 1, timeoutMilliseconds: 2000 } +auditStorage: { path: /var/lib/registry-evidence/mosd-programme/audit/evidence.jsonl, maximumFileBytes: 1073741824 } +outboundTls: { systemRoots: true, trustProfiles: {} } diff --git a/evidence/cells/nagdi/bundle/adapters/relay-extract.rhai b/evidence/cells/nagdi/bundle/adapters/relay-extract.rhai new file mode 100644 index 0000000..c18c50c --- /dev/null +++ b/evidence/cells/nagdi/bundle/adapters/relay-extract.rhai @@ -0,0 +1,7 @@ +fn extract(source_response, context) { + let domain = source_response["data"]["domainData"]; + if domain.contains("farmerRegistered") { + return #{outcome: "match", facts: #{farmer_registered: domain["farmerRegistered"], data_use_authorized: domain["dataUseAuthorized"], active_smallholder_farmer: domain["activeSmallholderFarmer"], active_farm_parcel: domain["activeFarmParcel"], crop_declared_for_season: domain["cropDeclaredForSeason"], district_climate_risk_active: domain["districtClimateRiskActive"], voucher_entitlement_current: domain["voucherEntitlementCurrent"], voucher_not_redeemed: domain["voucherNotRedeemed"]}}; + } + #{outcome: "match", facts: #{registered_herd: domain["registeredHerd"], herd_vaccination_current: domain["herdVaccinationCurrent"], origin_district_not_quarantined_for_species: domain["originDistrictNotQuarantinedForSpecies"], destination_district_open: domain["destinationDistrictOpen"], no_conflicting_open_movement_permit: domain["noConflictingOpenMovementPermit"]}} +} diff --git a/evidence/cells/nagdi/bundle/adapters/relay-prepare.rhai b/evidence/cells/nagdi/bundle/adapters/relay-prepare.rhai new file mode 100644 index 0000000..add0131 --- /dev/null +++ b/evidence/cells/nagdi/bundle/adapters/relay-prepare.rhai @@ -0,0 +1,5 @@ +fn prepare(selectors, context) { + let parameters = context["parameters"]; + let subject = selectors["subject"]["values"]; + #{query: [#{name: "fields", value: parameters["fields"]}], body: #{selectors: #{farmerId: subject["farmer_id"]}}} +} diff --git a/evidence/cells/nagdi/bundle/derivations/livestock.rhai b/evidence/cells/nagdi/bundle/derivations/livestock.rhai new file mode 100644 index 0000000..5585e3d --- /dev/null +++ b/evidence/cells/nagdi/bundle/derivations/livestock.rhai @@ -0,0 +1,10 @@ +fn derive(facts, selectors, evaluation_context) { + let registered = required(facts["registered_herd"], "required_fact_missing"); + let not_quarantined = required(facts["origin_district_not_quarantined_for_species"], "required_fact_missing"); + let eligible = registered && not_quarantined && required(facts["herd_vaccination_current"], "required_fact_missing") && required(facts["destination_district_open"], "required_fact_missing") && required(facts["no_conflicting_open_movement_permit"], "required_fact_missing"); + [ + #{concept_id: "https://id.registrystack.org/solmara/concept/registered-herd", value: registered}, + #{concept_id: "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", value: not_quarantined}, + #{concept_id: "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit", value: eligible} + ] +} diff --git a/evidence/cells/nagdi/bundle/derivations/voucher.rhai b/evidence/cells/nagdi/bundle/derivations/voucher.rhai new file mode 100644 index 0000000..3f15a74 --- /dev/null +++ b/evidence/cells/nagdi/bundle/derivations/voucher.rhai @@ -0,0 +1,10 @@ +fn derive(facts, selectors, evaluation_context) { + let registered = required(facts["farmer_registered"], "required_fact_missing"); + let authorized = required(facts["data_use_authorized"], "required_fact_missing"); + let eligible = registered && authorized && required(facts["active_smallholder_farmer"], "required_fact_missing") && required(facts["active_farm_parcel"], "required_fact_missing") && required(facts["crop_declared_for_season"], "required_fact_missing") && required(facts["district_climate_risk_active"], "required_fact_missing") && required(facts["voucher_entitlement_current"], "required_fact_missing") && required(facts["voucher_not_redeemed"], "required_fact_missing"); + [ + #{concept_id: "https://id.registrystack.org/solmara/concept/farmer-registered", value: registered}, + #{concept_id: "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", value: authorized}, + #{concept_id: "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher", value: eligible} + ] +} diff --git a/evidence/cells/nagdi/bundle/evidence.yaml b/evidence/cells/nagdi/bundle/evidence.yaml new file mode 100644 index 0000000..5838edb --- /dev/null +++ b/evidence/cells/nagdi/bundle/evidence.yaml @@ -0,0 +1,92 @@ +version: 1 +assuranceProfile: production +service: { providerId: https://id.registrystack.org/solmara/evidence/nagdi, trustDomain: https://id.registrystack.org/solmara/trust-domain/lab } +issuer: { id: did:web:id.registrystack.org:solmara:authority:nagdi } +authentication: { kind: oidc-access-token, issuer: https://mint.solmara.registrystack.org, audiences: [solmara-runtime], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://mint.solmara.registrystack.org/.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, activePublicJwkFile: public-keys/nagdi.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 300, verifierClockSkewSeconds: 30 } +responseFormats: [signed-jws] +selectorProfiles: { farmer-reference-v1: { maximumAggregateBytes: 32, fields: { farmer_id: { type: string, minimumBytes: 7, maximumBytes: 16 } } } } +sources: + nagdi-voucher: + transport: http-json + baseUrl: https://nagdi-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/nagdi-voucher-evidence-client-id, clientAssertionKeyRef: secret:file/nagdi-voucher-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:nagdi:voucher-by-farmer-id', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: &relay-unresolved { status: 404, type: https://id.registrystack.org/problems/registry-relay/consultation/unresolved, code: consultation.unresolved } + request: + method: POST + path: /v2/resources/farmer/lookups/voucher-by-farmer-id + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: farmer-reference-v1, fields: [farmer_id] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: 'farmerRegistered,dataUseAuthorized,activeSmallholderFarmer,activeFarmParcel,cropDeclaredForSeason,districtClimateRiskActive,voucherEntitlementCurrent,voucherNotRedeemed' } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 512, maximumNormalizedBytes: 2048 } + projection: [/data/domainData/farmerRegistered, /data/domainData/dataUseAuthorized, /data/domainData/activeSmallholderFarmer, /data/domainData/activeFarmParcel, /data/domainData/cropDeclaredForSeason, /data/domainData/districtClimateRiskActive, /data/domainData/voucherEntitlementCurrent, /data/domainData/voucherNotRedeemed] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/voucher-response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/voucher-facts.schema.yaml + nagdi-livestock: + transport: http-json + baseUrl: https://nagdi-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/nagdi-livestock-evidence-client-id, clientAssertionKeyRef: secret:file/nagdi-livestock-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:nagdi:movement-by-farmer-id', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: *relay-unresolved + request: + method: POST + path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: farmer-reference-v1, fields: [farmer_id] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: 'registeredHerd,herdVaccinationCurrent,originDistrictNotQuarantinedForSpecies,destinationDistrictOpen,noConflictingOpenMovementPermit' } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 512, maximumNormalizedBytes: 2048 } + projection: [/data/domainData/registeredHerd, /data/domainData/herdVaccinationCurrent, /data/domainData/originDistrictNotQuarantinedForSpecies, /data/domainData/destinationDistrictOpen, /data/domainData/noConflictingOpenMovementPermit] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/livestock-response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/livestock-facts.schema.yaml +authorityProfiles: + nagdi-consumers-v1: + kind: statutory + requesterTags: [solmara-demo] + grants: + - { requirement: https://id.registrystack.org/solmara/requirement/nagdi-voucher/v1, purpose: voucher-eligibility-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: farmer-reference-v1, valueOrigin: request }] } + - { requirement: https://id.registrystack.org/solmara/requirement/nagdi-livestock/v1, purpose: livestock-movement-control, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: farmer-reference-v1, valueOrigin: request }] } +requirements: + - id: https://id.registrystack.org/solmara/requirement/nagdi-voucher/v1 + kind: information-requirement + acquisition: { kind: single, source: nagdi-voucher } + purposes: [voucher-eligibility-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [farmer-reference-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/agriculture/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/climate-smart-voucher/v1 + validitySeconds: 300 + derivation: { script: derivations/voucher.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/farmer-registered, form: boolean, required: true, constraints: {} }, { id: https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose, form: boolean, required: true, constraints: {} }, { id: https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/voucher.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/nagdi-voucher] } + existenceDisclosure: collapse-unresolved + - id: https://id.registrystack.org/solmara/requirement/nagdi-livestock/v1 + kind: information-requirement + acquisition: { kind: single, source: nagdi-livestock } + purposes: [livestock-movement-control] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [farmer-reference-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/agriculture/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/livestock-movement/v1 + validitySeconds: 300 + derivation: { script: derivations/livestock.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/registered-herd, form: boolean, required: true, constraints: {} }, { id: https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species, form: boolean, required: true, constraints: {} }, { id: https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/livestock.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/nagdi-livestock] } + existenceDisclosure: collapse-unresolved diff --git a/evidence/cells/nagdi/bundle/fixtures/livestock.yaml b/evidence/cells/nagdi/bundle/fixtures/livestock.yaml new file mode 100644 index 0000000..519c671 --- /dev/null +++ b/evidence/cells/nagdi/bundle/fixtures/livestock.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.nagdi-livestock/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: farmer-reference-v1, values: { farmer_id: FR-000002 } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: 'registeredHerd,herdVaccinationCurrent,originDistrictNotQuarantinedForSpecies,destinationDistrictOpen,noConflictingOpenMovementPermit' }], body: { selectors: { farmerId: FR-000002 } } } + expectedTransport: { path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { registeredHerd: true, herdVaccinationCurrent: true, originDistrictNotQuarantinedForSpecies: true, destinationDistrictOpen: true, noConflictingOpenMovementPermit: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { registered_herd: true, herd_vaccination_current: true, origin_district_not_quarantined_for_species: true, destination_district_open: true, no_conflicting_open_movement_permit: true }, values: { https://id.registrystack.org/solmara/concept/registered-herd: true, https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species: true, https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit: true } } + - id: negative-quarantine + response: { data: { domainData: { registeredHerd: true, herdVaccinationCurrent: true, originDistrictNotQuarantinedForSpecies: false, destinationDistrictOpen: true, noConflictingOpenMovementPermit: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, values: { https://id.registrystack.org/solmara/concept/registered-herd: true, https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species: false, https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit: false } } + - id: boundary-eligible + response: { data: { domainData: { registeredHerd: true, herdVaccinationCurrent: true, originDistrictNotQuarantinedForSpecies: true, destinationDistrictOpen: true, noConflictingOpenMovementPermit: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, values: { https://id.registrystack.org/solmara/concept/registered-herd: true, https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species: true, https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit: true } } + - id: missing-fact + response: { data: { domainData: { registeredHerd: true, herdVaccinationCurrent: true, destinationDistrictOpen: true, noConflictingOpenMovementPermit: true } } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/registered-herd, https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species, https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit], evidenceExcludes: [farmer_id, FR-000002], diagnosticsExclude: [FR-000002] } diff --git a/evidence/cells/nagdi/bundle/fixtures/voucher.yaml b/evidence/cells/nagdi/bundle/fixtures/voucher.yaml new file mode 100644 index 0000000..17fa957 --- /dev/null +++ b/evidence/cells/nagdi/bundle/fixtures/voucher.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.nagdi-voucher/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: farmer-reference-v1, values: { farmer_id: FR-000001 } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: 'farmerRegistered,dataUseAuthorized,activeSmallholderFarmer,activeFarmParcel,cropDeclaredForSeason,districtClimateRiskActive,voucherEntitlementCurrent,voucherNotRedeemed' }], body: { selectors: { farmerId: FR-000001 } } } + expectedTransport: { path: /v2/resources/farmer/lookups/voucher-by-farmer-id, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { farmerRegistered: true, dataUseAuthorized: true, activeSmallholderFarmer: true, activeFarmParcel: true, cropDeclaredForSeason: true, districtClimateRiskActive: true, voucherEntitlementCurrent: true, voucherNotRedeemed: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { farmer_registered: true, data_use_authorized: true, active_smallholder_farmer: true, active_farm_parcel: true, crop_declared_for_season: true, district_climate_risk_active: true, voucher_entitlement_current: true, voucher_not_redeemed: true }, values: { https://id.registrystack.org/solmara/concept/farmer-registered: true, https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose: true, https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher: true } } + - id: negative-redeemed + response: { data: { domainData: { farmerRegistered: true, dataUseAuthorized: true, activeSmallholderFarmer: true, activeFarmParcel: true, cropDeclaredForSeason: true, districtClimateRiskActive: true, voucherEntitlementCurrent: true, voucherNotRedeemed: false } } } + expected: { lookup: match, derivationRuns: true, signed: true, values: { https://id.registrystack.org/solmara/concept/farmer-registered: true, https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose: true, https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher: false } } + - id: boundary-eligible + response: { data: { domainData: { farmerRegistered: true, dataUseAuthorized: true, activeSmallholderFarmer: true, activeFarmParcel: true, cropDeclaredForSeason: true, districtClimateRiskActive: true, voucherEntitlementCurrent: true, voucherNotRedeemed: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, values: { https://id.registrystack.org/solmara/concept/farmer-registered: true, https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose: true, https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher: true } } + - id: missing-fact + response: { data: { domainData: { farmerRegistered: true, dataUseAuthorized: true, activeSmallholderFarmer: true, activeFarmParcel: true, cropDeclaredForSeason: true, districtClimateRiskActive: true, voucherEntitlementCurrent: true } } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/farmer-registered, https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose, https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher], evidenceExcludes: [farmer_id, FR-000001], diagnosticsExclude: [FR-000001] } diff --git a/evidence/cells/nagdi/bundle/schemas/livestock-facts.schema.yaml b/evidence/cells/nagdi/bundle/schemas/livestock-facts.schema.yaml new file mode 100644 index 0000000..c5c9f36 --- /dev/null +++ b/evidence/cells/nagdi/bundle/schemas/livestock-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [registered_herd, herd_vaccination_current, origin_district_not_quarantined_for_species, destination_district_open, no_conflicting_open_movement_permit] +properties: { registered_herd: { type: boolean }, herd_vaccination_current: { type: boolean }, origin_district_not_quarantined_for_species: { type: boolean }, destination_district_open: { type: boolean }, no_conflicting_open_movement_permit: { type: boolean } } diff --git a/evidence/cells/nagdi/bundle/schemas/livestock-response.schema.yaml b/evidence/cells/nagdi/bundle/schemas/livestock-response.schema.yaml new file mode 100644 index 0000000..116dcdc --- /dev/null +++ b/evidence/cells/nagdi/bundle/schemas/livestock-response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [registeredHerd, herdVaccinationCurrent, originDistrictNotQuarantinedForSpecies, destinationDistrictOpen, noConflictingOpenMovementPermit], properties: { registeredHerd: { type: boolean }, herdVaccinationCurrent: { type: boolean }, originDistrictNotQuarantinedForSpecies: { type: boolean }, destinationDistrictOpen: { type: boolean }, noConflictingOpenMovementPermit: { type: boolean } } } } } } diff --git a/evidence/cells/nagdi/bundle/schemas/relay-adapter-parameters.schema.yaml b/evidence/cells/nagdi/bundle/schemas/relay-adapter-parameters.schema.yaml new file mode 100644 index 0000000..88c2ff3 --- /dev/null +++ b/evidence/cells/nagdi/bundle/schemas/relay-adapter-parameters.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [fields] +properties: { fields: { type: string, minLength: 1, maxLength: 512 } } diff --git a/evidence/cells/nagdi/bundle/schemas/voucher-facts.schema.yaml b/evidence/cells/nagdi/bundle/schemas/voucher-facts.schema.yaml new file mode 100644 index 0000000..14f53cc --- /dev/null +++ b/evidence/cells/nagdi/bundle/schemas/voucher-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [farmer_registered, data_use_authorized, active_smallholder_farmer, active_farm_parcel, crop_declared_for_season, district_climate_risk_active, voucher_entitlement_current, voucher_not_redeemed] +properties: { farmer_registered: { type: boolean }, data_use_authorized: { type: boolean }, active_smallholder_farmer: { type: boolean }, active_farm_parcel: { type: boolean }, crop_declared_for_season: { type: boolean }, district_climate_risk_active: { type: boolean }, voucher_entitlement_current: { type: boolean }, voucher_not_redeemed: { type: boolean } } diff --git a/evidence/cells/nagdi/bundle/schemas/voucher-response.schema.yaml b/evidence/cells/nagdi/bundle/schemas/voucher-response.schema.yaml new file mode 100644 index 0000000..86fbcb0 --- /dev/null +++ b/evidence/cells/nagdi/bundle/schemas/voucher-response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [farmerRegistered, dataUseAuthorized, activeSmallholderFarmer, activeFarmParcel, cropDeclaredForSeason, districtClimateRiskActive, voucherEntitlementCurrent, voucherNotRedeemed], properties: { farmerRegistered: { type: boolean }, dataUseAuthorized: { type: boolean }, activeSmallholderFarmer: { type: boolean }, activeFarmParcel: { type: boolean }, cropDeclaredForSeason: { type: boolean }, districtClimateRiskActive: { type: boolean }, voucherEntitlementCurrent: { type: boolean }, voucherNotRedeemed: { type: boolean } } } } } } diff --git a/evidence/cells/nagdi/runtime.yaml b/evidence/cells/nagdi/runtime.yaml new file mode 100644 index 0000000..73206d3 --- /dev/null +++ b/evidence/cells/nagdi/runtime.yaml @@ -0,0 +1,7 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/nagdi/bundle +listener: { bindHost: 172.29.1.26, port: 8080, tlsTermination: operator-controlled-upstream, trustProxyIdentityHeaders: false, maximumRequestBytes: 65536, maximumConcurrentRequests: 64, requestTimeoutMilliseconds: 10000, shutdownGraceMilliseconds: 30000 } +secretProviders: { file: { root: /run/secrets/registry-evidence/nagdi } } +signer: { kind: transit, unixSocketPath: /run/registry-evidence/nagdi/transit-proxy.sock, mount: transit, keyName: solmara-evidence-nagdi, keyVersion: 1, timeoutMilliseconds: 2000 } +auditStorage: { path: /var/lib/registry-evidence/nagdi/audit/evidence.jsonl, maximumFileBytes: 1073741824 } +outboundTls: { systemRoots: true, trustProfiles: {} } diff --git a/evidence/cells/nia/bundle/adapters/sqlite-extract.rhai b/evidence/cells/nia/bundle/adapters/sqlite-extract.rhai new file mode 100644 index 0000000..483d916 --- /dev/null +++ b/evidence/cells/nia/bundle/adapters/sqlite-extract.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, context) { + let rows = source_response["rows"]; + if rows.len == 0 { return #{outcome: "no_match"}; } + if rows.len > 1 { return #{outcome: "ambiguous"}; } + #{outcome: "match", facts: rows[0]} +} diff --git a/evidence/cells/nia/bundle/derivations/population-active.rhai b/evidence/cells/nia/bundle/derivations/population-active.rhai new file mode 100644 index 0000000..b67df39 --- /dev/null +++ b/evidence/cells/nia/bundle/derivations/population-active.rhai @@ -0,0 +1,4 @@ +fn derive(facts, selectors, evaluation_context) { + let active = required(facts["identity_status"], "required_fact_missing") == "active" && required(facts["alive"], "required_fact_missing"); + [#{concept_id: evaluation_context["parameters"]["concept_id"], value: active}] +} diff --git a/evidence/cells/nia/bundle/evidence.yaml b/evidence/cells/nia/bundle/evidence.yaml new file mode 100644 index 0000000..275d41d --- /dev/null +++ b/evidence/cells/nia/bundle/evidence.yaml @@ -0,0 +1,67 @@ +version: 1 +assuranceProfile: production +service: { providerId: https://id.registrystack.org/solmara/evidence/nia, trustDomain: https://id.registrystack.org/solmara/trust-domain/lab } +issuer: { id: did:web:id.registrystack.org:solmara:authority:nia } +authentication: { kind: oidc-access-token, issuer: https://mint.solmara.registrystack.org, audiences: [solmara-runtime], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://mint.solmara.registrystack.org/.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, activePublicJwkFile: public-keys/nia.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 3600, verifierClockSkewSeconds: 30 } +responseFormats: [signed-jws] +selectorProfiles: + solmara-uin-v1: { maximumAggregateBytes: 16, fields: { uin: { type: string, minimumBytes: 10, maximumBytes: 10 } } } +sources: + population-extract: + transport: sqlite-extract + extractProfile: nia-population-extract + posture: source-derived + maximumExtractAgeSeconds: 86400 + request: + statement: queries/population-evidence.sql + columns: [{ name: uin, type: string }, { name: identity_status, type: string }, { name: alive, type: boolean }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + parameterBindings: { uin: { kind: selector, role: subject, profile: solmara-uin-v1, field: uin } } + projection: [/rows/*/uin, /rows/*/identity_status, /rows/*/alive] + maximumRows: 2 + maximumCellBytes: 128 + maximumStatementSteps: 100000 + timeoutMilliseconds: 2000 + maximumResponseBytes: 8192 + concurrencyLimit: 8 + responseSchema: schemas/population-response.schema.yaml + extractScript: adapters/sqlite-extract.rhai + factSchema: schemas/population-facts.schema.yaml +authorityProfiles: + nia-consumers-v1: + kind: statutory + requesterTags: [solmara-demo] + grants: + - { requirement: https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1, purpose: child-benefit-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } + - { requirement: https://id.registrystack.org/solmara/requirement/nia-citizen-status/v1, purpose: citizen-self-service, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } +requirements: + - id: https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1 + kind: information-requirement + acquisition: { kind: single, source: population-extract } + purposes: [child-benefit-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/child-benefit/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/population-active/v1 + validitySeconds: 3600 + derivation: { script: derivations/population-active.rhai, parameters: { concept_id: https://id.registrystack.org/solmara/concept/population-record-active } } + concepts: [{ id: https://id.registrystack.org/solmara/concept/population-record-active, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/child-benefit.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/nia-child-benefit] } + existenceDisclosure: collapse-unresolved + - id: https://id.registrystack.org/solmara/requirement/nia-citizen-status/v1 + kind: information-requirement + acquisition: { kind: single, source: population-extract } + purposes: [citizen-self-service] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/citizen-self-service/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/citizen-population-active/v1 + validitySeconds: 3600 + derivation: { script: derivations/population-active.rhai, parameters: { concept_id: https://id.registrystack.org/solmara/concept/citizen-population-record-active } } + concepts: [{ id: https://id.registrystack.org/solmara/concept/citizen-population-record-active, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/citizen.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/nia-citizen] } + existenceDisclosure: collapse-unresolved diff --git a/evidence/cells/nia/bundle/fixtures/child-benefit.yaml b/evidence/cells/nia/bundle/fixtures/child-benefit.yaml new file mode 100644 index 0000000..982ce58 --- /dev/null +++ b/evidence/cells/nia/bundle/fixtures/child-benefit.yaml @@ -0,0 +1,42 @@ +fixture: registry.evidence.reference.solmara.nia-child-benefit/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000004' } } } + derivationSelectorInputs: {} + extract: | + CREATE TABLE evidence_extract (published_at TEXT NOT NULL, publisher TEXT NOT NULL, extract_id TEXT NOT NULL); + INSERT INTO evidence_extract VALUES ('2026-07-14T00:00:00Z', 'did:web:id.registrystack.org:solmara:authority:nia', 'nia-population-2026-07-14'); + CREATE TABLE population_evidence (uin TEXT NOT NULL, identity_status TEXT NOT NULL, alive INTEGER NOT NULL); + INSERT INTO population_evidence VALUES ('1000000004', 'active', 1); + INSERT INTO population_evidence VALUES ('1000000014', 'inactive', 1); + INSERT INTO population_evidence VALUES ('1000000016', 'active', 1); + INSERT INTO population_evidence VALUES ('1000000016', 'inactive', 1); + expectedRequestParts: { parameters: {} } + expectedTransport: { statement: queries/population-evidence.sql } +cases: + - id: positive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000004' } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { uin: '1000000004', identity_status: active, alive: true }, value: true } + - id: negative-inactive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000014' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-active + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000004' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-record + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000098' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: no-match + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000099' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: ambiguous-duplicate + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000016' } } } + expected: { lookup: ambiguous, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/population-record-active], evidenceExcludes: [uin, identity_status, alive], diagnosticsExclude: ['1000000004'] } diff --git a/evidence/cells/nia/bundle/fixtures/citizen.yaml b/evidence/cells/nia/bundle/fixtures/citizen.yaml new file mode 100644 index 0000000..c8b3f19 --- /dev/null +++ b/evidence/cells/nia/bundle/fixtures/citizen.yaml @@ -0,0 +1,43 @@ +fixture: registry.evidence.reference.solmara.nia-citizen/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000005' } } } + derivationSelectorInputs: {} + extract: | + CREATE TABLE evidence_extract (published_at TEXT NOT NULL, publisher TEXT NOT NULL, extract_id TEXT NOT NULL); + INSERT INTO evidence_extract VALUES ('2026-07-14T00:00:00Z', 'did:web:id.registrystack.org:solmara:authority:nia', 'nia-population-2026-07-14'); + CREATE TABLE population_evidence (uin TEXT NOT NULL, identity_status TEXT NOT NULL, alive INTEGER NOT NULL); + INSERT INTO population_evidence VALUES ('1000000005', 'active', 1); + INSERT INTO population_evidence VALUES ('1000000015', 'inactive', 1); + INSERT INTO population_evidence VALUES ('1000000025', 'active', 0); + INSERT INTO population_evidence VALUES ('1000000035', 'active', 1); + INSERT INTO population_evidence VALUES ('1000000035', 'inactive', 1); + expectedRequestParts: { parameters: {} } + expectedTransport: { statement: queries/population-evidence.sql } +cases: + - id: positive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000005' } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { uin: '1000000005', identity_status: active, alive: true }, value: true } + - id: negative-inactive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000015' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-not-alive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000025' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: missing-record + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000098' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: no-match + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000099' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: ambiguous-duplicate + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000035' } } } + expected: { lookup: ambiguous, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/citizen-population-record-active], evidenceExcludes: [uin, identity_status, alive], diagnosticsExclude: ['1000000005'] } diff --git a/evidence/cells/nia/bundle/queries/population-evidence.sql b/evidence/cells/nia/bundle/queries/population-evidence.sql new file mode 100644 index 0000000..189568c --- /dev/null +++ b/evidence/cells/nia/bundle/queries/population-evidence.sql @@ -0,0 +1,4 @@ +SELECT uin, identity_status, alive +FROM population_evidence +WHERE uin = :uin +LIMIT 2; diff --git a/evidence/cells/nia/bundle/schemas/population-facts.schema.yaml b/evidence/cells/nia/bundle/schemas/population-facts.schema.yaml new file mode 100644 index 0000000..5bbda0a --- /dev/null +++ b/evidence/cells/nia/bundle/schemas/population-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [uin, identity_status, alive] +properties: { uin: { type: string, minLength: 10, maxLength: 10 }, identity_status: { type: string, minLength: 1, maxLength: 32 }, alive: { type: boolean } } diff --git a/evidence/cells/nia/bundle/schemas/population-response.schema.yaml b/evidence/cells/nia/bundle/schemas/population-response.schema.yaml new file mode 100644 index 0000000..0df8bbb --- /dev/null +++ b/evidence/cells/nia/bundle/schemas/population-response.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [rows] +properties: + rows: { type: array, maxItems: 2, items: { type: object, additionalProperties: false, required: [uin, identity_status, alive], properties: { uin: { type: string, minLength: 10, maxLength: 10 }, identity_status: { type: string, minLength: 1, maxLength: 32 }, alive: { type: boolean } } } } diff --git a/evidence/cells/nia/runtime.yaml b/evidence/cells/nia/runtime.yaml new file mode 100644 index 0000000..3e54cb9 --- /dev/null +++ b/evidence/cells/nia/runtime.yaml @@ -0,0 +1,9 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/nia/bundle +listener: { bindHost: 172.29.1.22, port: 8080, tlsTermination: operator-controlled-upstream, trustProxyIdentityHeaders: false, maximumRequestBytes: 65536, maximumConcurrentRequests: 64, requestTimeoutMilliseconds: 10000, shutdownGraceMilliseconds: 30000 } +secretProviders: { file: { root: /run/secrets/registry-evidence/nia } } +signer: { kind: transit, unixSocketPath: /run/registry-evidence/nia/transit-proxy.sock, mount: transit, keyName: solmara-evidence-nia, keyVersion: 1, timeoutMilliseconds: 2000 } +auditStorage: { path: /var/lib/registry-evidence/nia/audit/evidence.jsonl, maximumFileBytes: 1073741824 } +outboundTls: { systemRoots: true, trustProfiles: {} } +sourceExtracts: + nia-population-extract: { path: /var/lib/registry-evidence/nia/extracts/nia-population-20260704T090000Z.sqlite } diff --git a/evidence/cells/sipf/bundle/adapters/relay-extract.rhai b/evidence/cells/sipf/bundle/adapters/relay-extract.rhai new file mode 100644 index 0000000..4f1ead0 --- /dev/null +++ b/evidence/cells/sipf/bundle/adapters/relay-extract.rhai @@ -0,0 +1,5 @@ +fn extract(source_response, context) { + let domain = source_response["data"]["domainData"]; + if domain.contains("paymentStatus") { return #{outcome: "match", facts: #{payment_status: domain["paymentStatus"]}}; } + #{outcome: "match", facts: #{survivor_eligible: domain["survivorEligible"]}} +} diff --git a/evidence/cells/sipf/bundle/adapters/relay-prepare.rhai b/evidence/cells/sipf/bundle/adapters/relay-prepare.rhai new file mode 100644 index 0000000..fcbb8bd --- /dev/null +++ b/evidence/cells/sipf/bundle/adapters/relay-prepare.rhai @@ -0,0 +1,13 @@ +fn prepare(selectors, context) { + let parameters = context["parameters"]; + let subject = selectors["subject"]["values"]; + let relay_selectors = #{}; + if parameters["selectorField"] == "pensionerUin" { + relay_selectors["pensionerUin"] = subject["uin"]; + } else if parameters["selectorField"] == "spouseUin" { + relay_selectors["spouseUin"] = subject["uin"]; + } else { + throw("invalid_selector_field"); + } + #{query: [#{name: "fields", value: parameters["fields"]}], body: #{selectors: relay_selectors}} +} diff --git a/evidence/cells/sipf/bundle/derivations/pension-active.rhai b/evidence/cells/sipf/bundle/derivations/pension-active.rhai new file mode 100644 index 0000000..2fd220b --- /dev/null +++ b/evidence/cells/sipf/bundle/derivations/pension-active.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "https://id.registrystack.org/solmara/concept/pension-payment-active", value: required(facts["payment_status"], "required_fact_missing") == "active"}] +} diff --git a/evidence/cells/sipf/bundle/derivations/survivor-eligible.rhai b/evidence/cells/sipf/bundle/derivations/survivor-eligible.rhai new file mode 100644 index 0000000..47e44d7 --- /dev/null +++ b/evidence/cells/sipf/bundle/derivations/survivor-eligible.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "https://id.registrystack.org/solmara/concept/survivor-is-eligible", value: required(facts["survivor_eligible"], "required_fact_missing")}] +} diff --git a/evidence/cells/sipf/bundle/evidence.yaml b/evidence/cells/sipf/bundle/evidence.yaml new file mode 100644 index 0000000..2a2dfc6 --- /dev/null +++ b/evidence/cells/sipf/bundle/evidence.yaml @@ -0,0 +1,92 @@ +version: 1 +assuranceProfile: production +service: { providerId: https://id.registrystack.org/solmara/evidence/sipf, trustDomain: https://id.registrystack.org/solmara/trust-domain/lab } +issuer: { id: did:web:id.registrystack.org:solmara:authority:sipf } +authentication: { kind: oidc-access-token, issuer: https://mint.solmara.registrystack.org, audiences: [solmara-runtime], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://mint.solmara.registrystack.org/.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, activePublicJwkFile: public-keys/sipf.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 300, verifierClockSkewSeconds: 30 } +responseFormats: [signed-jws] +selectorProfiles: { solmara-uin-v1: { maximumAggregateBytes: 16, fields: { uin: { type: string, minimumBytes: 10, maximumBytes: 10 } } } } +sources: + sipf-pension: + transport: http-json + baseUrl: https://sipf-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/sipf-pension-evidence-client-id, clientAssertionKeyRef: secret:file/sipf-pension-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:sipf:by-pensioner-uin', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: &relay-unresolved { status: 404, type: https://id.registrystack.org/problems/registry-relay/consultation/unresolved, code: consultation.unresolved } + request: + method: POST + path: /v2/resources/pension-payment/lookups/by-pensioner-uin + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: paymentStatus, selectorField: pensionerUin } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 64, maximumNormalizedBytes: 1024 } + projection: [/data/domainData/paymentStatus] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/pension-response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/pension-facts.schema.yaml + sipf-survivor: + transport: http-json + baseUrl: https://sipf-relay.solmara.registrystack.org + posture: field-projected + authentication: { kind: oauth2-client-credentials, tokenEndpoint: https://mint.solmara.registrystack.org/token, clientIdRef: secret:file/sipf-survivor-evidence-client-id, clientAssertionKeyRef: secret:file/sipf-survivor-evidence-client-key, clientAssertionAudience: https://mint.solmara.registrystack.org/token, scope: 'solmara:relay:sipf:by-spouse-uin', audience: solmara-runtime, maximumCacheSeconds: 60 } + unresolvedProblem: *relay-unresolved + request: + method: POST + path: /v2/resources/survivor-case/lookups/by-spouse-uin + fixedHeaders: [{ name: Accept, value: application/json }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + prepareScript: adapters/relay-prepare.rhai + adapterParameters: { fields: survivorEligible, selectorField: spouseUin } + adapterParametersSchema: schemas/relay-adapter-parameters.schema.yaml + preparationLimits: { query: required, jsonBody: required, maximumQueryPairs: 1, maximumQueryNameBytes: 16, maximumQueryValueBytes: 64, maximumNormalizedBytes: 1024 } + projection: [/data/domainData/survivorEligible] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/survivor-response.schema.yaml + extractScript: adapters/relay-extract.rhai + factSchema: schemas/survivor-facts.schema.yaml +authorityProfiles: + sipf-consumers-v1: + kind: statutory + requesterTags: [solmara-demo] + grants: + - { requirement: https://id.registrystack.org/solmara/requirement/sipf-pension-payment/v1, purpose: pension-payment-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } + - { requirement: https://id.registrystack.org/solmara/requirement/sipf-survivor-benefit/v1, purpose: survivor-benefit-determination, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] } +requirements: + - id: https://id.registrystack.org/solmara/requirement/sipf-pension-payment/v1 + kind: information-requirement + acquisition: { kind: single, source: sipf-pension } + purposes: [pension-payment-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/social-insurance/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/pension-payment-active/v1 + validitySeconds: 300 + derivation: { script: derivations/pension-active.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/pension-payment-active, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/pension.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/sipf-pension] } + existenceDisclosure: collapse-unresolved + - id: https://id.registrystack.org/solmara/requirement/sipf-survivor-benefit/v1 + kind: information-requirement + acquisition: { kind: single, source: sipf-survivor } + purposes: [survivor-benefit-determination] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/social-insurance/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/survivor-benefit/v1 + validitySeconds: 300 + derivation: { script: derivations/survivor-eligible.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/survivor-is-eligible, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/survivor.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/sipf-survivor] } + existenceDisclosure: collapse-unresolved diff --git a/evidence/cells/sipf/bundle/fixtures/pension.yaml b/evidence/cells/sipf/bundle/fixtures/pension.yaml new file mode 100644 index 0000000..01bffd8 --- /dev/null +++ b/evidence/cells/sipf/bundle/fixtures/pension.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.sipf-pension/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000008' } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: paymentStatus }], body: { selectors: { pensionerUin: '1000000008' } } } + expectedTransport: { path: /v2/resources/pension-payment/lookups/by-pensioner-uin, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { paymentStatus: active } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { payment_status: active }, value: true } + - id: negative-suspended + response: { data: { domainData: { paymentStatus: suspended } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-active + response: { data: { domainData: { paymentStatus: active } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-fact + response: { data: { domainData: {} } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/pension-payment-active], evidenceExcludes: [payment_status, '1000000008'], diagnosticsExclude: ['1000000008'] } diff --git a/evidence/cells/sipf/bundle/fixtures/survivor.yaml b/evidence/cells/sipf/bundle/fixtures/survivor.yaml new file mode 100644 index 0000000..cdf4f61 --- /dev/null +++ b/evidence/cells/sipf/bundle/fixtures/survivor.yaml @@ -0,0 +1,34 @@ +fixture: registry.evidence.reference.solmara.sipf-survivor/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000009' } } } + derivationSelectorInputs: {} + expectedRequestParts: { query: [{ name: fields, value: survivorEligible }], body: { selectors: { spouseUin: '1000000009' } } } + expectedTransport: { path: /v2/resources/survivor-case/lookups/by-spouse-uin, fixedHeaders: [{ name: Accept, value: application/json }] } +cases: + - id: positive + response: { data: { domainData: { survivorEligible: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { survivor_eligible: true }, value: true } + - id: negative-ineligible + response: { data: { domainData: { survivorEligible: false } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-eligible + response: { data: { domainData: { survivorEligible: true } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-fact + response: { data: { domainData: {} } } + expected: { publicProblem: source.unavailable, derivationRuns: false, signed: false } + - id: no-match + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: ambiguous-relay-collapse + declaredUnresolved: true + expected: { publicProblem: evidence.unavailable, derivationRuns: false, signed: false, sourceRequestCount: 1 } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/survivor-is-eligible], evidenceExcludes: [survivor_eligible, '1000000009'], diagnosticsExclude: ['1000000009'] } diff --git a/evidence/cells/sipf/bundle/schemas/pension-facts.schema.yaml b/evidence/cells/sipf/bundle/schemas/pension-facts.schema.yaml new file mode 100644 index 0000000..55f7693 --- /dev/null +++ b/evidence/cells/sipf/bundle/schemas/pension-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [payment_status] +properties: { payment_status: { type: string, minLength: 1, maxLength: 32 } } diff --git a/evidence/cells/sipf/bundle/schemas/pension-response.schema.yaml b/evidence/cells/sipf/bundle/schemas/pension-response.schema.yaml new file mode 100644 index 0000000..fe0c969 --- /dev/null +++ b/evidence/cells/sipf/bundle/schemas/pension-response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [paymentStatus], properties: { paymentStatus: { type: string, minLength: 1, maxLength: 32 } } } } } } diff --git a/evidence/cells/sipf/bundle/schemas/relay-adapter-parameters.schema.yaml b/evidence/cells/sipf/bundle/schemas/relay-adapter-parameters.schema.yaml new file mode 100644 index 0000000..352194d --- /dev/null +++ b/evidence/cells/sipf/bundle/schemas/relay-adapter-parameters.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [fields, selectorField] +properties: { fields: { type: string, minLength: 1, maxLength: 512 }, selectorField: { type: string, enum: [pensionerUin, spouseUin] } } diff --git a/evidence/cells/sipf/bundle/schemas/survivor-facts.schema.yaml b/evidence/cells/sipf/bundle/schemas/survivor-facts.schema.yaml new file mode 100644 index 0000000..21f0fb6 --- /dev/null +++ b/evidence/cells/sipf/bundle/schemas/survivor-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [survivor_eligible] +properties: { survivor_eligible: { type: boolean } } diff --git a/evidence/cells/sipf/bundle/schemas/survivor-response.schema.yaml b/evidence/cells/sipf/bundle/schemas/survivor-response.schema.yaml new file mode 100644 index 0000000..dfe82f7 --- /dev/null +++ b/evidence/cells/sipf/bundle/schemas/survivor-response.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [data] +properties: { data: { type: object, additionalProperties: false, required: [domainData], properties: { domainData: { type: object, additionalProperties: false, required: [survivorEligible], properties: { survivorEligible: { type: boolean } } } } } } diff --git a/evidence/cells/sipf/runtime.yaml b/evidence/cells/sipf/runtime.yaml new file mode 100644 index 0000000..f611f6e --- /dev/null +++ b/evidence/cells/sipf/runtime.yaml @@ -0,0 +1,7 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/sipf/bundle +listener: { bindHost: 172.29.1.25, port: 8080, tlsTermination: operator-controlled-upstream, trustProxyIdentityHeaders: false, maximumRequestBytes: 65536, maximumConcurrentRequests: 64, requestTimeoutMilliseconds: 10000, shutdownGraceMilliseconds: 30000 } +secretProviders: { file: { root: /run/secrets/registry-evidence/sipf } } +signer: { kind: transit, unixSocketPath: /run/registry-evidence/sipf/transit-proxy.sock, mount: transit, keyName: solmara-evidence-sipf, keyVersion: 1, timeoutMilliseconds: 2000 } +auditStorage: { path: /var/lib/registry-evidence/sipf/audit/evidence.jsonl, maximumFileBytes: 1073741824 } +outboundTls: { systemRoots: true, trustProfiles: {} } diff --git a/evidence/cells/sro/bundle/adapters/sqlite-extract.rhai b/evidence/cells/sro/bundle/adapters/sqlite-extract.rhai new file mode 100644 index 0000000..483d916 --- /dev/null +++ b/evidence/cells/sro/bundle/adapters/sqlite-extract.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, context) { + let rows = source_response["rows"]; + if rows.len == 0 { return #{outcome: "no_match"}; } + if rows.len > 1 { return #{outcome: "ambiguous"}; } + #{outcome: "match", facts: rows[0]} +} diff --git a/evidence/cells/sro/bundle/derivations/poverty-priority.rhai b/evidence/cells/sro/bundle/derivations/poverty-priority.rhai new file mode 100644 index 0000000..7ec7398 --- /dev/null +++ b/evidence/cells/sro/bundle/derivations/poverty-priority.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "https://id.registrystack.org/solmara/concept/household-below-poverty-threshold", value: required(facts["poverty_band"], "required_fact_missing") == "priority"}] +} diff --git a/evidence/cells/sro/bundle/evidence.yaml b/evidence/cells/sro/bundle/evidence.yaml new file mode 100644 index 0000000..6da0298 --- /dev/null +++ b/evidence/cells/sro/bundle/evidence.yaml @@ -0,0 +1,52 @@ +version: 1 +assuranceProfile: production +service: { providerId: https://id.registrystack.org/solmara/evidence/sro, trustDomain: https://id.registrystack.org/solmara/trust-domain/lab } +issuer: { id: did:web:id.registrystack.org:solmara:authority:sro } +authentication: { kind: oidc-access-token, issuer: https://mint.solmara.registrystack.org, audiences: [solmara-runtime], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://mint.solmara.registrystack.org/.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, activePublicJwkFile: public-keys/sro.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 3600, verifierClockSkewSeconds: 30 } +responseFormats: [signed-jws] +selectorProfiles: + solmara-uin-v1: { maximumAggregateBytes: 16, fields: { uin: { type: string, minimumBytes: 10, maximumBytes: 10 } } } +sources: + poverty-extract: + transport: sqlite-extract + extractProfile: sro-poverty-extract + posture: source-derived + maximumExtractAgeSeconds: 86400 + request: + statement: queries/poverty-evidence.sql + columns: [{ name: uin, type: string }, { name: poverty_band, type: string }] + selectorInputs: [{ role: subject, alternatives: [{ profile: solmara-uin-v1, fields: [uin] }] }] + parameterBindings: { uin: { kind: selector, role: subject, profile: solmara-uin-v1, field: uin } } + projection: [/rows/*/uin, /rows/*/poverty_band] + maximumRows: 2 + maximumCellBytes: 128 + maximumStatementSteps: 100000 + timeoutMilliseconds: 2000 + maximumResponseBytes: 8192 + concurrencyLimit: 8 + responseSchema: schemas/poverty-response.schema.yaml + extractScript: adapters/sqlite-extract.rhai + factSchema: schemas/poverty-facts.schema.yaml +authorityProfiles: + sro-consumers-v1: + kind: statutory + requesterTags: [solmara-demo] + grants: [{ requirement: https://id.registrystack.org/solmara/requirement/sro-child-benefit/v1, purpose: child-benefit-review, audienceFrom: authenticated-requester, responseFormats: [signed-jws], subjects: [{ role: subject, selectorProfile: solmara-uin-v1, valueOrigin: request }] }] +requirements: + - id: https://id.registrystack.org/solmara/requirement/sro-child-benefit/v1 + kind: information-requirement + acquisition: { kind: single, source: poverty-extract } + purposes: [child-benefit-review] + subjectRoles: [{ role: subject, cardinality: one, selectorProfiles: [solmara-uin-v1] }] + referenceFrameworks: [https://id.registrystack.org/solmara/framework/child-benefit/v1] + evidenceType: https://id.registrystack.org/solmara/evidence-type/poverty-priority/v1 + validitySeconds: 3600 + derivation: { script: derivations/poverty-priority.rhai, parameters: {} } + concepts: [{ id: https://id.registrystack.org/solmara/concept/household-below-poverty-threshold, form: boolean, required: true, constraints: {} }] + fixtures: fixtures/child-benefit.yaml + disclosureGuard: { families: [https://id.registrystack.org/solmara/disclosure-family/sro-child-benefit] } + existenceDisclosure: collapse-unresolved diff --git a/evidence/cells/sro/bundle/fixtures/child-benefit.yaml b/evidence/cells/sro/bundle/fixtures/child-benefit.yaml new file mode 100644 index 0000000..02dab87 --- /dev/null +++ b/evidence/cells/sro/bundle/fixtures/child-benefit.yaml @@ -0,0 +1,42 @@ +fixture: registry.evidence.reference.solmara.sro-child-benefit/v1 +synthetic_only: true +common: + observed_at: '2026-07-14T00:00:00Z' + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000006' } } } + derivationSelectorInputs: {} + extract: | + CREATE TABLE evidence_extract (published_at TEXT NOT NULL, publisher TEXT NOT NULL, extract_id TEXT NOT NULL); + INSERT INTO evidence_extract VALUES ('2026-07-14T00:00:00Z', 'did:web:id.registrystack.org:solmara:authority:sro', 'sro-poverty-2026-07-14'); + CREATE TABLE poverty_evidence (uin TEXT NOT NULL, poverty_band TEXT NOT NULL); + INSERT INTO poverty_evidence VALUES ('1000000006', 'priority'); + INSERT INTO poverty_evidence VALUES ('1000000016', 'standard'); + INSERT INTO poverty_evidence VALUES ('1000000026', 'priority'); + INSERT INTO poverty_evidence VALUES ('1000000026', 'standard'); + expectedRequestParts: { parameters: {} } + expectedTransport: { statement: queries/poverty-evidence.sql } +cases: + - id: positive + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000006' } } } + expected: { lookup: match, derivationRuns: true, signed: true, facts: { uin: '1000000006', poverty_band: priority }, value: true } + - id: negative-standard-band + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000016' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: false } + - id: boundary-priority-band + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000006' } } } + expected: { lookup: match, derivationRuns: true, signed: true, value: true } + - id: missing-record + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000098' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: no-match + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000099' } } } + expected: { lookup: no_match, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: ambiguous-duplicate + selectors: { subject: { profile: solmara-uin-v1, values: { uin: '1000000026' } } } + expected: { lookup: ambiguous, publicProblem: evidence.unavailable, derivationRuns: false, signed: false } + - id: source-failure + sourceFailure: timeout + expected: { publicProblem: source.unavailable, signed: false } + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: { bundle: rejected } +privacyExpectation: { evidenceContains: [https://id.registrystack.org/solmara/concept/household-below-poverty-threshold], evidenceExcludes: [uin, poverty_band], diagnosticsExclude: ['1000000006'] } diff --git a/evidence/cells/sro/bundle/queries/poverty-evidence.sql b/evidence/cells/sro/bundle/queries/poverty-evidence.sql new file mode 100644 index 0000000..097d38f --- /dev/null +++ b/evidence/cells/sro/bundle/queries/poverty-evidence.sql @@ -0,0 +1,4 @@ +SELECT uin, poverty_band +FROM poverty_evidence +WHERE uin = :uin +LIMIT 2; diff --git a/evidence/cells/sro/bundle/schemas/poverty-facts.schema.yaml b/evidence/cells/sro/bundle/schemas/poverty-facts.schema.yaml new file mode 100644 index 0000000..cf6b469 --- /dev/null +++ b/evidence/cells/sro/bundle/schemas/poverty-facts.schema.yaml @@ -0,0 +1,4 @@ +type: object +additionalProperties: false +required: [uin, poverty_band] +properties: { uin: { type: string, minLength: 10, maxLength: 10 }, poverty_band: { type: string, minLength: 1, maxLength: 16 } } diff --git a/evidence/cells/sro/bundle/schemas/poverty-response.schema.yaml b/evidence/cells/sro/bundle/schemas/poverty-response.schema.yaml new file mode 100644 index 0000000..69f4104 --- /dev/null +++ b/evidence/cells/sro/bundle/schemas/poverty-response.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [rows] +properties: + rows: { type: array, maxItems: 2, items: { type: object, additionalProperties: false, required: [uin, poverty_band], properties: { uin: { type: string, minLength: 10, maxLength: 10 }, poverty_band: { type: string, minLength: 1, maxLength: 16 } } } } diff --git a/evidence/cells/sro/runtime.yaml b/evidence/cells/sro/runtime.yaml new file mode 100644 index 0000000..bf15eb8 --- /dev/null +++ b/evidence/cells/sro/runtime.yaml @@ -0,0 +1,9 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/sro/bundle +listener: { bindHost: 172.29.1.23, port: 8080, tlsTermination: operator-controlled-upstream, trustProxyIdentityHeaders: false, maximumRequestBytes: 65536, maximumConcurrentRequests: 64, requestTimeoutMilliseconds: 10000, shutdownGraceMilliseconds: 30000 } +secretProviders: { file: { root: /run/secrets/registry-evidence/sro } } +signer: { kind: transit, unixSocketPath: /run/registry-evidence/sro/transit-proxy.sock, mount: transit, keyName: solmara-evidence-sro, keyVersion: 1, timeoutMilliseconds: 2000 } +auditStorage: { path: /var/lib/registry-evidence/sro/audit/evidence.jsonl, maximumFileBytes: 1073741824 } +outboundTls: { systemRoots: true, trustProfiles: {} } +sourceExtracts: + sro-poverty-extract: { path: /var/lib/registry-evidence/sro/extracts/sro-poverty-20260704T090000Z.sqlite } diff --git a/evidence/mint.yaml b/evidence/mint.yaml new file mode 100644 index 0000000..e86d900 --- /dev/null +++ b/evidence/mint.yaml @@ -0,0 +1,40 @@ +version: 1 +issuer: https://mint.solmara.registrystack.org +listener: + address: 172.29.1.20 + port: 8081 +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/mint.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: solmara-mint + keyVersion: 1 + timeoutMilliseconds: 2000 +secretProviders: + file: + root: /run/secrets/registry-mint +audit: + path: /var/lib/registry-mint/audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyRef: secret:file/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [solmara-runtime] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: https://mint.solmara.registrystack.org/token + maximumLifetimeSeconds: 300 + algorithms: [ES256, RS256] +clients: + directory: clients diff --git a/evidence/scripts/build-cells.py b/evidence/scripts/build-cells.py new file mode 100644 index 0000000..3a65882 --- /dev/null +++ b/evidence/scripts/build-cells.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Build deployable Evidence/Mint config from authored templates and operator keys.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +import yaml +from cryptography.hazmat.primitives.asymmetric import ec, rsa + + +ROOT = Path(__file__).resolve().parents[2] +CELL_ROOT = ROOT / "evidence" / "cells" +CELLS = ("cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") +RELAY_CLIENTS = { + "cra-pension-evidence": ("solmara:relay:cra:death-by-uin", "https://id.registrystack.org/solmara/purpose/pension-payment-review"), + "cra-citizen-evidence": ("solmara:relay:cra:citizen-link-by-uin", "https://id.registrystack.org/solmara/purpose/citizen-self-service"), + "mosd-child-benefit-evidence": ("solmara:relay:mosd:by-uin", "https://id.registrystack.org/solmara/purpose/child-benefit-review"), + "sipf-pension-evidence": ("solmara:relay:sipf:by-pensioner-uin", "https://id.registrystack.org/solmara/purpose/pension-payment-review"), + "sipf-survivor-evidence": ("solmara:relay:sipf:by-spouse-uin", "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"), + "nagdi-voucher-evidence": ("solmara:relay:nagdi:voucher-by-farmer-id", "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"), + "nagdi-livestock-evidence": ("solmara:relay:nagdi:movement-by-farmer-id", "https://id.registrystack.org/solmara/purpose/livestock-movement-control"), + "nia-esignet": ("solmara:relay:nia:esignet-userinfo", "https://id.registrystack.org/solmara/purpose/esignet-identity-verification"), +} +CLIENT_CELLS = { + "cra-pension-evidence": "cra", + "cra-citizen-evidence": "cra", + "mosd-child-benefit-evidence": "mosd-programme", + "sipf-pension-evidence": "sipf", + "sipf-survivor-evidence": "sipf", + "nagdi-voucher-evidence": "nagdi", + "nagdi-livestock-evidence": "nagdi", +} + + +def b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def public_jwk(private_path: Path) -> dict[str, str]: + value = json.loads(private_path.read_text(encoding="utf-8")) + if value.get("kty") == "RSA": + required = {"kty", "alg", "n", "e", "d", "p", "q", "dp", "dq", "qi", "kid"} + if set(value) != required or value.get("alg") != "RS256": + raise ValueError(f"{private_path}: expected an exact private RS256 JWK") + + def decode(member: str) -> int: + encoded = value[member] + return int.from_bytes(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)), "big") + + private = rsa.RSAPrivateNumbers( + p=decode("p"), q=decode("q"), d=decode("d"), dmp1=decode("dp"), + dmq1=decode("dq"), iqmp=decode("qi"), + public_numbers=rsa.RSAPublicNumbers(e=decode("e"), n=decode("n")), + ) + try: + derived = private.private_key().public_key().public_numbers() + except ValueError as exc: + raise ValueError(f"{private_path}: private RS256 JWK members are inconsistent") from exc + result = {"e": b64url(derived.e.to_bytes((derived.e.bit_length() + 7) // 8, "big")), "kty": "RSA", "n": b64url(derived.n.to_bytes((derived.n.bit_length() + 7) // 8, "big"))} + thumbprint = hashlib.sha256(json.dumps(result, separators=(",", ":"), sort_keys=True).encode()).digest() + result["alg"] = "RS256" + result["kid"] = b64url(thumbprint) + if any(value[key] != result[key] for key in ("n", "e", "kid")): + raise ValueError(f"{private_path}: private JWK public members or kid do not match its key") + return result + if set(value) != {"kty", "crv", "alg", "x", "y", "d", "kid"} or value.get("kty") != "EC" or value.get("crv") != "P-256" or value.get("alg") != "ES256": + raise ValueError(f"{private_path}: expected an exact private ES256 P-256 JWK") + private_value = int.from_bytes(base64.urlsafe_b64decode(value["d"] + "=="), "big") + public = ec.derive_private_key(private_value, ec.SECP256R1()).public_key().public_numbers() + result = {"crv": "P-256", "kty": "EC", "x": b64url(public.x.to_bytes(32, "big")), "y": b64url(public.y.to_bytes(32, "big"))} + thumbprint = hashlib.sha256(json.dumps(result, separators=(",", ":"), sort_keys=True).encode()).digest() + result["alg"] = "ES256" + result["kid"] = b64url(thumbprint) + if value["x"] != result["x"] or value["y"] != result["y"] or value["kid"] != result["kid"]: + raise ValueError(f"{private_path}: private JWK public members or kid do not match its scalar") + return result + + +def write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def make_read_only(root: Path) -> None: + for path in sorted(root.rglob("*"), reverse=True): + path.chmod(0o555 if path.is_dir() else 0o444) + root.chmod(0o555) + + +def build(private_root: Path, output: Path, evidence_binary: Path | None) -> None: + if output.exists(): + raise ValueError(f"refusing to overwrite generated output: {output}") + for cell in CELLS: + destination = output / "cells" / cell + shutil.copytree(CELL_ROOT / cell, destination) + cell_public = public_jwk(private_root / cell / "secrets" / "signing.jwk") + config_path = destination / "bundle" / "evidence.yaml" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + config["signing"]["activePublicJwkFile"] = f"public-keys/{cell_public['kid']}.jwk.json" + config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + write_json(destination / "bundle" / "public-keys" / f"{cell_public['kid']}.jwk.json", cell_public) + make_read_only(destination / "bundle") + if evidence_binary: + subprocess.run([str(evidence_binary), "bundle-check", "--bundle", str(destination / "bundle")], check=True) + make_read_only(destination) + + mint_destination = output / "mint" + mint_destination.mkdir(parents=True) + shutil.copy2(ROOT / "evidence" / "mint.yaml", mint_destination / "mint.yaml") + mint_public = public_jwk(private_root / "mint" / "secrets" / "signing.jwk") + mint_config_path = mint_destination / "mint.yaml" + mint_config = yaml.safe_load(mint_config_path.read_text(encoding="utf-8")) + mint_config["signing"]["activePublicJwkFile"] = f"public-keys/{mint_public['kid']}.jwk.json" + mint_config_path.write_text(yaml.safe_dump(mint_config, sort_keys=False), encoding="utf-8") + write_json(mint_destination / "public-keys" / f"{mint_public['kid']}.jwk.json", mint_public) + for client, (scope, purpose) in RELAY_CLIENTS.items(): + registration = { + "clientId": client, + "principal": f"https://id.registrystack.org/solmara/client/{client}", + "authorization": {"scopes": [scope], "claims": {"purpose": purpose}}, + "keys": [public_jwk( + private_root / CLIENT_CELLS[client] / "secrets" / f"{client}-client-key" + if client in CLIENT_CELLS + else private_root / "mint" / "clients" / "nia-esignet-rsa-client-key" + )], + } + path = mint_destination / "clients" / f"{client}.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(registration, sort_keys=False), encoding="utf-8") + application = { + "clientId": "solmara-demo", + "principal": "https://id.registrystack.org/solmara/client/solmara-demo", + "evidenceAudience": "https://id.registrystack.org/solmara/audience/demo-client", + "requesterTags": ["solmara-demo"], + "keys": [public_jwk(private_root / "mint" / "clients" / "solmara-demo-client-key")], + } + application_path = mint_destination / "clients" / "solmara-demo.yaml" + application_path.write_text(yaml.safe_dump(application, sort_keys=False), encoding="utf-8") + make_read_only(mint_destination) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--private-key-root", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--evidence-binary", type=Path) + args = parser.parse_args() + build(args.private_key_root.resolve(), args.output.resolve(), args.evidence_binary) + + +if __name__ == "__main__": + main() diff --git a/evidence/tests/test_cells.py b/evidence/tests/test_cells.py new file mode 100644 index 0000000..067340d --- /dev/null +++ b/evidence/tests/test_cells.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +import yaml +from cryptography.hazmat.primitives.asymmetric import ec, rsa + + +ROOT = Path(__file__).resolve().parents[2] +CELLS = ("cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") +EXPECTED_REQUIREMENTS = { + "https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1": ("https://id.registrystack.org/solmara/evidence-type/cra-child-benefit/v1", "child-benefit-review", 3600), + "https://id.registrystack.org/solmara/requirement/cra-pension-death/v1": ("https://id.registrystack.org/solmara/evidence-type/cra-death-status/v1", "pension-payment-review", 300), + "https://id.registrystack.org/solmara/requirement/cra-citizen-record/v1": ("https://id.registrystack.org/solmara/evidence-type/civil-record-linked/v1", "citizen-self-service", 300), + "https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1": ("https://id.registrystack.org/solmara/evidence-type/population-active/v1", "child-benefit-review", 3600), + "https://id.registrystack.org/solmara/requirement/nia-citizen-status/v1": ("https://id.registrystack.org/solmara/evidence-type/citizen-population-active/v1", "citizen-self-service", 3600), + "https://id.registrystack.org/solmara/requirement/sro-child-benefit/v1": ("https://id.registrystack.org/solmara/evidence-type/poverty-priority/v1", "child-benefit-review", 3600), + "https://id.registrystack.org/solmara/requirement/mosd-child-benefit/v1": ("https://id.registrystack.org/solmara/evidence-type/not-enrolled/v1", "child-benefit-review", 300), + "https://id.registrystack.org/solmara/requirement/sipf-pension-payment/v1": ("https://id.registrystack.org/solmara/evidence-type/pension-payment-active/v1", "pension-payment-review", 300), + "https://id.registrystack.org/solmara/requirement/sipf-survivor-benefit/v1": ("https://id.registrystack.org/solmara/evidence-type/survivor-benefit/v1", "survivor-benefit-determination", 300), + "https://id.registrystack.org/solmara/requirement/nagdi-voucher/v1": ("https://id.registrystack.org/solmara/evidence-type/climate-smart-voucher/v1", "voucher-eligibility-review", 300), + "https://id.registrystack.org/solmara/requirement/nagdi-livestock/v1": ("https://id.registrystack.org/solmara/evidence-type/livestock-movement/v1", "livestock-movement-control", 300), +} +ISSUERS = { + "cra": "did:web:id.registrystack.org:solmara:authority:cra", + "nia": "did:web:id.registrystack.org:solmara:authority:nia", + "sro": "did:web:id.registrystack.org:solmara:authority:sro", + "mosd-programme": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", + "sipf": "did:web:id.registrystack.org:solmara:authority:sipf", + "nagdi": "did:web:id.registrystack.org:solmara:authority:nagdi", +} + + +def load_yaml(path: Path): + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def private_jwk() -> dict[str, str]: + key = ec.generate_private_key(ec.SECP256R1()).private_numbers() + + def encode(value: int) -> str: + return base64.urlsafe_b64encode(value.to_bytes(32, "big")).rstrip(b"=").decode() + + public = {"kty": "EC", "crv": "P-256", "x": encode(key.public_numbers.x), "y": encode(key.public_numbers.y)} + thumbprint = {member: public[member] for member in ("crv", "kty", "x", "y")} + public["alg"] = "ES256" + public["kid"] = base64.urlsafe_b64encode( + hashlib.sha256(json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode()).digest() + ).rstrip(b"=").decode() + public["d"] = encode(key.private_value) + return public + + +def rsa_private_jwk() -> str: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_numbers() + + def encode(value: int) -> str: + return base64.urlsafe_b64encode(value.to_bytes((value.bit_length() + 7) // 8, "big")).rstrip(b"=").decode() + + value = { + "kty": "RSA", "alg": "RS256", "n": encode(key.public_numbers.n), "e": encode(key.public_numbers.e), + "d": encode(key.d), "p": encode(key.p), "q": encode(key.q), "dp": encode(key.dmp1), + "dq": encode(key.dmq1), "qi": encode(key.iqmp), + } + thumbprint = {member: value[member] for member in ("e", "kty", "n")} + value["kid"] = base64.urlsafe_b64encode(hashlib.sha256(json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode()).digest()).rstrip(b"=").decode() + return json.dumps(value) + + +class CellContractsTest(unittest.TestCase): + def configs(self): + return {cell: load_yaml(ROOT / "evidence" / "cells" / cell / "bundle" / "evidence.yaml") for cell in CELLS} + + def test_six_independent_authority_identities_and_runtime_boundaries(self): + configs = self.configs() + self.assertEqual(set(configs), set(CELLS)) + for offset, cell in enumerate(CELLS, 21): + config = configs[cell] + runtime = load_yaml(ROOT / "evidence" / "cells" / cell / "runtime.yaml") + self.assertEqual(config["service"]["providerId"], f"https://id.registrystack.org/solmara/evidence/{cell}") + self.assertEqual(config["issuer"]["id"], ISSUERS[cell]) + self.assertEqual(config["signing"]["algorithm"], "ES256") + self.assertEqual(config["signing"]["activePublicJwkFile"], f"public-keys/{cell}.jwk.json") + self.assertFalse((ROOT / "evidence" / "cells" / cell / "bundle" / f"public-keys/{cell}.jwk.json").exists()) + self.assertEqual(runtime["listener"]["bindHost"], f"172.29.1.{offset}") + self.assertEqual(runtime["signer"]["keyName"], f"solmara-evidence-{cell}") + self.assertNotEqual(runtime["auditStorage"]["path"], "/var/lib/registry-evidence/audit/evidence.jsonl") + + def test_exact_requirement_evidence_type_purpose_and_validity_set(self): + actual = {} + for config in self.configs().values(): + for requirement in config["requirements"]: + actual[requirement["id"]] = (requirement["evidenceType"], requirement["purposes"][0], requirement["validitySeconds"]) + self.assertEqual(actual, EXPECTED_REQUIREMENTS) + + def test_direct_extracts_are_fixed_bounded_and_version_bound(self): + configs = self.configs() + direct = [configs["cra"]["sources"]["cra-child-benefit"], configs["nia"]["sources"]["population-extract"], configs["sro"]["sources"]["poverty-extract"]] + self.assertEqual([source["maximumExtractAgeSeconds"] for source in direct], [86400, 86400, 86400]) + for source in direct: + self.assertEqual(source["transport"], "sqlite-extract") + self.assertEqual(source["request"]["maximumRows"], 2) + self.assertNotIn("authentication", source) + runtime_paths = { + cell: next(iter(load_yaml(ROOT / "evidence" / "cells" / cell / "runtime.yaml")["sourceExtracts"].values()))["path"] + for cell in ("cra", "nia", "sro") + } + self.assertEqual(runtime_paths, { + "cra": "/var/lib/registry-evidence/cra/extracts/cra-birth-20260704T090000Z.sqlite", + "nia": "/var/lib/registry-evidence/nia/extracts/nia-population-20260704T090000Z.sqlite", + "sro": "/var/lib/registry-evidence/sro/extracts/sro-poverty-20260704T090000Z.sqlite", + }) + + def test_relay_sources_use_only_named_v2_lookups_and_declared_unresolved_problem(self): + relay_sources = [] + for config in self.configs().values(): + relay_sources.extend(source for source in config["sources"].values() if source["transport"] == "http-json") + self.assertEqual(len(relay_sources), 7) + for source in relay_sources: + self.assertEqual(source["request"]["method"], "POST") + self.assertRegex(source["request"]["path"], r"^/v2/resources/[^/]+/lookups/[^/]+$") + self.assertEqual(source["request"]["projection"][0].split("/")[1:3], ["data", "domainData"]) + self.assertEqual(source["unresolvedProblem"], {"status": 404, "type": "https://id.registrystack.org/problems/registry-relay/consultation/unresolved", "code": "consultation.unresolved"}) + auth = source["authentication"] + self.assertEqual(auth["kind"], "oauth2-client-credentials") + self.assertEqual(auth["tokenEndpoint"], "https://mint.solmara.registrystack.org/token") + self.assertEqual(auth["clientAssertionAudience"], auth["tokenEndpoint"]) + self.assertEqual(auth["audience"], "solmara-runtime") + self.assertNotIn("clientSecretRef", auth) + + actual = {(source["request"]["path"], source["authentication"]["scope"]) for source in relay_sources} + self.assertEqual(actual, { + ("/v2/resources/civil-person/lookups/death-by-uin", "solmara:relay:cra:death-by-uin"), + ("/v2/resources/civil-person/lookups/citizen-link-by-uin", "solmara:relay:cra:citizen-link-by-uin"), + ("/v2/resources/beneficiary-enrolment/lookups/by-uin", "solmara:relay:mosd:by-uin"), + ("/v2/resources/pension-payment/lookups/by-pensioner-uin", "solmara:relay:sipf:by-pensioner-uin"), + ("/v2/resources/survivor-case/lookups/by-spouse-uin", "solmara:relay:sipf:by-spouse-uin"), + ("/v2/resources/farmer/lookups/voucher-by-farmer-id", "solmara:relay:nagdi:voucher-by-farmer-id"), + ("/v2/resources/livestock-herd/lookups/movement-by-farmer-id", "solmara:relay:nagdi:movement-by-farmer-id"), + }) + + def test_no_retired_v1_or_caller_controlled_purpose_shapes(self): + authored = "\n".join(path.read_text(encoding="utf-8") for path in (ROOT / "evidence" / "cells").rglob("*.*") if path.is_file()) + for prohibited in ("/v1/datasets/", "Data-Purpose", "static-bearer", "static-authorization"): + self.assertNotIn(prohibited, authored) + + def test_relay_prepare_scripts_emit_only_the_approved_nested_selectors(self): + cra = (ROOT / "evidence/cells/cra/bundle/adapters/relay-prepare.rhai").read_text() + mosd = (ROOT / "evidence/cells/mosd-programme/bundle/adapters/relay-prepare.rhai").read_text() + sipf = (ROOT / "evidence/cells/sipf/bundle/adapters/relay-prepare.rhai").read_text() + nagdi = (ROOT / "evidence/cells/nagdi/bundle/adapters/relay-prepare.rhai").read_text() + self.assertIn('body: #{selectors: #{uin:', cra) + self.assertNotIn('deceased: true', cra) + self.assertIn('body: #{selectors: #{uin:', mosd) + self.assertIn('relay_selectors["pensionerUin"] = subject["uin"]', sipf) + self.assertIn('relay_selectors["spouseUin"] = subject["uin"]', sipf) + self.assertIn('body: #{selectors: #{farmerId:', nagdi) + self.assertNotIn("target", cra + mosd + sipf + nagdi) + + def test_builder_injects_public_halves_and_closed_mint_registrations(self): + spec = importlib.util.spec_from_file_location("build_cells", ROOT / "evidence" / "scripts" / "build-cells.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(module) + with tempfile.TemporaryDirectory() as directory: + temp = Path(directory) + private_root = temp / "private" + for cell in CELLS: + path = private_root / cell / "secrets" / "signing.jwk" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(private_jwk()), encoding="utf-8") + mint_signing = private_root / "mint" / "secrets" / "signing.jwk" + mint_signing.parent.mkdir(parents=True) + mint_signing.write_text(json.dumps(private_jwk()), encoding="utf-8") + for client in module.RELAY_CLIENTS: + path = (private_root / module.CLIENT_CELLS[client] / "secrets" / f"{client}-client-key" if client in module.CLIENT_CELLS else private_root / "mint" / "clients" / "nia-esignet-rsa-client-key") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(rsa_private_jwk() if client == "nia-esignet" else json.dumps(private_jwk()), encoding="utf-8") + application_key = private_root / "mint" / "clients" / "solmara-demo-client-key" + application_key.write_text(json.dumps(private_jwk()), encoding="utf-8") + output = temp / "output" + module.build(private_root, output, None) + public_documents = list(output.rglob("*.jwk.json")) + self.assertEqual(len(public_documents), 7) + for path in public_documents: + value = json.loads(path.read_text(encoding="utf-8")) + self.assertNotIn("d", value) + self.assertEqual(len(value["kid"]), 43) + self.assertEqual(path.name, f"{value['kid']}.jwk.json") + registrations = {path.stem: load_yaml(path) for path in (output / "mint" / "clients").glob("*.yaml")} + self.assertEqual(set(registrations), set(module.RELAY_CLIENTS) | {"solmara-demo"}) + application = registrations.pop("solmara-demo") + self.assertEqual(application["evidenceAudience"], "https://id.registrystack.org/solmara/audience/demo-client") + self.assertEqual(application["requesterTags"], ["solmara-demo"]) + for client, registration in registrations.items(): + scope, purpose = module.RELAY_CLIENTS[client] + self.assertEqual(registration["authorization"], {"scopes": [scope], "claims": {"purpose": purpose}}) + + nia_registration = registrations["nia-esignet"] + self.assertEqual(nia_registration["keys"][0]["kty"], "RSA") + self.assertEqual(nia_registration["keys"][0]["alg"], "RS256") + self.assertNotIn("d", nia_registration["keys"][0]) + nia_relay = load_yaml(ROOT / "relays" / "nia" / "registry.yaml") + nia_profile = nia_relay["resources"][0]["operations"]["lookups"][0]["accessProfiles"]["esignet"] + self.assertEqual( + nia_registration["authorization"]["claims"]["purpose"], + nia_profile["access"]["purpose"]["allowed"][0], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/generator/output/checksums.sha256 b/generator/output/checksums.sha256 index 6980158..09b6369 100644 --- a/generator/output/checksums.sha256 +++ b/generator/output/checksums.sha256 @@ -65,22 +65,22 @@ d9e59a69c8168bd3374b375fb3e9897ded4acd74b600309d446e507be2543265 ministries/int 815396c62cb0d8b93d2c928aeb91c9b398318267dd5ff1288180de970d064f8d ministries/interior-civil/fixtures/relationship.csv 5880193db8814db61d0ea548c6fd575fc4a1a80f2bcfb268fb09ddbb85c5702f ministries/interior-civil/manifest/fragment.yaml 8c03c745c9db517d1899a2172f27f7b081e18ed5707ff13697cf21ec03c8c550 ministries/interior-population/crosswalks/person.publicschema.yaml -9b638a0bc384f49e0f03bd786e96104b2ee3ed200462e10586fb400e131cc545 ministries/interior-population/fixtures/001-schema.sql -249b804280da2f2673355b58574e751ec530463947f7e036d9292a1cb6f94c04 ministries/interior-population/fixtures/002-load.sql +47c48c0796ad863ce048eb95b3e736487aa4a38db0a8c642c63a783886e20215 ministries/interior-population/fixtures/001-schema.sql +8dcc7a0e6501f0cbf1c594486a273bcde05911aa6cbe20dcfda99d83b25c79da ministries/interior-population/fixtures/002-load.sql 9c8ba5f60b8de794a2683ed681007965d2cae464e856c50b26e6ec49ed6a0fce ministries/interior-population/fixtures/consent_directive.csv 7f7f5a9a43fda8afc647bbe9bb67b64ee6f4fc1f8d52ba4656ad86a589746109 ministries/interior-population/fixtures/identity_document.csv e27ca0df1c1de91eb71673de25e5f02f48a14d7ac4551daa1bd39e8c887d551b ministries/interior-population/fixtures/population_person.csv 1bb558153f13b9c9ef4b730a0eebff7445e8d967b0945b224128e1d6f6389808 ministries/interior-population/manifest/fragment.yaml 6fe91b758a235881a2951069ad3663878982da0b91996fd8aafe0201e92e8094 ministries/labour-pensions/crosswalks/pension-case.publicschema.yaml -4990226a52462d100b3b3224c680f29a8d6b0a893d44ecf3ccc70162abd56e29 ministries/labour-pensions/fixtures/003-schema.sql -e3fe1d0faccede590348f6c2fb1e04bb08599d6c68f20da803acccc03393937b ministries/labour-pensions/fixtures/004-load.sql -8ff8cefbe323cab01331bac9cc08274ba3df20e6b426a3cfc5a37927bfec103b ministries/labour-pensions/fixtures/pension_case.csv +069c71335871825c38280901a5c64dc41ccce91d11c2c62c919a88899dcde24e ministries/labour-pensions/fixtures/003-schema.sql +b1ff968f1fbdf632bcd57207cd1d6ae724a12ed8967fb17f6b015ed73941c060 ministries/labour-pensions/fixtures/004-load.sql +bec871a255a240ba4077b914be691b1eb5a76e9f4c776e1ddca8737a09f0bd3a ministries/labour-pensions/fixtures/pension_case.csv 52bd856ce77c8747bab41e0de875846bef7e98a4c23eca1bf2616a388f258193 ministries/labour-pensions/fixtures/sipf_contribution_account.csv 98744875ad4080a8d53064a2850b19e299331595aef58ed7bf055bf069cf1187 ministries/labour-pensions/fixtures/sipf_contribution_period.csv 51a3f783312190393a4a8f299a38b3187965a03bc46302dda5e6a78b8915d3e0 ministries/labour-pensions/fixtures/sipf_payment_instruction.csv 47d32a1528cb5bc04c9d97317493e0b91870e5f9799dedc54e7cb4c9730f0a60 ministries/labour-pensions/fixtures/sipf_pension_award.csv d8c446c918e802acc44b813754f87a5f38b93678e669def4d03f2da939026ead ministries/labour-pensions/fixtures/sipf_proof_of_life_check.csv -5185ec453a3e21572a5e3838ac7a2dff0a0be1cf5209ec2dd997ec2ef5a35e3b ministries/labour-pensions/fixtures/sipf_survivor_link.csv +bbc8ffe85cd6c941889eab4f70f5f0e908e57ccd994f6132f3f723aaf855e549 ministries/labour-pensions/fixtures/sipf_survivor_link.csv dbb3bddd608356edd4b2e6dd9a201c535107068b097e58482835863618d16620 ministries/labour-pensions/manifest/fragment.yaml e76e4e8546d47d6b52957c8466056ec113304676804ca717a6b4dd7c6c99b879 ministries/social-development/crosswalks/enrollment.publicschema.yaml 9f844f6668958a8d072d921b7b41ed1b360214058ba97e5f29e602f50a00bf86 ministries/social-development/crosswalks/household.publicschema.yaml diff --git a/generator/solmara_lab/generate.py b/generator/solmara_lab/generate.py index 6b4cf28..55e9cec 100644 --- a/generator/solmara_lab/generate.py +++ b/generator/solmara_lab/generate.py @@ -7,7 +7,7 @@ import random import shutil from dataclasses import dataclass -from datetime import date, datetime, timedelta +from datetime import date, timedelta from pathlib import Path OBSERVED_AT = "2026-07-04T09:00:00Z" @@ -467,6 +467,7 @@ def build_rows(root: Path) -> dict[str, list[dict[str, object]]]: instructions.append(add_meta({"instruction_id": f"SIPF-PI-{idx:06d}", "award_no": award_no, "beneficiary_uin": uins[key], "pay_period_month": "2026-07-01", "amount": "240.00", "currency": "XTS", "payment_status": "released" if key == "deceased_pensioner" else "scheduled", "hold_reason": "none", "released_at": "2026-07-01T10:00:00Z" if key == "deceased_pensioner" else ""}, "SIPF-CORE")) pol.append(add_meta({"check_id": f"SIPF-POL-{idx:06d}", "award_no": award_no, "beneficiary_uin": uins[key], "method": "civil_registry_crosscheck", "result": "deceased_found" if key == "deceased_pensioner" else "confirmed_alive", "checked_date": "2026-06-01", "next_due_date": "2026-06-01" if key == "survivor_waits" else "2026-12-01"}, "SIPF-CORE")) survivor.append(add_meta({"survivor_link_id": "SIPF-SL-000001", "deceased_uin": uins["deceased_pensioner"], "survivor_uin": uins["survivor_spouse"], "relationship": "spouse", "proof_mrn": "MRN-1970-0401-00001", "survivor_award_no": "", "link_status": "verified"}, "SIPF-CORE")) + survivor.append(add_meta({"survivor_link_id": "SIPF-SL-000002", "deceased_uin": uins["survivor_waits"], "survivor_uin": uins["divorced_head"], "relationship": "former_spouse", "proof_mrn": "MRN-2010-0302-00002", "survivor_award_no": "", "link_status": "dissolved"}, "SIPF-CORE")) nagdi = build_nagdi(uins, by_uin, household_ids) return { "population_person": [add_meta(p, "NIA-SOLMARAID") for p in people], @@ -749,8 +750,33 @@ def generate(root: Path) -> None: write_csv(root / "ministries/interior-civil/fixtures" / f"{table}.csv", rows[table]) for table in ["population_person", "identity_document", "consent_directive"]: write_csv(root / "ministries/interior-population/fixtures" / f"{table}.csv", rows[table]) - write_text(root / "ministries/interior-population/fixtures/001-schema.sql", "create table population_person (uin text primary key, person_id text, legacy_nid text, given_name text, family_name text, birth_date date, sex text, district_code text, address_area text, settlement_type text, identity_status text, pending_merge_with_uin text, match_basis text, alive boolean, birth_brn text, updated_at timestamptz, observed_at timestamptz, source_system text);\n") - write_text(root / "ministries/interior-population/fixtures/002-load.sql", "copy population_person from '/docker-entrypoint-initdb.d/population_person.csv' with (format csv, header true);\n") + write_text(root / "ministries/interior-population/fixtures/001-schema.sql", "create table if not exists population_person (uin text primary key, person_id text, legacy_nid text, given_name text, family_name text, birth_date date, sex text, district_code text, address_area text, settlement_type text, identity_status text, pending_merge_with_uin text, match_basis text, alive boolean, birth_brn text, updated_at timestamptz, observed_at timestamptz, source_system text);\n") + write_text(root / "ministries/interior-population/fixtures/002-load.sql", """create temporary table population_person_fixture + (like population_person including defaults); + +copy population_person_fixture from '/docker-entrypoint-initdb.d/population_person.csv' with (format csv, header true); + +insert into population_person +select * from population_person_fixture +on conflict (uin) do update set + person_id = excluded.person_id, + legacy_nid = excluded.legacy_nid, + given_name = excluded.given_name, + family_name = excluded.family_name, + birth_date = excluded.birth_date, + sex = excluded.sex, + district_code = excluded.district_code, + address_area = excluded.address_area, + settlement_type = excluded.settlement_type, + identity_status = excluded.identity_status, + pending_merge_with_uin = excluded.pending_merge_with_uin, + match_basis = excluded.match_basis, + alive = excluded.alive, + birth_brn = excluded.birth_brn, + updated_at = excluded.updated_at, + observed_at = excluded.observed_at, + source_system = excluded.source_system; +""") for table in ["household", "household_member", "socio_economic_profile", "scoring_event", "program", "eligibility_decision", "enrollment", "entitlement", "payment_event", "grievance", "social_registry_household", "child_benefit_household", "programme_mis_enrollment"]: write_csv(root / "ministries/social-development/fixtures" / f"{table}.csv", rows[table]) for table in ["sipf_contribution_account", "sipf_contribution_period", "sipf_pension_award", "sipf_payment_instruction", "sipf_proof_of_life_check", "sipf_survivor_link", "pension_case"]: diff --git a/generator/solmara_lab/publisher.py b/generator/solmara_lab/publisher.py new file mode 100644 index 0000000..bfc06cb --- /dev/null +++ b/generator/solmara_lab/publisher.py @@ -0,0 +1,984 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sqlite3 +import stat +import tempfile +from collections.abc import Callable, Iterable, Sequence +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, NamedTuple + +from .generate import OBSERVED_AT, build_relay_projections, build_rows + +RELAY_DIRECTORY = Path("output/sqlite/relay") +EVIDENCE_DIRECTORY = Path("output/sqlite/evidence") + +RELAY_FILENAMES = { + "cra": "cra.sqlite", + "nia": "nia.sqlite", + "mosd": "mosd.sqlite", + "sipf": "sipf.sqlite", + "nagdi": "nagdi.sqlite", +} + +DEFAULT_EXTRACTS = { + "cra": "cra-birth-20260704T090000Z", + "nia": "nia-population-20260704T090000Z", + "sro": "sro-poverty-20260704T090000Z", +} + +PUBLISHERS = { + "cra": "did:web:id.registrystack.org:solmara:authority:cra", + "nia": "did:web:id.registrystack.org:solmara:authority:nia", + "sro": "did:web:id.registrystack.org:solmara:authority:sro", +} + +EXTRACT_PREFIXES = { + "cra": "cra-birth", + "nia": "nia-population", + "sro": "sro-poverty", +} + +MAX_EXTRACT_AGE_SECONDS = 86_400 + +EXTRACT_TABLES = { + "cra": ( + "birth_evidence", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "birth_date", + "birth_brn", + ), + ), + "nia": ( + "population_evidence", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "identity_status", + "alive", + ), + ), + "sro": ( + "poverty_evidence", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "poverty_band", + ), + ), +} + +_EXTRACT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_RFC3339 = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$" +) + + +class ExtractValidationError(RuntimeError): + """Raised when an immutable extract cannot be trusted for binding.""" + + +class StaleExtractError(ExtractValidationError): + """Raised when an otherwise valid extract is outside its accepted age.""" + + +class ExtractMetadata(NamedTuple): + published_at: str + publisher: str + extract_id: str + + +def _bool(value: object) -> int: + if isinstance(value, bool): + return int(value) + if str(value).lower() == "true": + return 1 + if str(value).lower() == "false": + return 0 + raise ValueError("expected a boolean value") + + +def _revision(record: dict[str, object]) -> str: + encoded = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + return f"rev-{hashlib.sha256(encoded).hexdigest()[:16]}" + + +def _record( + record_id: object, + lifecycle_state: object, + recorded_at: object, + **domain: object, +) -> tuple[object, ...]: + revision_input = { + "record_id": str(record_id), + "lifecycle_state": str(lifecycle_state), + "recorded_at": str(recorded_at), + **domain, + } + return ( + str(record_id), + _revision(revision_input), + str(lifecycle_state), + str(recorded_at), + *domain.values(), + ) + + +def _configure(connection: sqlite3.Connection) -> None: + connection.execute("PRAGMA journal_mode = DELETE") + connection.execute("PRAGMA synchronous = FULL") + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA secure_delete = ON") + connection.execute("PRAGMA user_version = 1") + + +def _finish(connection: sqlite3.Connection) -> None: + connection.commit() + connection.execute("VACUUM") + connection.execute("PRAGMA optimize") + + +def _sidecars(path: Path) -> list[Path]: + candidates = [ + path.with_name(path.name + suffix) + for suffix in ("-journal", "-shm", "-wal") + ] + return [candidate for candidate in candidates if candidate.exists()] + + +def _ensure_no_sidecars(path: Path) -> None: + if _sidecars(path): + raise RuntimeError("SQLite publication left a journal sidecar") + + +def _replace_database( + target: Path, populate: Callable[[sqlite3.Connection], None] +) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp") + if temporary.exists(): + temporary.unlink() + try: + with sqlite3.connect(temporary) as connection: + _configure(connection) + populate(connection) + _finish(connection) + _ensure_no_sidecars(temporary) + os.replace(temporary, target) + _ensure_no_sidecars(target) + finally: + if temporary.exists(): + temporary.unlink() + + +def _create_immutable_database( + target: Path, populate: Callable[[sqlite3.Connection], None] +) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + raise FileExistsError("immutable Evidence extract target already exists") from None + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".tmp", dir=target.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + with sqlite3.connect(temporary) as connection: + _configure(connection) + populate(connection) + _finish(connection) + _ensure_no_sidecars(temporary) + temporary.chmod(stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) + try: + os.link(temporary, target) + except FileExistsError: + raise FileExistsError( + "immutable Evidence extract target already exists" + ) from None + _ensure_no_sidecars(target) + finally: + temporary.unlink(missing_ok=True) + for sidecar in _sidecars(temporary): + sidecar.unlink() + + +def _insert_rows( + connection: sqlite3.Connection, + table: str, + columns: Sequence[str], + rows: Iterable[Sequence[object]], +) -> None: + placeholders = ", ".join("?" for _ in columns) + connection.executemany( + f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders})", + rows, + ) + + +def _publisher_rows(root: Path) -> dict[str, list[dict[str, object]]]: + rows = build_rows(root) + rows.update(build_relay_projections(rows)) + return rows + + +def _publish_cra(connection: sqlite3.Connection, rows: dict[str, list[dict[str, object]]]) -> None: + connection.executescript( + """ + CREATE TABLE civil_person_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + birth_date TEXT NOT NULL, + birth_brn TEXT, + deceased INTEGER NOT NULL CHECK (deceased IN (0, 1)) + ) STRICT; + CREATE VIEW relay_civil_person AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, birth_date, birth_brn, deceased + FROM civil_person_source; + """ + ) + people = {row["uin"]: row for row in rows["civil_person"]} + records = [] + for row in rows["civil_person_projection"]: + person = people[row["uin"]] + records.append( + _record( + person["person_id"], + "deceased" if _bool(row["deceased"]) else "active", + person["observed_at"], + uin=row["uin"], + birth_date=row["birth_date"], + birth_brn=row["birth_brn"] or None, + deceased=_bool(row["deceased"]), + ) + ) + _insert_rows( + connection, + "civil_person_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "birth_date", + "birth_brn", + "deceased", + ), + records, + ) + + +def _publish_nia(connection: sqlite3.Connection, rows: dict[str, list[dict[str, object]]]) -> None: + connection.executescript( + """ + CREATE TABLE population_person_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + legacy_nid TEXT, + given_name TEXT NOT NULL, + family_name TEXT NOT NULL, + sex TEXT NOT NULL, + birth_date TEXT NOT NULL, + identity_status TEXT NOT NULL, + alive INTEGER NOT NULL CHECK (alive IN (0, 1)) + ) STRICT; + CREATE VIEW relay_population_person AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, legacy_nid, given_name, family_name, sex, birth_date, + identity_status, alive + FROM population_person_source; + """ + ) + records = [ + _record( + row["person_id"], + row["identity_status"], + row["updated_at"], + uin=row["uin"], + legacy_nid=row["legacy_nid"] or None, + given_name=row["given_name"], + family_name=row["family_name"], + sex=row["sex"], + birth_date=row["birth_date"], + identity_status=row["identity_status"], + alive=_bool(row["alive"]), + ) + for row in rows["population_person"] + ] + _insert_rows( + connection, + "population_person_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "legacy_nid", + "given_name", + "family_name", + "sex", + "birth_date", + "identity_status", + "alive", + ), + records, + ) + + +def _publish_mosd(connection: sqlite3.Connection, rows: dict[str, list[dict[str, object]]]) -> None: + connection.executescript( + """ + CREATE TABLE beneficiary_enrolment_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + duplicate_flag INTEGER NOT NULL CHECK (duplicate_flag IN (0, 1)) + ) STRICT; + CREATE VIEW relay_beneficiary_enrolment AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, duplicate_flag + FROM beneficiary_enrolment_source; + """ + ) + records = [ + _record( + f"MOSD-ENROL-{row['uin']}", + "active", + OBSERVED_AT, + uin=row["uin"], + duplicate_flag=_bool(row["duplicate_flag"]), + ) + for row in rows["programme_mis_enrollment"] + ] + _insert_rows( + connection, + "beneficiary_enrolment_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "duplicate_flag", + ), + records, + ) + + +def _publish_sipf(connection: sqlite3.Connection, rows: dict[str, list[dict[str, object]]]) -> None: + connection.executescript( + """ + CREATE TABLE pension_case_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + pensioner_uin TEXT NOT NULL UNIQUE, + payment_status TEXT NOT NULL + ) STRICT; + CREATE TABLE survivor_case_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + spouse_uin TEXT NOT NULL UNIQUE, + survivor_eligible INTEGER NOT NULL CHECK (survivor_eligible IN (0, 1)) + ) STRICT; + CREATE VIEW relay_pension_payment AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + pensioner_uin, payment_status + FROM pension_case_source; + CREATE VIEW relay_survivor_case AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + spouse_uin, survivor_eligible + FROM survivor_case_source; + """ + ) + pension_records = [] + survivor_records = [] + for row in rows["pension_case"]: + pension_records.append( + _record( + row["pension_case_id"], + row["pension_status"], + row["observed_at"], + pensioner_uin=row["pensioner_uin"], + payment_status=row["payment_status"], + ) + ) + if row["spouse_uin"]: + survivor_records.append( + _record( + f"{row['pension_case_id']}-SURVIVOR", + "active", + row["observed_at"], + spouse_uin=row["spouse_uin"], + survivor_eligible=_bool(row["survivor_eligible"]), + ) + ) + _insert_rows( + connection, + "pension_case_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "pensioner_uin", + "payment_status", + ), + pension_records, + ) + _insert_rows( + connection, + "survivor_case_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "spouse_uin", + "survivor_eligible", + ), + survivor_records, + ) + + +def _publish_nagdi(connection: sqlite3.Connection, rows: dict[str, list[dict[str, object]]]) -> None: + connection.executescript( + """ + CREATE TABLE farmer_voucher_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + farmer_id TEXT NOT NULL UNIQUE, + farmer_registered INTEGER NOT NULL CHECK (farmer_registered IN (0, 1)), + data_use_authorized INTEGER NOT NULL CHECK (data_use_authorized IN (0, 1)), + active_smallholder_farmer INTEGER NOT NULL CHECK (active_smallholder_farmer IN (0, 1)), + active_farm_parcel INTEGER NOT NULL CHECK (active_farm_parcel IN (0, 1)), + crop_declared_for_season INTEGER NOT NULL CHECK (crop_declared_for_season IN (0, 1)), + district_climate_risk_active INTEGER NOT NULL CHECK (district_climate_risk_active IN (0, 1)), + voucher_entitlement_current INTEGER NOT NULL CHECK (voucher_entitlement_current IN (0, 1)), + voucher_not_redeemed INTEGER NOT NULL CHECK (voucher_not_redeemed IN (0, 1)) + ) STRICT; + CREATE TABLE livestock_movement_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + herd_id TEXT NOT NULL UNIQUE, + farmer_id TEXT NOT NULL, + registered_herd INTEGER NOT NULL CHECK (registered_herd IN (0, 1)), + herd_vaccination_current INTEGER NOT NULL CHECK (herd_vaccination_current IN (0, 1)), + origin_district_not_quarantined_for_species INTEGER NOT NULL CHECK (origin_district_not_quarantined_for_species IN (0, 1)), + destination_district_open INTEGER NOT NULL CHECK (destination_district_open IN (0, 1)), + no_conflicting_open_movement_permit INTEGER NOT NULL CHECK (no_conflicting_open_movement_permit IN (0, 1)) + ) STRICT; + CREATE VIEW relay_farmer_voucher AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + farmer_id, farmer_registered, data_use_authorized, + active_smallholder_farmer, active_farm_parcel, + crop_declared_for_season, district_climate_risk_active, + voucher_entitlement_current, voucher_not_redeemed + FROM farmer_voucher_source; + CREATE VIEW relay_livestock_movement AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + herd_id, farmer_id, registered_herd, + herd_vaccination_current, + origin_district_not_quarantined_for_species, + destination_district_open, + no_conflicting_open_movement_permit + FROM livestock_movement_source; + """ + ) + voucher_records = [ + _record( + f"NAGDI-VOUCHER-{row['farmer_id']}", + "active", + OBSERVED_AT, + farmer_id=row["farmer_id"], + farmer_registered=_bool(row["farmer_registered"]), + data_use_authorized=_bool(row["data_use_authorized"]), + active_smallholder_farmer=_bool(row["active_smallholder_farmer"]), + active_farm_parcel=_bool(row["active_farm_parcel"]), + crop_declared_for_season=_bool(row["crop_declared_for_season"]), + district_climate_risk_active=_bool(row["district_climate_risk_active"]), + voucher_entitlement_current=_bool(row["voucher_entitlement_current"]), + voucher_not_redeemed=_bool(row["voucher_not_redeemed"]), + ) + for row in rows["farmer_voucher"] + ] + movement_records = [ + _record( + f"NAGDI-MOVEMENT-{row['herd_id']}", + "active", + OBSERVED_AT, + herd_id=row["herd_id"], + farmer_id=row["farmer_id"], + registered_herd=_bool(row["registered_herd"]), + herd_vaccination_current=_bool(row["herd_vaccination_current"]), + origin_district_not_quarantined_for_species=_bool( + row["origin_district_not_quarantined_for_species"] + ), + destination_district_open=_bool(row["destination_district_open"]), + no_conflicting_open_movement_permit=_bool( + row["no_conflicting_open_movement_permit"] + ), + ) + for row in rows["livestock_movement"] + ] + _insert_rows( + connection, + "farmer_voucher_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "farmer_id", + "farmer_registered", + "data_use_authorized", + "active_smallholder_farmer", + "active_farm_parcel", + "crop_declared_for_season", + "district_climate_risk_active", + "voucher_entitlement_current", + "voucher_not_redeemed", + ), + voucher_records, + ) + _insert_rows( + connection, + "livestock_movement_source", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "herd_id", + "farmer_id", + "registered_herd", + "herd_vaccination_current", + "origin_district_not_quarantined_for_species", + "destination_district_open", + "no_conflicting_open_movement_permit", + ), + movement_records, + ) + + +_RELAY_PUBLISHERS: dict[ + str, Callable[[sqlite3.Connection, dict[str, list[dict[str, object]]]], None] +] = { + "cra": _publish_cra, + "nia": _publish_nia, + "mosd": _publish_mosd, + "sipf": _publish_sipf, + "nagdi": _publish_nagdi, +} + + +def publish_relay_sources(root: Path) -> dict[str, Path]: + root = root.resolve() + rows = _publisher_rows(root) + published = {} + for authority, filename in RELAY_FILENAMES.items(): + target = root / RELAY_DIRECTORY / filename + populate = _RELAY_PUBLISHERS[authority] + _replace_database(target, lambda connection, p=populate: p(connection, rows)) + published[authority] = target + return published + + +def _extract_rows( + authority: str, rows: dict[str, list[dict[str, object]]] +) -> tuple[str, tuple[str, ...], list[tuple[object, ...]]]: + if authority == "cra": + people = {row["uin"]: row for row in rows["civil_person"]} + records = [] + for row in rows["civil_person_projection"]: + person = people[row["uin"]] + records.append( + _record( + person["person_id"], + "registered" if row["birth_brn"] else "unregistered", + person["observed_at"], + uin=row["uin"], + birth_date=row["birth_date"], + birth_brn=row["birth_brn"] or None, + ) + ) + return ( + "birth_evidence", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "birth_date", + "birth_brn", + ), + records, + ) + if authority == "nia": + records = [ + _record( + row["person_id"], + row["identity_status"], + row["updated_at"], + uin=row["uin"], + identity_status=row["identity_status"], + alive=_bool(row["alive"]), + ) + for row in rows["population_person"] + ] + return ( + "population_evidence", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "identity_status", + "alive", + ), + records, + ) + if authority == "sro": + records = [ + _record( + f"SRO-POVERTY-{row['uin']}", + "current", + OBSERVED_AT, + uin=row["uin"], + poverty_band=row["poverty_band"], + ) + for row in rows["child_benefit_household"] + ] + return ( + "poverty_evidence", + ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "poverty_band", + ), + records, + ) + raise ValueError("authority must be one of: cra, nia, sro") + + +def _create_extract_table( + connection: sqlite3.Connection, table: str, columns: Sequence[str] +) -> None: + types = { + "alive": "INTEGER NOT NULL CHECK (alive IN (0, 1))", + } + nullable = {"birth_brn"} + definitions = [] + for column in columns: + if column == "record_id": + definition = "TEXT PRIMARY KEY" + elif column in types: + definition = types[column] + elif column in nullable: + definition = "TEXT" + else: + definition = "TEXT NOT NULL" + definitions.append(f"{column} {definition}") + connection.execute(f"CREATE TABLE {table} ({', '.join(definitions)}) STRICT") + + +def _validate_extract_id(extract_id: str) -> None: + if not _EXTRACT_ID.fullmatch(extract_id): + raise ValueError("extract_id must be a filename-safe identifier") + + +def _validate_published_at(published_at: str) -> None: + if not _RFC3339.fullmatch(published_at): + raise ValueError("published_at must be an RFC 3339 timestamp") + try: + parsed = datetime.fromisoformat(published_at) + except ValueError: + raise ValueError("published_at must be an RFC 3339 timestamp") from None + if parsed.utcoffset() is None: + raise ValueError("published_at must be an RFC 3339 timestamp") + + +def _published_datetime(published_at: str) -> datetime: + _validate_published_at(published_at) + return datetime.fromisoformat(published_at).astimezone(UTC) + + +def canonical_published_at(published_at: str) -> str: + """Return one stable UTC representation for an explicit publication time.""" + + parsed = _published_datetime(published_at) + timespec = "microseconds" if parsed.microsecond else "seconds" + return parsed.isoformat(timespec=timespec).replace("+00:00", "Z") + + +def timestamped_extract_id(authority: str, published_at: str) -> str: + """Derive a deterministic immutable extract identifier from its authority and time.""" + + if authority not in EXTRACT_PREFIXES: + raise ValueError("authority must be one of: cra, nia, sro") + parsed = _published_datetime(published_at) + timestamp = parsed.strftime("%Y%m%dT%H%M%S") + if parsed.microsecond: + timestamp += f"{parsed.microsecond:06d}" + return f"{EXTRACT_PREFIXES[authority]}-{timestamp}Z" + + +def _database_uri(path: Path) -> str: + return path.resolve().as_uri() + "?mode=ro&immutable=1" + + +def validate_extract( + path: Path, + authority: str, + *, + observed_at: str, + expected_extract_id: str | None = None, + expected_published_at: str | None = None, + maximum_age_seconds: int = MAX_EXTRACT_AGE_SECONDS, +) -> ExtractMetadata: + """Validate the immutable file, exact metadata, schema, and freshness.""" + + if authority not in PUBLISHERS: + raise ValueError("authority must be one of: cra, nia, sro") + if maximum_age_seconds < 0: + raise ValueError("maximum_age_seconds must not be negative") + observed = _published_datetime(observed_at) + path = path.absolute() + if path.is_symlink() or not path.is_file(): + raise ExtractValidationError("extract is not an immutable regular file") + if stat.S_IMODE(path.stat().st_mode) & 0o222: + raise ExtractValidationError("extract has a writable mode") + if path.suffix != ".sqlite": + raise ExtractValidationError("extract filename does not match its binding") + bound_extract_id = path.stem + try: + _validate_extract_id(bound_extract_id) + except ValueError: + raise ExtractValidationError( + "extract filename does not match its binding" + ) from None + if expected_extract_id is not None and bound_extract_id != expected_extract_id: + raise ExtractValidationError("extract filename does not match its binding") + + try: + with sqlite3.connect(_database_uri(path), uri=True) as connection: + if connection.execute("PRAGMA quick_check").fetchall() != [("ok",)]: + raise ExtractValidationError("extract integrity check failed") + metadata_columns = tuple( + row[1] + for row in connection.execute("PRAGMA table_info(evidence_extract)") + ) + if metadata_columns != ("published_at", "publisher", "extract_id"): + raise ExtractValidationError("extract metadata schema is invalid") + metadata_rows = connection.execute( + "SELECT published_at, publisher, extract_id FROM evidence_extract" + ).fetchall() + if len(metadata_rows) != 1: + raise ExtractValidationError("extract metadata cardinality is invalid") + published_at, publisher, extract_id = metadata_rows[0] + table, expected_columns = EXTRACT_TABLES[authority] + actual_columns = tuple( + row[1] for row in connection.execute(f"PRAGMA table_info({table})") + ) + if actual_columns != expected_columns: + raise ExtractValidationError("extract source schema is invalid") + except ExtractValidationError: + raise + except (OSError, sqlite3.Error): + raise ExtractValidationError("extract cannot be validated") from None + + if ( + not isinstance(published_at, str) + or not isinstance(publisher, str) + or not isinstance(extract_id, str) + ): + raise ExtractValidationError("extract metadata types are invalid") + if publisher != PUBLISHERS[authority] or extract_id != bound_extract_id: + raise ExtractValidationError("extract metadata does not match its binding") + try: + published = _published_datetime(published_at) + except ValueError: + raise ExtractValidationError("extract publication time is invalid") from None + if expected_published_at is not None: + try: + expected_published = canonical_published_at(expected_published_at) + except ValueError: + raise ValueError("expected_published_at must be an RFC 3339 timestamp") from None + if canonical_published_at(published_at) != expected_published: + raise ExtractValidationError("extract metadata does not match its binding") + age = observed - published + if age < timedelta(0): + raise ExtractValidationError("extract publication time is in the future") + if age > timedelta(seconds=maximum_age_seconds): + raise StaleExtractError("extract is outside its accepted age") + return ExtractMetadata(published_at, publisher, extract_id) + + +def extract_path(root: Path, extract_id: str) -> Path: + _validate_extract_id(extract_id) + return root.resolve() / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + + +def publish_extract( + root: Path, authority: str, published_at: str, extract_id: str +) -> Path: + if authority not in PUBLISHERS: + raise ValueError("authority must be one of: cra, nia, sro") + _validate_published_at(published_at) + target = extract_path(root, extract_id) + rows = _publisher_rows(root.resolve()) + table, columns, records = _extract_rows(authority, rows) + + def populate(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE evidence_extract ( + published_at TEXT NOT NULL, + publisher TEXT NOT NULL, + extract_id TEXT NOT NULL + ) STRICT + """ + ) + connection.execute( + "INSERT INTO evidence_extract VALUES (?, ?, ?)", + (published_at, PUBLISHERS[authority], extract_id), + ) + _create_extract_table(connection, table, columns) + _insert_rows(connection, table, columns, records) + + _create_immutable_database(target, populate) + return target + + +def publish_all(root: Path) -> dict[str, Path]: + root = root.resolve() + extract_targets = { + authority: extract_path(root, extract_id) + for authority, extract_id in DEFAULT_EXTRACTS.items() + } + existing = [path for path in extract_targets.values() if path.exists()] + if existing: + raise FileExistsError("an immutable Evidence extract target already exists") + published = publish_relay_sources(root) + for authority, extract_id in DEFAULT_EXTRACTS.items(): + published[f"{authority}_extract"] = publish_extract( + root, authority, OBSERVED_AT, extract_id + ) + return published + + +def mutate_mosd_state( + database: Path, uin: str, duplicate_flag: bool, recorded_at: str +) -> None: + database = database.resolve() + before = database.stat() + with sqlite3.connect(database) as connection: + _configure(connection) + current = connection.execute( + "SELECT record_id, lifecycle_state FROM beneficiary_enrolment_source WHERE uin = ?", + (uin,), + ).fetchone() + if current is None: + raise LookupError("MoSD enrolment record was not found") + revision_input: dict[str, Any] = { + "record_id": current[0], + "lifecycle_state": current[1], + "recorded_at": recorded_at, + "uin": uin, + "duplicate_flag": int(duplicate_flag), + } + connection.execute( + """ + UPDATE beneficiary_enrolment_source + SET duplicate_flag = ?, record_revision = ?, recorded_at = ? + WHERE uin = ? + """, + (int(duplicate_flag), _revision(revision_input), recorded_at, uin), + ) + connection.commit() + after = database.stat() + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise RuntimeError("MoSD publication replaced the live database path") + _ensure_no_sidecars(database) + + +def _parse_bool(value: str) -> bool: + if value == "true": + return True + if value == "false": + return False + raise argparse.ArgumentTypeError("expected true or false") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Publish Solmara SQLite sources") + commands = parser.add_subparsers(dest="command", required=True) + default_root = Path(__file__).resolve().parents[2] + + publish_all_command = commands.add_parser("publish-all") + publish_all_command.add_argument("--root", type=Path, default=default_root) + + extract = commands.add_parser("publish-extract") + extract.add_argument("--root", type=Path, default=default_root) + extract.add_argument("--authority", choices=sorted(PUBLISHERS), required=True) + extract.add_argument("--published-at", required=True) + extract.add_argument("--extract-id", required=True) + + mutation = commands.add_parser("mutate-mosd") + mutation.add_argument("--root", type=Path, default=default_root) + mutation.add_argument("--database", type=Path) + mutation.add_argument("--uin", required=True) + mutation.add_argument("--duplicate-flag", type=_parse_bool, required=True) + mutation.add_argument("--recorded-at", required=True) + + args = parser.parse_args() + root = args.root.resolve() + if args.command == "publish-all": + publish_all(root) + elif args.command == "publish-extract": + publish_extract(root, args.authority, args.published_at, args.extract_id) + else: + database = args.database or root / RELAY_DIRECTORY / RELAY_FILENAMES["mosd"] + mutate_mosd_state(database, args.uin, args.duplicate_flag, args.recorded_at) + + +if __name__ == "__main__": + main() diff --git a/generator/tests/test_generator.py b/generator/tests/test_generator.py index 04b811c..eabda09 100644 --- a/generator/tests/test_generator.py +++ b/generator/tests/test_generator.py @@ -261,6 +261,12 @@ def test_relay_projection_edges(self) -> None: } self.assertEqual(pensions["2300109568"]["payment_status"], "active") self.assertEqual(pensions["2300109568"]["survivor_eligible"], "true") + survivor_cases = { + row["spouse_uin"]: row + for row in pensions.values() + if row["spouse_uin"] + } + self.assertEqual(survivor_cases["2300146081"]["survivor_eligible"], "false") vouchers = { row["farmer_id"]: row diff --git a/generator/tests/test_publisher.py b/generator/tests/test_publisher.py new file mode 100644 index 0000000..54f84b5 --- /dev/null +++ b/generator/tests/test_publisher.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import hashlib +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from solmara_lab.generate import OBSERVED_AT +from solmara_lab.publisher import ( + DEFAULT_EXTRACTS, + EVIDENCE_DIRECTORY, + PUBLISHERS, + RELAY_DIRECTORY, + RELAY_FILENAMES, + ExtractValidationError, + StaleExtractError, + canonical_published_at, + mutate_mosd_state, + publish_all, + publish_extract, + publish_relay_sources, + timestamped_extract_id, + validate_extract, +) + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def query(path: Path, statement: str, parameters: tuple[object, ...] = ()) -> list[tuple]: + with sqlite3.connect(path) as connection: + return connection.execute(statement, parameters).fetchall() + + +def columns(path: Path, relation: str) -> list[str]: + return [row[1] for row in query(path, f"PRAGMA table_info({relation})")] + + +def published_files(root: Path) -> list[Path]: + return sorted((root / "output/sqlite").rglob("*.sqlite")) + + +class PublisherTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.published = publish_all(self.root) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_repeatable_schema_content_and_bytes(self) -> None: + relay_paths = sorted((self.root / RELAY_DIRECTORY).glob("*.sqlite")) + before_republish = {path: digest(path) for path in relay_paths} + publish_relay_sources(self.root) + self.assertEqual( + {path: digest(path) for path in relay_paths}, before_republish + ) + with tempfile.TemporaryDirectory() as second_directory: + second = Path(second_directory) + publish_all(second) + first_files = published_files(self.root) + second_files = published_files(second) + self.assertEqual( + [path.relative_to(self.root) for path in first_files], + [path.relative_to(second) for path in second_files], + ) + self.assertEqual( + [digest(path) for path in first_files], + [digest(path) for path in second_files], + ) + + def test_relay_views_have_stable_record_fields_and_minimal_domains(self) -> None: + required = [ + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + ] + expected = { + "cra": { + "relay_civil_person": required + + ["uin", "birth_date", "birth_brn", "deceased"] + }, + "nia": { + "relay_population_person": required + + [ + "uin", + "legacy_nid", + "given_name", + "family_name", + "sex", + "birth_date", + "identity_status", + "alive", + ] + }, + "mosd": { + "relay_beneficiary_enrolment": required + ["uin", "duplicate_flag"] + }, + "sipf": { + "relay_pension_payment": required + ["pensioner_uin", "payment_status"], + "relay_survivor_case": required + ["spouse_uin", "survivor_eligible"], + }, + "nagdi": { + "relay_farmer_voucher": required + + [ + "farmer_id", + "farmer_registered", + "data_use_authorized", + "active_smallholder_farmer", + "active_farm_parcel", + "crop_declared_for_season", + "district_climate_risk_active", + "voucher_entitlement_current", + "voucher_not_redeemed", + ], + "relay_livestock_movement": required + + [ + "herd_id", + "farmer_id", + "registered_herd", + "herd_vaccination_current", + "origin_district_not_quarantined_for_species", + "destination_district_open", + "no_conflicting_open_movement_permit", + ], + }, + } + for authority, relations in expected.items(): + path = self.root / RELAY_DIRECTORY / RELAY_FILENAMES[authority] + for relation, expected_columns in relations.items(): + self.assertEqual(columns(path, relation), expected_columns) + record_fields = query( + path, + f"SELECT record_id, record_revision, lifecycle_state, recorded_at FROM {relation}", + ) + self.assertTrue(record_fields) + self.assertTrue(all(all(value for value in row) for row in record_fields)) + + def test_expected_positive_and_control_rows_are_preserved(self) -> None: + cra = self.root / RELAY_DIRECTORY / RELAY_FILENAMES["cra"] + self.assertEqual( + query( + cra, + "SELECT birth_brn, deceased FROM relay_civil_person WHERE uin = ?", + ("2300010248",), + ), + [("BRN-2022-0101-00001", 0)], + ) + self.assertEqual( + query( + cra, + "SELECT deceased FROM relay_civil_person WHERE uin = ?", + ("2300109568",), + ), + [(1,)], + ) + + nia = self.root / RELAY_DIRECTORY / RELAY_FILENAMES["nia"] + self.assertEqual( + query( + nia, + "SELECT identity_status, alive, recorded_at FROM relay_population_person WHERE uin = ?", + ("2300127827",), + ), + [("active", 1, "2026-07-01T08:00:00Z")], + ) + self.assertEqual( + query( + nia, + "SELECT identity_status, alive FROM relay_population_person WHERE uin = ?", + ("2300109568",), + ), + [("deceased", 0)], + ) + + mosd = self.root / RELAY_DIRECTORY / RELAY_FILENAMES["mosd"] + self.assertEqual( + query( + mosd, + "SELECT uin, duplicate_flag FROM relay_beneficiary_enrolment WHERE uin IN (?, ?) ORDER BY uin", + ("2300010248", "2300054788"), + ), + [("2300010248", 0), ("2300054788", 1)], + ) + + sipf = self.root / RELAY_DIRECTORY / RELAY_FILENAMES["sipf"] + self.assertEqual( + query( + sipf, + "SELECT payment_status FROM relay_pension_payment WHERE pensioner_uin = ?", + ("2300109568",), + ), + [("active",)], + ) + self.assertEqual( + query( + sipf, + "SELECT survivor_eligible FROM relay_survivor_case WHERE spouse_uin = ?", + ("2300146081",), + ), + [(0,)], + ) + + nagdi = self.root / RELAY_DIRECTORY / RELAY_FILENAMES["nagdi"] + self.assertEqual( + query( + nagdi, + "SELECT data_use_authorized FROM relay_farmer_voucher WHERE farmer_id = ?", + ("FR-1002",), + ), + [(0,)], + ) + self.assertEqual( + query( + nagdi, + "SELECT origin_district_not_quarantined_for_species FROM relay_livestock_movement WHERE farmer_id = ?", + ("FR-1004",), + ), + [(0,)], + ) + + def test_extract_metadata_is_exact_and_no_sidecars_remain(self) -> None: + table_by_authority = { + "cra": "birth_evidence", + "nia": "population_evidence", + "sro": "poverty_evidence", + } + for authority, extract_id in DEFAULT_EXTRACTS.items(): + path = self.root / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + self.assertEqual( + query(path, "SELECT published_at, publisher, extract_id FROM evidence_extract"), + [(OBSERVED_AT, PUBLISHERS[authority], extract_id)], + ) + self.assertEqual( + query(path, "SELECT count(*) FROM evidence_extract"), [(1,)] + ) + self.assertTrue(query(path, f"SELECT record_id FROM {table_by_authority[authority]} LIMIT 1")) + for path in published_files(self.root): + for suffix in ("-journal", "-shm", "-wal"): + self.assertFalse(path.with_name(path.name + suffix).exists()) + for extract_id in DEFAULT_EXTRACTS.values(): + path = self.root / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o444) + + def test_extract_tables_have_only_the_required_columns(self) -> None: + required = [ + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + ] + expected = { + "cra": ("birth_evidence", required + ["uin", "birth_date", "birth_brn"]), + "nia": ( + "population_evidence", + required + + [ + "uin", + "identity_status", + "alive", + ], + ), + "sro": ("poverty_evidence", required + ["uin", "poverty_band"]), + } + for authority, (table, expected_columns) in expected.items(): + extract_id = DEFAULT_EXTRACTS[authority] + path = self.root / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + self.assertEqual(columns(path, table), expected_columns) + + def test_extract_rows_keep_birth_population_and_poverty_story_outcomes(self) -> None: + cra = self.root / EVIDENCE_DIRECTORY / f"{DEFAULT_EXTRACTS['cra']}.sqlite" + self.assertEqual( + query( + cra, + "SELECT birth_brn, lifecycle_state FROM birth_evidence WHERE uin = ?", + ("2300010248",), + ), + [("BRN-2022-0101-00001", "registered")], + ) + self.assertEqual( + query( + cra, + "SELECT birth_date, birth_brn, lifecycle_state FROM birth_evidence WHERE uin = ?", + ("2300073046",), + ), + [("2020-05-18", None, "unregistered")], + ) + self.assertEqual( + query( + cra, + "SELECT birth_date, birth_brn, lifecycle_state FROM birth_evidence WHERE uin = ?", + ("2300091305",), + ), + [("2019-12-12", "BRN-2019-0203-00010", "registered")], + ) + + nia = self.root / EVIDENCE_DIRECTORY / f"{DEFAULT_EXTRACTS['nia']}.sqlite" + self.assertEqual( + query( + nia, + "SELECT identity_status, alive FROM population_evidence WHERE uin = ?", + ("2300109568",), + ), + [("deceased", 0)], + ) + + sro = self.root / EVIDENCE_DIRECTORY / f"{DEFAULT_EXTRACTS['sro']}.sqlite" + self.assertEqual( + query( + sro, + "SELECT uin, poverty_band FROM poverty_evidence WHERE uin IN (?, ?) ORDER BY uin", + ("2300010248", "2300036523"), + ), + [("2300010248", "priority"), ("2300036523", "not_eligible")], + ) + + def test_immutable_extract_refuses_an_existing_target(self) -> None: + extract_id = "sro-poverty-20260705T090000Z" + publish_extract(self.root, "sro", "2026-07-05T09:00:00Z", extract_id) + with self.assertRaises(FileExistsError): + publish_extract(self.root, "sro", "2026-07-05T09:00:00Z", extract_id) + + def test_extract_refuses_a_non_rfc3339_publication_time(self) -> None: + for suffix, published_at in ( + ("syntax", "2026-07-05 09:00:00"), + ("calendar", "2026-02-30T09:00:00Z"), + ): + with self.subTest(published_at=published_at): + extract_id = f"sro-poverty-invalid-{suffix}" + with self.assertRaisesRegex(ValueError, "RFC 3339"): + publish_extract(self.root, "sro", published_at, extract_id) + self.assertFalse( + (self.root / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite").exists() + ) + + def test_a_second_sro_publication_uses_a_new_filename(self) -> None: + original = self.root / EVIDENCE_DIRECTORY / f"{DEFAULT_EXTRACTS['sro']}.sqlite" + original_digest = digest(original) + extract_id = "sro-poverty-20260705T090000Z" + second = publish_extract( + self.root, "sro", "2026-07-05T09:00:00Z", extract_id + ) + self.assertNotEqual(original, second) + self.assertEqual(digest(original), original_digest) + self.assertEqual( + query(second, "SELECT published_at, publisher, extract_id FROM evidence_extract"), + [("2026-07-05T09:00:00Z", PUBLISHERS["sro"], extract_id)], + ) + + def test_timestamped_extract_ids_are_deterministic_for_the_instant(self) -> None: + self.assertEqual( + canonical_published_at("2026-08-12T16:34:56.123456+07:00"), + "2026-08-12T09:34:56.123456Z", + ) + self.assertEqual( + timestamped_extract_id("cra", "2026-08-12T16:34:56.123456+07:00"), + "cra-birth-20260812T093456123456Z", + ) + self.assertEqual( + timestamped_extract_id("cra", "2026-08-12T09:34:56.123456Z"), + "cra-birth-20260812T093456123456Z", + ) + + def test_extract_validation_binds_exact_metadata_schema_and_age(self) -> None: + extract_id = "sro-poverty-20260812T090000Z" + path = publish_extract( + self.root, "sro", "2026-08-12T09:00:00Z", extract_id + ) + metadata = validate_extract( + path, + "sro", + observed_at="2026-08-12T10:00:00Z", + expected_extract_id=extract_id, + expected_published_at="2026-08-12T09:00:00Z", + ) + self.assertEqual(metadata.extract_id, extract_id) + with self.assertRaisesRegex(StaleExtractError, "accepted age"): + validate_extract( + path, + "sro", + observed_at="2026-08-13T09:00:01Z", + expected_extract_id=extract_id, + ) + with self.assertRaisesRegex(ExtractValidationError, "future"): + validate_extract( + path, + "sro", + observed_at="2026-08-12T08:59:59Z", + expected_extract_id=extract_id, + ) + + path.chmod(0o644) + try: + with self.assertRaisesRegex(ExtractValidationError, "writable"): + validate_extract( + path, + "sro", + observed_at="2026-08-12T10:00:00Z", + expected_extract_id=extract_id, + ) + finally: + path.chmod(0o444) + + def test_extract_validation_refuses_metadata_mismatch_and_extra_columns(self) -> None: + cases = ("publisher", "extract_id", "column", "row") + for index, mismatch in enumerate(cases): + with self.subTest(mismatch=mismatch): + extract_id = f"sro-poverty-20260812T10000{index}Z" + path = publish_extract( + self.root, "sro", "2026-08-12T10:00:00Z", extract_id + ) + path.chmod(0o644) + with sqlite3.connect(path) as connection: + if mismatch == "publisher": + connection.execute( + "UPDATE evidence_extract SET publisher = ?", + (PUBLISHERS["cra"],), + ) + elif mismatch == "extract_id": + connection.execute( + "UPDATE evidence_extract SET extract_id = ?", + ("metadata-does-not-match",), + ) + elif mismatch == "column": + connection.execute( + "ALTER TABLE evidence_extract ADD COLUMN unexpected TEXT" + ) + else: + connection.execute( + "INSERT INTO evidence_extract VALUES (?, ?, ?)", + ( + "2026-08-12T10:00:00Z", + PUBLISHERS["sro"], + extract_id, + ), + ) + connection.commit() + path.chmod(0o444) + with self.assertRaises(ExtractValidationError): + validate_extract( + path, + "sro", + observed_at="2026-08-12T10:00:00Z", + expected_extract_id=extract_id, + ) + + def test_mosd_mutation_changes_only_the_live_source_in_place(self) -> None: + files = published_files(self.root) + before_digests = {path: digest(path) for path in files} + mosd = self.root / RELAY_DIRECTORY / RELAY_FILENAMES["mosd"] + before_inode = mosd.stat().st_ino + before_row = query( + mosd, + "SELECT * FROM relay_beneficiary_enrolment WHERE uin = ?", + ("2300010248",), + )[0] + + mutate_mosd_state(mosd, "2300010248", True, "2026-07-05T10:00:00Z") + + after_row = query( + mosd, + "SELECT * FROM relay_beneficiary_enrolment WHERE uin = ?", + ("2300010248",), + )[0] + changed = {path for path in files if digest(path) != before_digests[path]} + self.assertEqual(changed, {mosd}) + self.assertEqual(mosd.stat().st_ino, before_inode) + self.assertEqual(before_row[0], after_row[0]) + self.assertNotEqual(before_row[1], after_row[1]) + self.assertEqual(after_row[2], "active") + self.assertEqual(after_row[3], "2026-07-05T10:00:00Z") + self.assertEqual(after_row[4], "2300010248") + self.assertEqual((before_row[5], after_row[5]), (0, 1)) + for suffix in ("-journal", "-shm", "-wal"): + self.assertFalse(mosd.with_name(mosd.name + suffix).exists()) + + def test_module_cli_publishes_and_mutates(self) -> None: + with tempfile.TemporaryDirectory() as cli_directory: + cli_root = Path(cli_directory) + publish = subprocess.run( + [ + sys.executable, + "-m", + "solmara_lab.publisher", + "publish-all", + "--root", + str(cli_root), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(publish.returncode, 0, publish.stderr) + mosd = cli_root / RELAY_DIRECTORY / RELAY_FILENAMES["mosd"] + mutation = subprocess.run( + [ + sys.executable, + "-m", + "solmara_lab.publisher", + "mutate-mosd", + "--root", + str(cli_root), + "--uin", + "2300010248", + "--duplicate-flag", + "true", + "--recorded-at", + "2026-07-05T10:00:00Z", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(mutation.returncode, 0, mutation.stderr) + self.assertEqual( + query( + mosd, + "SELECT duplicate_flag, recorded_at FROM relay_beneficiary_enrolment WHERE uin = ?", + ("2300010248",), + ), + [(1, "2026-07-05T10:00:00Z")], + ) + extract_id = "sro-poverty-20260705T110000Z" + extract = subprocess.run( + [ + sys.executable, + "-m", + "solmara_lab.publisher", + "publish-extract", + "--root", + str(cli_root), + "--authority", + "sro", + "--published-at", + "2026-07-05T11:00:00Z", + "--extract-id", + extract_id, + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(extract.returncode, 0, extract.stderr) + extract_path = cli_root / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + self.assertEqual( + query(extract_path, "SELECT published_at, publisher, extract_id FROM evidence_extract"), + [("2026-07-05T11:00:00Z", PUBLISHERS["sro"], extract_id)], + ) + self.assertEqual(stat.S_IMODE(extract_path.stat().st_mode), 0o444) + + +if __name__ == "__main__": + unittest.main() diff --git a/home/Dockerfile b/home/Dockerfile index 325ef19..db3090f 100644 --- a/home/Dockerfile +++ b/home/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS build +FROM node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build WORKDIR /app RUN corepack enable @@ -21,7 +21,7 @@ ENV SOLMARA_LAB_ROOT=/app ENV CI=true RUN pnpm build -FROM node:22-alpine +FROM node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 WORKDIR /app/home ENV NODE_ENV=production diff --git a/home/e2e/home.test.ts b/home/e2e/home.test.ts index 1b841d3..78261c3 100644 --- a/home/e2e/home.test.ts +++ b/home/e2e/home.test.ts @@ -2,8 +2,8 @@ import { expect, test } from '@playwright/test'; const NAV_LINKS = ['How it works', 'Stories', 'Citizen demo', 'Developers', 'Status']; -const evaluationUrl = (configuredUrl: string | undefined, fallbackUrl: string) => - `${(configuredUrl ?? fallbackUrl).replace('127.0.0.1', 'localhost').replace(/\/+$/, '')}/v1/evaluations`; +const evidenceUrl = (configuredUrl: string | undefined, fallbackUrl: string) => + `${(configuredUrl ?? fallbackUrl).replace('127.0.0.1', 'localhost').replace(/\/+$/, '')}/v1/evidence`; test('landing renders with header nav and every section in order', async ({ page }) => { const response = await page.goto('/'); @@ -126,14 +126,14 @@ test('country, developer, and status inventories have dedicated routes', async ( await expect(page.locator('#nation .persona')).toHaveCount(12); await page.goto('/developers'); - await expect(page.locator('#engineer-door .curl-example')).toHaveCount(4); + await expect(page.locator('#engineer-door .curl-example')).toHaveCount(5); await expect(page.locator('#engineer-door')).toContainText('pension-payment-review'); await page.goto('/status'); - await expect(page.locator('#status .status')).toHaveCount(17); + await expect(page.locator('#status .status')).toHaveCount(18); }); -test('non-developer page data excludes developer-only published tokens', async ({ request }) => { +test('no page publishes runtime tokens', async ({ request }) => { test.skip(process.env.SOLMARA_HOME_E2E_MODE === 'live', 'uses the offline server token fixture'); for (const path of ['/', '/country', '/status']) { @@ -142,7 +142,7 @@ test('non-developer page data excludes developer-only published tokens', async ( } const developerResponse = await request.get('/developers'); - expect(await developerResponse.text()).toContain('route-scope-test-token'); + expect(await developerResponse.text()).not.toContain('route-scope-test-token'); }); test('purposes page lists every purpose with plain language and working anchors', async ({ page }) => { @@ -153,43 +153,37 @@ test('purposes page lists every purpose with plain language and working anchors' await expect(page.locator('#child-benefit-review .plain')).not.toBeEmpty(); // Denial codes link to the problem-code reference. await expect( - page.locator('#child-benefit-review a[href="/problem-codes#pdp.purpose_not_permitted"]') + page.locator('#child-benefit-review a[href="/problem-codes#not_authorized"]') ).toBeVisible(); }); -test('problem-codes anchors resolve, including pdp.purpose_not_permitted', async ({ page }) => { - await page.goto('/problem-codes#pdp.purpose_not_permitted'); - await expect(page.locator('[id="pdp.purpose_not_permitted"]')).toBeVisible(); +test('problem-codes anchors resolve, including current Evidence authorization denials', async ({ page }) => { + await page.goto('/problem-codes#not_authorized'); + await expect(page.locator('[id="not_authorized"]')).toBeVisible(); // The raw-row refusal a skeptic hits is documented too. - await expect(page.locator('[id="request.invalid"]')).toBeVisible(); + await expect(page.locator('[id="malformed_request"]')).toBeVisible(); }); -test('anatomy lists every relay and notary with repo config links', async ({ page }) => { +test('anatomy lists five Relay V2 projects and six authority Evidence cells with config links', async ({ page }) => { await page.goto('/anatomy'); - await expect(page.locator('#relays .entity')).toHaveCount(6); - await expect(page.locator('#notaries .entity')).toHaveCount(6); - const craLinks = page.locator('#cra-civil-relay a.config-link'); - await expect(craLinks.filter({ hasText: 'projects/cra-civil/registry-stack.yaml' })).toHaveAttribute( - 'href', - /github\.com.*projects\/cra-civil\/registry-stack\.yaml/ - ); - await expect( - craLinks.filter({ hasText: 'runtime/registry-projects/local/cra-civil/relay/relay.yaml' }) - ).toHaveAttribute('href', /github\.com.*relay\/relay\.yaml/); - await expect(craLinks.filter({ hasText: 'ministries/interior-civil' })).toHaveAttribute( - 'href', - /github\.com.*ministries\/interior-civil/ - ); + await expect(page.locator('#relays .entity')).toHaveCount(5); + for (const id of ['cra-evidence', 'nia-evidence', 'sro-evidence', 'mosd-programme-evidence', 'sipf-evidence', 'nagdi-evidence']) { + await expect(page.locator(`#${id}`)).toBeVisible(); + } + await expect(page.locator('#cra-relay a.config-link')).toHaveAttribute('href', /github\.com.*relays\/cra/); + await expect(page.locator('#cra-evidence a.config-link')).toHaveAttribute('href', /github\.com.*evidence\/cells\/cra/); }); test('status grid shows the whole topology', async ({ page }) => { await page.goto('/status'); - await expect(page.locator('#status .status')).toHaveCount(17); + await expect(page.locator('#status .status')).toHaveCount(18); + await expect(page.locator('#status .status.up')).toHaveCount(17); + await expect(page.locator('#status .status', { hasText: 'Optional eSignet' })).toContainText('down'); }); test('engineer door always shows the copy-as-curl examples', async ({ page }) => { await page.goto('/developers'); - await expect(page.locator('#engineer-door .curl-example')).toHaveCount(4); + await expect(page.locator('#engineer-door .curl-example')).toHaveCount(5); // The skeptic wrong-purpose curl is one of them. await expect(page.locator('#engineer-door')).toContainText('pension-payment-review'); }); @@ -215,22 +209,23 @@ test('the nation map renders district labels on the committed district geometry' await expect(page.locator('#solmara-preview .map .district-label', { hasText: 'Ketterin' })).toBeVisible(); }); -test('explorer renders all five published artifact families from the live bundle', async ({ page }) => { +test('explorer renders the published authority Evidence artifact families from the live bundle', async ({ page }) => { test.skip(process.env.SOLMARA_HOME_E2E_MODE !== 'live', 'requires the live static-metadata bundle'); await page.goto('/explorer'); - for (const id of ['api-catalog', 'datasets', 'services', 'offerings', 'policies']) { + for (const id of ['api-catalog', 'services', 'offerings', 'policies']) { await expect(page.locator(`#${id}`)).toBeVisible(); } + await expect(page.locator('#datasets')).toHaveCount(0); await expect(page.locator('#offerings .entity').first()).toBeVisible(); // Offerings cross-link to purposes. await expect(page.locator('#offerings a[href^="/purposes#"]').first()).toBeVisible(); }); -test('engineer door publishes the synthetic demo tokens', async ({ page }) => { - test.skip(process.env.SOLMARA_HOME_E2E_MODE !== 'live', 'demo tokens come from the container allowlist env'); +test('engineer door never publishes runtime tokens', async ({ page }) => { await page.goto('/developers'); - await expect(page.locator('#engineer-door .token').first()).toBeVisible(); - await expect(page.locator('#engineer-door .token-disclaimer')).toContainText('synthetic'); + await expect(page.locator('#engineer-door .token')).toHaveCount(0); + await expect(page.locator('#engineer-door .token-disclaimer')).toContainText('never publishes them'); + await expect(page.locator('#engineer-door')).toContainText('$CRA_EVIDENCE_ACCESS_TOKEN'); }); test('landing fails closed when the scenario runner is unavailable', async ({ page }) => { @@ -246,10 +241,10 @@ test('purpose lens: the live review reveals evidence and the wrong-purpose chall await page.goto('/'); await expect( - page.locator('#proof .proof-grid > div').filter({ hasText: 'live registries' }).locator('strong') + page.locator('#proof .proof-grid > div').filter({ hasText: 'authority Evidence cells' }).locator('strong') ).toHaveText('6'); await expect( - page.locator('#solmara-preview .country-facts > div').filter({ hasText: 'Live registries' }).locator('dd') + page.locator('#solmara-preview .country-facts > div').filter({ hasText: 'Evidence cells' }).locator('dd') ).toHaveText('6'); // The boundary challenge appears only after a successful live evidence run. @@ -271,21 +266,11 @@ test('purpose lens: the live review reveals evidence and the wrong-purpose chall }); expect(requestDeadSpace).toBeLessThan(80); - // Alternate purposes and raw requests stay out of the primary flow. When the - // visitor opens them, the native select remains bounded by its card and uses - // readable story labels instead of long identifier-heavy option labels. - const advanced = page.locator('#purpose-limitation .advanced-request'); - await expect(advanced).not.toHaveAttribute('open', ''); - await advanced.locator(':scope > summary').click(); - const purposeSelect = advanced.getByLabel('Alternate purpose'); - await expect(purposeSelect).toBeVisible(); - const selectFits = await purposeSelect.evaluate((select) => { - const selectRect = select.getBoundingClientRect(); - const cardRect = select.closest('.purpose-picker')?.getBoundingClientRect(); - return Boolean(cardRect && selectRect.width <= cardRect.width); - }); - expect(selectFits).toBe(true); - await expect(advanced.locator('.request-inspector')).not.toHaveAttribute('open', ''); + // Alternate-purpose controls were removed from the landing flow. The fixed + // challenge exercises the reviewed wrong-purpose path without selector or raw + // request exposure. + await expect(page.locator('#purpose-limitation .advanced-request')).toHaveCount(0); + await expect(page.locator('#purpose-limitation .request-inspector')).toHaveCount(0); // The default challenge reuses the evidence request for pension review. await page.getByRole('button', { name: 'Test the safeguard' }).click(); @@ -300,8 +285,7 @@ test('purpose lens: the live review reveals evidence and the wrong-purpose chall test('purpose lens: a needs-attention result never renders as successful evidence', async ({ page }) => { test.skip(process.env.SOLMARA_HOME_E2E_MODE !== 'live', 'requires live scenario metadata'); await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('/'); - await page.route('**/api/scenarios/birth-to-child-benefit/steps/positive/run', async (route) => { + await page.route(/\/api\/scenarios\/birth-to-child-benefit\/steps\/positive\/run$/, async (route) => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ @@ -326,6 +310,7 @@ test('purpose lens: a needs-attention result never renders as successful evidenc }) }); }); + await page.goto('/'); await page.getByRole('button', { name: 'Run the check without sharing records' }).click(); @@ -341,16 +326,12 @@ test('purpose lens: a needs-attention result never renders as successful evidenc test('purpose lens: an unexpected error never renders as a successful safeguard refusal', async ({ page }) => { test.skip(process.env.SOLMARA_HOME_E2E_MODE !== 'live', 'requires live scenario metadata'); await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('/'); - await page.getByRole('button', { name: 'Run the check without sharing records' }).click(); - await expect(page.locator('#purpose-limitation')).toBeVisible({ timeout: 30_000 }); - - await page.route('**/api/scenarios/birth-to-child-benefit/steps/positive/run', async (route) => { + await page.route(/\/api\/scenarios\/birth-to-child-benefit\/steps\/purpose-denial\/run$/, async (route) => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ result: { - step_id: 'positive', + step_id: 'purpose-denial', friendly: { title: 'Request needs attention.', message: 'The downstream service could not complete the safeguard test.', @@ -370,6 +351,10 @@ test('purpose lens: an unexpected error never renders as a successful safeguard }) }); }); + await page.goto('/'); + await page.getByRole('button', { name: 'Run the check without sharing records' }).click(); + await expect(page.locator('#purpose-limitation')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: 'Test the safeguard' }).click(); const boundaryResult = page.locator('#purpose-limitation .boundary-result'); @@ -394,7 +379,8 @@ test('story page: stepper runs an evaluate step and a purpose-denial step with a // collector gathers source-owned predicates, but does not compose eligibility. await expect(page.locator('#credential .inspector')).toContainText('Evidence returned', { timeout: 30_000 }); await expect(page.locator('#credential .inspector')).toContainText('child-benefit-federator'); - await expect(page.locator('#credential .inspector')).toContainText('Source authorities4'); + await expect(page.locator('#credential .inspector')).toContainText('Source authorities'); + await expect(page.locator('#credential .inspector')).toContainText('4'); await expect(page.locator('#credential .inspector')).toContainText('not_composed'); // The purpose-denial step is first-class and renders the stable problem code linked to /problem-codes. @@ -403,7 +389,7 @@ test('story page: stepper runs an evaluate step and a purpose-denial step with a await expect(denialLink).toBeVisible({ timeout: 30_000 }); }); -test('citizen story renders runnable curls for each authority call', async ({ page }) => { +test('citizen story renders sanitized curl skeletons for each authority call', async ({ page }) => { test.skip(process.env.SOLMARA_HOME_E2E_MODE !== 'live', 'requires a live scenario runner behind the stack'); await page.goto('/stories/citizen-self-service'); @@ -414,14 +400,13 @@ test('citizen story renders runnable curls for each authority call', async ({ pa const authorityRequests = result.locator('.request-list .peer-call'); await expect(authorityRequests).toHaveCount(2); - await expect(authorityRequests.getByRole('button', { name: 'Copy as curl' })).toHaveCount(2); + await expect(authorityRequests.getByRole('button', { name: 'Copy safe curl skeleton' })).toHaveCount(2); await expect(authorityRequests.nth(0)).toContainText( - evaluationUrl(process.env.CRA_NOTARY_URL, 'http://localhost:4325') + evidenceUrl(process.env.SOLMARA_CRA_EVIDENCE_URL, 'https://evidence.solmara.invalid/evidence/cra') ); await expect(authorityRequests.nth(1)).toContainText( - evaluationUrl(process.env.NIA_NOTARY_URL, 'http://localhost:4326') + evidenceUrl(process.env.SOLMARA_NIA_EVIDENCE_URL, 'https://evidence.solmara.invalid/evidence/nia') ); - await expect(result).not.toContainText('solmara://authority-notaries'); }); test('story page fits a mobile viewport without horizontal overflow', async ({ page }) => { diff --git a/home/src/lib/components/EngineerDoor.svelte b/home/src/lib/components/EngineerDoor.svelte index f106175..9b369b0 100644 --- a/home/src/lib/components/EngineerDoor.svelte +++ b/home/src/lib/components/EngineerDoor.svelte @@ -1,8 +1,7 @@ @@ -19,15 +19,15 @@

The Nation

-

One island, six live authorities, ten future registries in view

+

One island, six live authorities, and future registries in view

- {#each liveRegistries as registry} + {#each evidenceOfferings as offering}
-

{registry.title}

-

{registry.authority?.name}

- {registry.entities.length} entities, {registry.purposes.length} purposes +

{offering.title}

+

{offering.issuing_authority?.name}

+ {offering.access?.source_type ?? 'Evidence'} · {offering.purposes.length} purpose
{/each} {#each grayRegistries as registry} diff --git a/home/src/lib/components/ProofStrip.svelte b/home/src/lib/components/ProofStrip.svelte index 2920f5a..9a73230 100644 --- a/home/src/lib/components/ProofStrip.svelte +++ b/home/src/lib/components/ProofStrip.svelte @@ -5,10 +5,10 @@ export let scenarios: Scenario[] = []; $: authorities = metadata.available - ? new Set(metadata.catalog.datasets.map((dataset) => dataset.authority?.id ?? dataset.authority?.name).filter(Boolean)).size + ? new Set(metadata.offerings.map((offering) => offering.issuing_authority?.id ?? offering.issuing_authority?.name).filter(Boolean)).size : null; - $: registries = metadata.available - ? metadata.catalog.datasets.length + $: evidenceCells = metadata.available + ? metadata.catalog.data_services?.length ?? authorities : null; $: journeys = scenarios.length || null; @@ -17,7 +17,7 @@
1synthetic country
{authorities ?? 'Unavailable'}live authorities
-
{registries ?? 'Unavailable'}live registries
+
{evidenceCells ?? 'Unavailable'}authority Evidence cells
{journeys ?? 'Unavailable'}guided policy journeys
0real resident records
diff --git a/home/src/lib/components/PurposeLens.svelte b/home/src/lib/components/PurposeLens.svelte index 56200de..0cc68f5 100644 --- a/home/src/lib/components/PurposeLens.svelte +++ b/home/src/lib/components/PurposeLens.svelte @@ -1,17 +1,14 @@
@@ -255,7 +231,7 @@ No. This request is allowed only for Mateo's child-benefit review. Try to reuse it for a pension review and the same services must refuse.

-
@@ -298,34 +274,9 @@ {/if}
-
- Explore other purposes or inspect the technical request -
-
- -

- The request will carry {selectedPurposeView?.slug ?? 'no-purpose-selected'}. -

- -
-
- View the request preview and curl -
{flipPreviewLine}
- {#if flipCurl} - - {/if} -
-
-
+

+ This fixed scenario sends its reviewed pension purpose in the JSON body. The browser cannot override a scenario purpose. +

{/if} diff --git a/home/src/lib/components/SolmaraPreview.svelte b/home/src/lib/components/SolmaraPreview.svelte index 23b13f6..22e0c16 100644 --- a/home/src/lib/components/SolmaraPreview.svelte +++ b/home/src/lib/components/SolmaraPreview.svelte @@ -8,10 +8,10 @@ export let country: any; $: authorities = metadata.available - ? new Set(metadata.catalog.datasets.map((dataset) => dataset.authority?.id ?? dataset.authority?.name).filter(Boolean)).size + ? new Set(metadata.offerings.map((offering) => offering.issuing_authority?.id ?? offering.issuing_authority?.name).filter(Boolean)).size : null; - $: registries = metadata.available - ? metadata.catalog.datasets.length + $: evidenceCells = metadata.available + ? metadata.catalog.data_services?.length ?? authorities : null; @@ -27,7 +27,7 @@

Live authorities
{authorities ?? 'Unavailable'}
-
Live registries
{registries ?? 'Unavailable'}
+
Evidence cells
{evidenceCells ?? 'Unavailable'}
Real records
0
Explore Solmara and its full synthetic cast diff --git a/home/src/lib/components/TrustStrip.svelte b/home/src/lib/components/TrustStrip.svelte index b723e1e..1a56a41 100644 --- a/home/src/lib/components/TrustStrip.svelte +++ b/home/src/lib/components/TrustStrip.svelte @@ -45,8 +45,8 @@

Pinned release

-

Relay {shortDigest(versions.REGISTRY_RELAY_IMAGE)}

-

Notary {shortDigest(versions.REGISTRY_NOTARY_IMAGE)}

+

Registry Stack {versions.REGISTRY_STACK_SOURCE_REF || 'missing'} {versions.REGISTRY_STACK_SOURCE_COMMIT?.slice(0, 12) || 'missing'}

+

Evidence {shortDigest(versions.SOLMARA_EVIDENCE_IMAGE)}

Smoke evidence

diff --git a/home/src/lib/curl.test.ts b/home/src/lib/curl.test.ts index 28355e2..e95d8a0 100644 --- a/home/src/lib/curl.test.ts +++ b/home/src/lib/curl.test.ts @@ -5,32 +5,32 @@ import { runnableRequestSources, toCurl } from './curl'; describe('runnableRequestSources', () => { const multiPreview: RequestSource = { method: 'MULTI', - url: 'solmara://authority-notaries', - headers: { 'Data-Purpose': 'citizen-self-service' } + url: 'solmara://authority-evidence', + purpose: 'citizen-self-service' }; - it('selects each executable authority call instead of the synthetic MULTI preview', () => { + it('selects each executable Evidence call instead of the synthetic MULTI preview', () => { const sources: RequestSource[] = [ { method: 'POST', - url: 'http://localhost:4325/v1/evaluations', - headers: { 'x-api-key': 'tok-cra-citizen' }, - body: { claims: ['civil-record-linked'] } + url: 'https://localhost:4341/v1/evidence', + headers: { Authorization: 'Bearer [runtime token hidden]' }, + body: { requirement: 'cra-citizen-record', purpose: 'citizen-self-service' } }, { method: 'POST', - url: 'http://localhost:4326/v1/evaluations', - headers: { 'x-api-key': 'tok-nia-citizen' }, - body: { claims: ['citizen-population-record-active'] } + url: 'https://localhost:4341/v1/evidence', + headers: { Authorization: 'Bearer [runtime token hidden]' }, + body: { requirement: 'nia-citizen-status', purpose: 'citizen-self-service' } } ]; const commands = runnableRequestSources(multiPreview, sources).map((source) => toCurl(source)); expect(commands).toHaveLength(2); - expect(commands[0]).toContain("curl -sS -X POST 'http://localhost:4325/v1/evaluations'"); - expect(commands[1]).toContain("curl -sS -X POST 'http://localhost:4326/v1/evaluations'"); - expect(commands.join('\n')).not.toContain('solmara://authority-notaries'); + expect(commands[0]).toContain("curl -sS -X POST 'https://localhost:4341/v1/evidence'"); + expect(commands[1]).toContain("curl -sS -X POST 'https://localhost:4341/v1/evidence'"); + expect(commands.join('\n')).not.toContain('solmara://authority-evidence'); }); it('uses the primary request when no underlying calls are present', () => { diff --git a/home/src/lib/curl.ts b/home/src/lib/curl.ts index 99632ed..37881f6 100644 --- a/home/src/lib/curl.ts +++ b/home/src/lib/curl.ts @@ -13,9 +13,8 @@ export function runnableRequestSources( } /** - * Render a request as a copy-as-curl snippet. Headers are prepared server-side: - * redacted by default, with only allowlisted synthetic lab tokens republished - * for the visitor center. URLs are already rewritten to host-reachable ones. + * Render an already-safe request as a copy-as-curl snippet. Runtime credential + * markers are omitted rather than copied into the browser. */ export function toCurl(source: RequestSource | undefined, overrideHeaders: Record = {}): string { if (!source || !source.url) return ''; @@ -23,6 +22,7 @@ export function toCurl(source: RequestSource | undefined, overrideHeaders: Recor const headers = { ...(source.headers ?? {}), ...overrideHeaders }; const lines: string[] = [`curl -sS -X ${method} '${source.url}'`]; for (const [key, value] of Object.entries(headers)) { + if (/authorization|x-api-key/i.test(key) || /runtime token hidden|bearer\s+[a-z0-9._-]+/i.test(value)) continue; lines.push(` -H '${key}: ${value}'`); } if (source.body !== undefined && source.body !== null) { diff --git a/home/src/lib/runresult.test.ts b/home/src/lib/runresult.test.ts index f46c899..b7fe922 100644 --- a/home/src/lib/runresult.test.ts +++ b/home/src/lib/runresult.test.ts @@ -113,9 +113,9 @@ describe('isExpectedProblemDenial', () => { result({ friendlyStatus: 'done', httpStatus: 403, - body: { code: 'pdp.purpose_not_permitted' } + body: { code: 'not_authorized' } }), - 'pdp.purpose_not_permitted' + 'not_authorized' ) ).toBe(true); }); @@ -138,7 +138,7 @@ describe('isExpectedProblemDenial', () => { body: {} }) ]) { - expect(isExpectedProblemDenial(candidate, 'pdp.purpose_not_permitted')).toBe(false); + expect(isExpectedProblemDenial(candidate, 'not_authorized')).toBe(false); } }); }); diff --git a/home/src/lib/runresult.ts b/home/src/lib/runresult.ts index 432a5ac..7070b32 100644 --- a/home/src/lib/runresult.ts +++ b/home/src/lib/runresult.ts @@ -20,7 +20,7 @@ export type ClaimResult = { id: string; satisfied: boolean | null; raw: Dict }; /** Read the per-claim evaluation results defensively across possible field names. */ export function claimResults(result: StepRunResult | null | undefined): ClaimResult[] { const body = responseBody(result); - const list = Array.isArray(body.results) ? body.results : []; + const list = Array.isArray(result?.results) ? result.results : Array.isArray(body.results) ? body.results : []; return list.filter((entry): entry is Dict => !!entry && typeof entry === 'object').map((entry) => ({ id: String(entry.claim_id ?? entry.claim ?? entry.id ?? entry.name ?? 'claim'), satisfied: typeof entry.satisfied === 'boolean' ? entry.satisfied : null, @@ -54,7 +54,8 @@ export function hasExpectedSuccessfulClaims( */ export function explicitProblemCode(result: StepRunResult | null | undefined): string | null { const body = responseBody(result); - const direct = body.code ?? body.problem_code ?? body.error; + const responseCode = (result?.response_source as Record | undefined)?.code; + const direct = responseCode ?? body.code ?? body.problem_code ?? body.error; if (typeof direct === 'string' && direct) return direct; if (typeof body.type === 'string' && body.type.includes('/')) { const tail = body.type.split(/[/#]/).filter(Boolean).pop(); @@ -64,7 +65,7 @@ export function explicitProblemCode(result: StepRunResult | null | undefined): s } /** - * Extract the stable problem code from a denial response. Notary denials are + * Extract the stable problem code from an Evidence denial. Safe problems have * problem+json with a `code`; fall back to the trailing segment of a `type` * URI, then to a compatibility code for older error responses. */ @@ -73,7 +74,7 @@ export function problemCode(result: StepRunResult | null | undefined): string | const explicit = explicitProblemCode(result); if (explicit) return explicit; // Only surface a synthesized code when the response actually denied. - if (status !== null && status >= 400) return 'pdp.purpose_not_permitted'; + if (status !== null && status >= 400) return 'not_authorized'; return null; } @@ -90,10 +91,13 @@ export function isDenial(result: StepRunResult | null | undefined): boolean { return status !== null && status >= 400; } -/** The purpose IRI actually sent, read from the redacted request headers. */ +/** The Evidence purpose code actually sent. */ export function requestPurpose(result: StepRunResult | null | undefined): string | null { - const headers = result?.request_source?.headers ?? {}; - return headers['Data-Purpose'] ?? headers['data-purpose'] ?? null; + const body = result?.request_source?.body; + const bodyPurpose = body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record).purpose + : undefined; + return result?.request_source?.purpose ?? (typeof bodyPurpose === 'string' ? bodyPurpose : undefined) ?? null; } /** @@ -128,7 +132,7 @@ export function hopsFromResult(result: StepRunResult | null | undefined): string try { hops.push(`Question sent to ${new URL(url).host}`); } catch { - hops.push('Question sent to the Notary'); + hops.push('Question sent to the authority Evidence service'); } } const purpose = requestPurpose(result); diff --git a/home/src/lib/server/data.test.ts b/home/src/lib/server/data.test.ts index b9b7bee..ef28a42 100644 --- a/home/src/lib/server/data.test.ts +++ b/home/src/lib/server/data.test.ts @@ -5,8 +5,8 @@ describe('home data adapters', () => { it('parses the purpose register from the normative docs page', async () => { const purposes = await readPurposes(); expect(purposes).toHaveLength(6); - expect(purposes[0].iri).toBe('https://id.registrystack.org/solmara/purpose/child-benefit-review'); - expect(purposes[0].denialCodes).toContain('pdp.purpose_not_permitted'); + expect(purposes[0].iri).toBe('child-benefit-review'); + expect(purposes[0].denialCodes).toContain('not_authorized'); }); it('derives anatomy services from compose', async () => { @@ -16,10 +16,14 @@ describe('home data adapters', () => { expect(services).toContain('static-metadata'); }); - it('reads pinned versions for the trust strip', async () => { + it('publishes the exact Registry Stack release identity', async () => { const versions = await readVersions(); - expect(versions.REGISTRY_RELAY_IMAGE).toMatch(/@sha256:/); - expect(versions.REGISTRY_NOTARY_IMAGE).toMatch(/@sha256:/); + expect(versions.REGISTRY_STACK_REQUIRED_VERSION).toBe('0.22.0'); + expect(versions.REGISTRY_STACK_SOURCE_REF).toBe('v0.22.0'); + expect(versions.REGISTRY_STACK_SOURCE_COMMIT).toMatch(/^[0-9a-f]{40}$/); + expect(versions.REGISTRY_RELAY_IMAGE).toMatch(/@sha256:[0-9a-f]{64}$/); + expect(versions.SOLMARA_EVIDENCE_IMAGE).toMatch(/^ghcr\.io\/registrystack\/evidence@sha256:[0-9a-f]{64}$/); + expect(versions.SOLMARA_MINT_IMAGE).toMatch(/^ghcr\.io\/registrystack\/mint@sha256:[0-9a-f]{64}$/); }); it('keeps compose-internal health probes out of visitor links', async () => { @@ -27,7 +31,7 @@ describe('home data adapters', () => { return new Response('{}', { status: String(url).includes('/health') ? 200 : 503 }); }); const runner = status.find((item) => item.id === 'scenario-runner'); - const metadata = status.find((item) => item.id === 'metadata'); + const metadata = status.find((item) => item.id === 'deterministic-publisher'); expect(runner?.status).toBe('up'); expect(runner?.href).toBeUndefined(); expect(metadata?.href).toBe('/.well-known/api-catalog'); diff --git a/home/src/lib/server/data.ts b/home/src/lib/server/data.ts index 4e6290a..fe4fc53 100644 --- a/home/src/lib/server/data.ts +++ b/home/src/lib/server/data.ts @@ -4,7 +4,7 @@ import { runtime, joinedUrl } from './runtime'; import { buildPublicUrlMap, mapPublicUrl } from './urlmap'; import { statusProbes } from './services'; import { readSeedSummary, readSmokeEvidence } from './evidence'; -import { buildCurlExamples, parsePublishedTokens } from './tokens'; +import { buildCurlExamples } from './tokens'; import { readPurposes } from './purposes'; import type { ChangelogEntry, ChangelogFullEntry, HomeData, MetadataBundle, Persona, Scenario, StatusItem } from '$lib/types'; @@ -34,7 +34,7 @@ export type LandingData = Pick< export type DeveloperData = Pick< HomeData, - 'publishedTokens' | 'curlExamples' | 'versions' | 'repoUrl' + 'curlExamples' | 'versions' | 'repoUrl' >; export type StatusData = Pick< @@ -77,23 +77,19 @@ export async function loadLandingData(fetcher: FetchLike = fetch): Promise { - const publishedTokens = parsePublishedTokens(); return { - publishedTokens, - curlExamples: buildCurlExamples(publishedTokens), + curlExamples: buildCurlExamples(), versions: await readVersions(), repoUrl: runtime.repoUrl }; } /** - * Load only the country route's public fields. Raw published tokens and - * token-bearing curl examples belong to the developer route and must never - * enter a non-developer page-data payload. + * Load only the country route's public fields. */ export async function loadCountryData(fetcher: FetchLike = fetch): Promise { const [metadata, personas, districts, provinces, country] = await Promise.all([ @@ -213,29 +209,49 @@ export async function fetchMetadata(fetcher: FetchLike = fetch): Promise, + catalog: Record, + offeringsPayload: Record, + policiesPayload: Record +): MetadataBundle { + return { + available: true, + apiCatalog, + catalog: { + datasets: arrayValue(catalog.datasets), + gray_registries: arrayValue(catalog.gray_registries), + authorities: arrayValue(catalog.authorities), + data_services: arrayValue(catalog.data_services), + public_services: arrayValue(catalog.public_services) + }, + offerings: arrayValue(offeringsPayload.offerings), + policies: arrayValue(policiesPayload['@graph']) + }; +} + export async function fetchScenarios(fetcher: FetchLike = fetch): Promise<{ scenarios: Scenario[]; defaultScenarioId: string }> { try { const summary = await fetchJson(fetcher, joinedUrl(runtime.scenarioRunnerUrl, '/v1/scenarios')); diff --git a/home/src/lib/server/problemcodes.test.ts b/home/src/lib/server/problemcodes.test.ts index 870028c..9e3dd7e 100644 --- a/home/src/lib/server/problemcodes.test.ts +++ b/home/src/lib/server/problemcodes.test.ts @@ -4,12 +4,12 @@ import type { Purpose, Scenario } from '$lib/types'; const purposes: Purpose[] = [ { - iri: 'https://id.registrystack.org/solmara/purpose/child-benefit-review', + iri: 'child-benefit-review', slug: 'child-benefit-review', advertisedBy: 'CRA', enforcedBy: 'child-benefit-federator', story: 'Birth to child benefit', - denialCodes: ['pdp.purpose_not_permitted'], + denialCodes: ['not_authorized'], plainLanguage: 'permits child benefit evidence' } ]; @@ -36,27 +36,26 @@ const scenarios: Scenario[] = [ describe('problem-code assembly', () => { const codes = assembleProblemCodes(purposes, scenarios); - it('includes every denial code from the catalogue plus the observed request.invalid', () => { + it('includes current Evidence authorization and malformed-request codes', () => { const ids = codes.map((code) => code.code); - expect(ids).toContain('pdp.purpose_not_permitted'); - expect(ids).toContain('request.invalid'); + expect(ids).toContain('not_authorized'); + expect(ids).toContain('malformed_request'); }); - it('uses the problem type URI observed in real notary responses', () => { - const pdp = codes.find((code) => code.code === 'pdp.purpose_not_permitted'); - expect(pdp?.typeUri).toBe('https://id.registrystack.org/problems/registry-notary/pdp/purpose_not_permitted'); + it('uses the current Evidence problem type URI', () => { + const pdp = codes.find((code) => code.code === 'not_authorized'); + expect(pdp?.typeUri).toBe('https://registrystack.org/problems/evidence/not_authorized'); expect(pdp?.problemJson.status).toBe(403); - expect(pdp?.problemJson.code).toBe('pdp.purpose_not_permitted'); + expect(pdp?.problemJson.code).toBe('not_authorized'); }); - it('links purpose_not_permitted to the story step that demonstrates it', () => { - const pdp = codes.find((code) => code.code === 'pdp.purpose_not_permitted'); + it('links not_authorized to the story step that demonstrates it', () => { + const pdp = codes.find((code) => code.code === 'not_authorized'); expect(pdp?.demonstratedBy.map((link) => link.stepId)).toContain('purpose-denial'); expect(pdp?.purposeSlugs).toContain('child-benefit-review'); }); it('anchors each code by its stable code string', () => { - // Phase A deep-links to /problem-codes#pdp.purpose_not_permitted. - expect(codes.some((code) => code.code === 'pdp.purpose_not_permitted')).toBe(true); + expect(codes.some((code) => code.code === 'not_authorized')).toBe(true); }); }); diff --git a/home/src/lib/server/problemcodes.ts b/home/src/lib/server/problemcodes.ts index ace1b91..d6a7a3f 100644 --- a/home/src/lib/server/problemcodes.ts +++ b/home/src/lib/server/problemcodes.ts @@ -2,7 +2,7 @@ import type { ProblemCode, Purpose, Scenario, StoryStepLink } from '$lib/types'; /** * Static, maintained metadata for every stable problem code the lab can emit. - * The `typeUri` values are the problem type URIs actually observed in Notary + * The `typeUri` values are the problem type URIs emitted by authority Evidence * responses. The set of codes is assembled from the purpose catalogue (which * lists each purpose's denial codes) plus these entries, so the page never * hand-maintains prose that can drift from the catalogue. Meanings are plain @@ -12,20 +12,27 @@ const CODE_META: Record< string, { title: string; status: number; typeUri: string; meaning: string; coverage?: string } > = { - 'pdp.purpose_not_permitted': { + not_authorized: { title: 'Purpose not permitted', status: 403, - typeUri: 'https://id.registrystack.org/problems/registry-notary/pdp/purpose_not_permitted', + typeUri: 'https://registrystack.org/problems/evidence/not_authorized', meaning: - 'The request named a purpose the authority does not allow for this evidence, or asked for a field outside that purpose. The Notary (the service that certifies evidence) refuses and discloses nothing. This is purpose limitation enforced at request time.' + 'The requester grant does not authorize this requirement, purpose, response format, or selector shape. The authority Evidence service refuses before source access and discloses nothing.' }, - 'request.invalid': { + malformed_request: { title: 'Invalid evidence request', status: 400, - typeUri: 'https://id.registrystack.org/problems/registry-notary/request/invalid', + typeUri: 'https://registrystack.org/problems/evidence/malformed_request', meaning: - 'The request asked for something the Notary will not serve, such as a raw source row instead of a purpose-limited predicate. The Notary rejects the request rather than reach into the register. This is the clean refusal a skeptic gets when they try a raw row read.', - coverage: 'Asserted by the published-token smoke: a raw-row read attempt with a published demo token.' + 'The request does not match the closed Evidence request contract. The authority Evidence service rejects it before evaluating a requirement.', + coverage: 'Covered by the Evidence bundle fixtures and current-main contract tests.' + }, + request_refused: { + title: 'Request refused', + status: 403, + typeUri: 'https://registrystack.org/problems/evidence/request_refused', + meaning: + 'The programme or authority refused the request without revealing whether any source record matched. The refusal is intentionally generic for wrong-purpose and unauthorized paths.' } }; @@ -63,12 +70,12 @@ export function assembleProblemCodes(purposes: Purpose[], scenarios: Scenario[]) const meta = CODE_META[code] ?? { title: code, status: 400, - typeUri: `https://id.registrystack.org/problems/registry-notary/${code.replace(/\./g, '/')}`, + typeUri: `https://registrystack.org/problems/evidence/${code.replace(/\./g, '_')}`, meaning: 'A stable problem code emitted by the stack. See the purpose catalogue for where it applies.' }; const typeUri = meta.typeUri; const purposeSlugs = purposes.filter((purpose) => purpose.denialCodes.includes(code)).map((purpose) => purpose.slug); - const demonstratedBy = code === 'pdp.purpose_not_permitted' ? denials : []; + const demonstratedBy = code === 'not_authorized' ? denials : []; return { code, typeUri, diff --git a/home/src/lib/server/purposes.test.ts b/home/src/lib/server/purposes.test.ts index 5dbb9da..a93717d 100644 --- a/home/src/lib/server/purposes.test.ts +++ b/home/src/lib/server/purposes.test.ts @@ -8,8 +8,8 @@ const SAMPLE = `# Solmara Purpose Catalogue | Purpose IRI | Advertised by | Enforced by | Story | Denial problem codes | |---|---|---|---|---| -| \`https://id.registrystack.org/solmara/purpose/child-benefit-review\` | CRA, NIA | CRA and NIA authority Notaries | Birth to child benefit | \`pdp.purpose_not_permitted\` | -| \`https://id.registrystack.org/solmara/purpose/voucher-eligibility-review\` | NAgDI | \`nagdi-notary\` | Farmer climate-smart voucher | \`pdp.purpose_not_permitted\` | +| \`child-benefit-review\` | CRA, NIA | Authority Evidence cells | Birth to child benefit | \`not_authorized\` | +| \`voucher-eligibility-review\` | NAgDI | Authority Evidence cells | Farmer climate-smart voucher | \`not_authorized\` | ## Purpose Rules @@ -41,7 +41,7 @@ function scenario(id: string, title: string, steps: { id: string; label: string; prompt: '', button: '', request_summary: '', - request_preview: { method: 'POST', url: 'http://x/v1/evaluations', headers: { 'Data-Purpose': step.purpose } } + request_preview: { method: 'POST', url: 'http://x/v1/evaluations', body: { purpose: step.purpose } } })), receipt: [] }; @@ -52,12 +52,12 @@ describe('purposes parser', () => { const purposes = parsePurposes(SAMPLE); expect(purposes).toHaveLength(2); const child = purposes[0]; - expect(child.iri).toBe('https://id.registrystack.org/solmara/purpose/child-benefit-review'); + expect(child.iri).toBe('child-benefit-review'); expect(child.slug).toBe('child-benefit-review'); expect(child.advertisedBy).toBe('CRA, NIA'); - expect(child.enforcedBy).toBe('CRA and NIA authority Notaries'); + expect(child.enforcedBy).toBe('Authority Evidence cells'); expect(child.story).toBe('Birth to child benefit'); - expect(child.denialCodes).toEqual(['pdp.purpose_not_permitted']); + expect(child.denialCodes).toEqual(['not_authorized']); expect(child.plainLanguage).toContain('permits evidence needed to determine whether a child'); expect(child.plainLanguage).toContain('does not permit raw poverty scores'); // The rule paragraph must not leak markdown backticks. @@ -81,11 +81,11 @@ describe('purposes parser', () => { it('links purposes to the story steps that send them', () => { const scenarios = [ scenario('birth-to-child-benefit', 'Birth to child benefit', [ - { id: 'positive', label: 'Evaluate eligible child', purpose: 'https://id.registrystack.org/solmara/purpose/child-benefit-review' }, - { id: 'purpose-denial', label: 'Purpose denial', purpose: 'https://id.registrystack.org/solmara/purpose/pension-payment-review' } + { id: 'positive', label: 'Evaluate eligible child', purpose: 'child-benefit-review' }, + { id: 'purpose-denial', label: 'Purpose denial', purpose: 'pension-payment-review' } ]) ]; - const links = storyLinksForPurpose('https://id.registrystack.org/solmara/purpose/child-benefit-review', scenarios); + const links = storyLinksForPurpose('child-benefit-review', scenarios); expect(links).toHaveLength(1); expect(links[0]).toMatchObject({ storyId: 'birth-to-child-benefit', stepId: 'positive' }); diff --git a/home/src/lib/server/purposes.ts b/home/src/lib/server/purposes.ts index e91ff79..f58dba7 100644 --- a/home/src/lib/server/purposes.ts +++ b/home/src/lib/server/purposes.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { runtime } from './runtime'; import type { Purpose, PurposeView, Scenario, StoryStepLink } from '$lib/types'; -const TABLE_ROW_PREFIX = '| `https://id.registrystack.org/solmara/purpose/'; +const TABLE_ROW_PREFIX = '| `'; const RULES_HEADING = '## Purpose Rules'; /** @@ -16,11 +16,22 @@ export function parsePurposes(raw: string): Purpose[] { const rules = parseRuleParagraphs(raw); return raw .split('\n') - .filter((line) => line.startsWith(TABLE_ROW_PREFIX)) + .filter((line) => line.startsWith(TABLE_ROW_PREFIX) && [5, 7].includes(line.split('|').length)) .map((line) => { const cells = line.split('|').slice(1, -1).map((cell) => cell.trim()); const iri = stripTicks(cells[0]); const slug = iri.split('/').pop() ?? iri; + if (cells.length === 3) { + return { + iri, + slug, + advertisedBy: cells[1], + enforcedBy: 'Authority Evidence cells', + story: slug.replace(/-/g, ' '), + denialCodes: ['not_authorized'], + plainLanguage: `${cells[1]} may answer this purpose through ${cells[2]}. Wrong-purpose and unauthorized requests disclose nothing.` + }; + } return { iri, slug, @@ -61,15 +72,16 @@ export async function readPurposes(): Promise { /** * Return the guided-story steps that demonstrate a purpose, matched on the - * `Data-Purpose` header each step's request preview actually sends. This keeps + * Evidence `purpose` member each step's request preview actually sends. This keeps * the story cross-links driven by the scenario data rather than a hand table. */ export function storyLinksForPurpose(iri: string, scenarios: Scenario[]): StoryStepLink[] { const links: StoryStepLink[] = []; for (const scenario of scenarios) { for (const step of scenario.steps) { - const headers = step.request_preview?.headers ?? {}; - const sent = headers['Data-Purpose'] ?? headers['data-purpose']; + const preview = step.request_preview; + const body = preview?.body && typeof preview.body === 'object' ? preview.body as Record : {}; + const sent = preview?.purpose ?? body.purpose; if (sent === iri) { links.push({ storyId: scenario.id, storyTitle: scenario.title, stepId: step.id, stepLabel: step.label }); } diff --git a/home/src/lib/server/services.test.ts b/home/src/lib/server/services.test.ts index 19f856c..0eeca14 100644 --- a/home/src/lib/server/services.test.ts +++ b/home/src/lib/server/services.test.ts @@ -1,110 +1,39 @@ import { describe, expect, it } from 'vitest'; import { statusProbes, topologyGroups } from './services'; -describe('status probe table', () => { - it('covers the whole topology: shared services and six authority pairs', () => { - const probes = statusProbes('http://127.0.0.1:4300'); - const relays = probes.filter((probe) => probe.role === 'relay'); - const notaries = probes.filter((probe) => probe.role === 'notary'); - const shared = probes.filter((probe) => probe.role === 'shared'); - expect(relays).toHaveLength(6); - expect(notaries).toHaveLength(6); - expect(shared.length).toBeGreaterThanOrEqual(4); - expect(probes.length).toBeGreaterThanOrEqual(16); - }); - - it('probes the federator on health, notaries on claims, and relays on their gated root', () => { - const probes = statusProbes('http://127.0.0.1:4300'); - const federator = probes.find((probe) => probe.id === 'child-benefit-federator'); - const notary = probes.find((probe) => probe.id === 'cra-notary'); - const relay = probes.find((probe) => probe.id === 'cra-civil-relay'); - expect(federator?.probeUrl).toContain('/health'); - expect(notary?.probeUrl).toContain('/v1/claims'); - expect(relay?.probeUrl?.endsWith('/')).toBe(true); - }); - - it('only marks the browsable services with an open link', () => { +describe('authority-cell topology', () => { + it('has exactly five Relays, six Evidence cells, and one shared Mint', () => { const probes = statusProbes('http://portal.example'); - expect(probes.find((probe) => probe.id === 'metadata')?.href).toBe('/.well-known/api-catalog'); - expect(probes.find((probe) => probe.id === 'portal')?.href).toBe('http://portal.example'); - expect(probes.find((probe) => probe.id === 'home')?.href).toBe('/'); - expect(probes.find((probe) => probe.id === 'cra-civil-relay')?.href).toBeUndefined(); - }); - - it('reads probe origins from the env table, overriding the localhost defaults', () => { - const probes = statusProbes('http://127.0.0.1:4300', { - CHILD_BENEFIT_FEDERATOR_URL: 'http://child-benefit-federator:8080' + expect(probes.filter((probe) => probe.role === 'relay')).toHaveLength(5); + expect(probes.filter((probe) => probe.role === 'evidence')).toHaveLength(6); + expect(probes.filter((probe) => probe.id === 'lab-mint')).toHaveLength(1); + expect(probes.filter((probe) => probe.id === 'mosd-relay')).toHaveLength(1); + expect(probes.some((probe) => probe.id === 'sro-relay')).toBe(false); + expect(probes.some((probe) => probe.id === 'registry-evidence')).toBe(false); + }); + + it('uses distinct authority Evidence identities and configurable origins', () => { + const probes = statusProbes('http://portal.example', { + SOLMARA_CRA_EVIDENCE_URL: 'https://cra.example', + SOLMARA_NIA_EVIDENCE_URL: 'https://nia.example' }); - expect(probes.find((probe) => probe.id === 'child-benefit-federator')?.probeUrl).toBe( - 'http://child-benefit-federator:8080/health' - ); - // A service with no override keeps its localhost default. - expect(probes.find((probe) => probe.id === 'sipf-notary')?.probeUrl).toBe('http://127.0.0.1:4322/v1/claims'); + expect(probes.find((probe) => probe.id === 'cra-evidence')?.probeUrl).toBe('https://cra.example/health'); + expect(probes.find((probe) => probe.id === 'nia-evidence')?.probeUrl).toBe('https://nia.example/health'); }); - it('maps every Compose authority Notary environment name to exactly one probe', () => { - const authorityOrigins = { - CRA_NOTARY_URL: 'http://cra-notary:8081', - NIA_NOTARY_URL: 'http://nia-notary:8081', - SRO_NOTARY_URL: 'http://sro-notary:8081', - PROGRAMME_NOTARY_URL: 'http://programme-notary:8081', - SIPF_NOTARY_URL: 'http://sipf-notary:8081', - NAGDI_NOTARY_URL: 'http://nagdi-notary:8081' - }; - const probes = statusProbes('http://127.0.0.1:4300', authorityOrigins).filter( - (probe) => probe.role === 'notary' - ); - - expect(Object.fromEntries(probes.map((probe) => [probe.id, probe.probeUrl]))).toEqual({ - 'cra-notary': 'http://cra-notary:8081/v1/claims', - 'nia-notary': 'http://nia-notary:8081/v1/claims', - 'sro-notary': 'http://sro-notary:8081/v1/claims', - 'programme-notary': 'http://programme-notary:8081/v1/claims', - 'sipf-notary': 'http://sipf-notary:8081/v1/claims', - 'nagdi-notary': 'http://nagdi-notary:8081/v1/claims' - }); - }); - - it('marks home as self so it is reported up without a network probe', () => { - const probes = statusProbes('http://127.0.0.1:4300'); - expect(probes.find((probe) => probe.id === 'home')?.self).toBe(true); - }); -}); - -describe('topology groups', () => { - const groups = topologyGroups('https://github.com/registrystack/solmara-lab'); - - it('groups six authority-owned Relays, six authority-owned Notaries, and shared services', () => { - const keys = groups.map((group) => group.key); - expect(keys).toEqual(['relays', 'notaries', 'shared']); - expect(groups[0].services).toHaveLength(6); - expect(groups[1].services).toHaveLength(6); - expect(groups[1].services.every((service) => Boolean(service.authority))).toBe(true); - }); - - it('links every authority to its authored project and generated Relay config', () => { - const cra = groups[0].services.find((service) => service.id === 'cra-civil-relay'); - expect(cra?.authority).toBe('Civil Registration Authority'); - const project = cra?.config.find((link) => link.label === 'Registry project'); - expect(project?.url).toBe( - 'https://github.com/registrystack/solmara-lab/blob/main/projects/cra-civil/registry-stack.yaml' - ); - const generated = cra?.config.find((link) => link.label === 'Generated Relay config'); - expect(generated?.url).toBe( - 'https://github.com/registrystack/solmara-lab/blob/main/runtime/registry-projects/local/cra-civil/relay/relay.yaml' - ); - const seed = cra?.config.find((link) => link.path === 'ministries/interior-civil'); - expect(seed?.url).toBe('https://github.com/registrystack/solmara-lab/tree/main/ministries/interior-civil'); + it('links only browser-facing services', () => { + const probes = statusProbes('http://portal.example'); + expect(probes.find((probe) => probe.id === 'deterministic-publisher')?.href).toBe('/.well-known/api-catalog'); + expect(probes.find((probe) => probe.id === 'portal')?.href).toBe('http://portal.example'); + expect(probes.find((probe) => probe.id === 'cra-evidence')?.href).toBeUndefined(); }); - it('links authority Notaries to their generated project closure', () => { - const nia = groups[1].services.find((service) => service.id === 'nia-notary'); - expect(nia?.config).toEqual([ - { - label: 'Generated Notary config', - path: 'runtime/registry-projects/local/nia-population/notary/notary.yaml', - url: 'https://github.com/registrystack/solmara-lab/blob/main/runtime/registry-projects/local/nia-population/notary/notary.yaml' - } - ]); + it('renders the approved deterministic topology groups and owned configs', () => { + const groups = topologyGroups('https://github.com/registrystack/solmara-lab'); + expect(groups.map((group) => group.key)).toEqual(['publisher', 'relays', 'evidence', 'programme', 'identity']); + expect(groups.find((group) => group.key === 'relays')?.services).toHaveLength(5); + expect(groups.find((group) => group.key === 'evidence')?.services.filter((service) => service.role === 'evidence')).toHaveLength(6); + expect(groups.find((group) => group.key === 'evidence')?.services.find((service) => service.id === 'cra-evidence')?.config[0].path).toBe('evidence/cells/cra'); + expect(groups.find((group) => group.key === 'identity')?.services[0].blurb).toContain('NIA Relay'); }); }); diff --git a/home/src/lib/server/services.ts b/home/src/lib/server/services.ts index a4d4e71..c63f4fc 100644 --- a/home/src/lib/server/services.ts +++ b/home/src/lib/server/services.ts @@ -1,19 +1,7 @@ import { env } from '$env/dynamic/private'; import type { ConfigLink, TopologyGroup, TopologyService } from '$lib/types'; -/** - * The one env-driven service table for the visitor center. Every server-side - * probe URL, every anatomy config link, and the status grid read from here so no - * port or hostname is scattered across components. Probe URLs default to the - * published localhost ports and are overridden by the compose-internal URLs the - * home container receives (see the `home` service env in `compose.yaml`), which - * is why relays and notaries are reachable for probing from inside the network. - * - * The internal probe origins mirror the same conventions as `urlmap.ts` and - * `scenarios/service_config.py`. - */ -export type ServiceRole = 'shared' | 'relay' | 'notary'; - +export type ServiceRole = 'shared' | 'relay' | 'evidence'; type ServiceStatusSemantics = 'health' | 'auth-gated'; type ServiceDef = { @@ -32,244 +20,200 @@ type ServiceDef = { configPaths: { label: string; path: string }[]; }; +const shared = ( + service: Omit & { semantics?: ServiceStatusSemantics } +): ServiceDef => ({ ...service, role: 'shared', semantics: service.semantics ?? 'health' }); + +const relay = (service: Omit): ServiceDef => ({ + ...service, + role: 'relay', + semantics: 'auth-gated' +}); + +const evidence = (service: Omit): ServiceDef => ({ + ...service, + role: 'evidence', + semantics: 'health' +}); + export const SERVICES: ServiceDef[] = [ - { - id: 'metadata', - label: 'Published metadata', - role: 'shared', - blurb: 'Serves the CPSV-AP metadata bundle that the Nation grid and the explorer render from.', + shared({ + id: 'deterministic-publisher', + label: 'Deterministic publisher', + blurb: 'Builds the metadata publication, immutable Evidence extracts, and Relay SQLite sources from one reviewed synthetic input set.', probeEnv: 'STATIC_METADATA_URL', probeDefault: 'http://127.0.0.1:4331', probePath: '/.well-known/api-catalog', - semantics: 'health', browsable: true, configPaths: [ - { label: 'Assembly manifest', path: 'metadata/assembly.yaml' }, - { label: 'Published bundle', path: 'metadata/public' } - ] - }, - { - id: 'scenario-runner', - label: 'Scenario runner', - role: 'shared', - blurb: 'Runs the guided stories and the Purpose Lens against the live notaries.', - probeEnv: 'SCENARIO_RUNNER_URL', - probeDefault: 'http://127.0.0.1:4302', - probePath: '/health', - semantics: 'health', - configPaths: [ - { label: 'Runner API', path: 'scenario-runner/server.py' }, - { label: 'Scenario modules', path: 'scenarios' } + { label: 'Publisher', path: 'generator/solmara_lab/publisher.py' }, + { label: 'Published metadata', path: 'metadata/public' }, + { label: 'Generated SQLite outputs', path: 'output/sqlite' } ] - }, - { - id: 'portal', - label: 'Citizen portal', - role: 'shared', - blurb: 'The in-fiction resident application. Solmara Lab hands personas into it.', - probeEnv: 'PORTAL_PROBE_URL', - probeDefault: 'http://127.0.0.1:4300', - probePath: '/', - semantics: 'health', - browsable: true, - configPaths: [{ label: 'Portal app', path: 'portal' }] - }, - { - id: 'home', - label: 'Solmara Lab', - role: 'shared', - blurb: 'The out-of-fiction front door to the live synthetic country.', - semantics: 'health', - self: true, - browsable: true, - configPaths: [{ label: 'Home app', path: 'home' }] - }, - { - id: 'cra-civil-relay', - label: 'CRA civil relay', - role: 'relay', + }), + relay({ + id: 'cra-relay', + label: 'CRA Relay', authority: 'Civil Registration Authority', - blurb: 'Read-only relay over civil registration (births, deaths).', + blurb: 'Relay V2 exact lookups over the CRA civil source.', probeEnv: 'CRA_CIVIL_RELAY_URL', probeDefault: 'http://127.0.0.1:4311', - probePath: '/', - semantics: 'auth-gated', - configPaths: [ - { label: 'Registry project', path: 'projects/cra-civil/registry-stack.yaml' }, - { label: 'Generated Relay config', path: 'runtime/registry-projects/local/cra-civil/relay/relay.yaml' }, - { label: 'Seed and fixtures', path: 'ministries/interior-civil' } - ] - }, - { - id: 'nia-population-relay', - label: 'NIA population relay', - role: 'relay', + probePath: '/ready', + configPaths: [{ label: 'Relay V2 project', path: 'relays/cra' }] + }), + relay({ + id: 'nia-relay', + label: 'NIA Relay', authority: 'National Identity Agency', - blurb: 'Read-only relay over the population register.', + blurb: 'Relay V2 exact lookups over the population source, also available to the optional eSignet path.', probeEnv: 'NIA_POPULATION_RELAY_URL', probeDefault: 'http://127.0.0.1:4312', - probePath: '/', - semantics: 'auth-gated', - configPaths: [ - { label: 'Registry project', path: 'projects/nia-population/registry-stack.yaml' }, - { label: 'Generated Relay config', path: 'runtime/registry-projects/local/nia-population/relay/relay.yaml' }, - { label: 'Seed and fixtures', path: 'ministries/interior-population' } - ] - }, - { - id: 'sro-social-relay', - label: 'SRO social relay', - role: 'relay', - authority: 'Social Registry Office', - blurb: 'Read-only relay over the social registry (household poverty band).', - probeEnv: 'SRO_SOCIAL_RELAY_URL', - probeDefault: 'http://127.0.0.1:4313', - probePath: '/', - semantics: 'auth-gated', - configPaths: [ - { label: 'Registry project', path: 'projects/sro-social/registry-stack.yaml' }, - { label: 'Generated Relay config', path: 'runtime/registry-projects/local/sro-social/relay/relay.yaml' }, - { label: 'Seed and fixtures', path: 'ministries/social-development' } - ] - }, - { - id: 'programme-mis-relay', - label: 'Programme MIS relay', - role: 'relay', + probePath: '/ready', + configPaths: [{ label: 'Relay V2 project', path: 'relays/nia' }] + }), + relay({ + id: 'mosd-relay', + label: 'MoSD Programme Relay', authority: 'Ministry of Social Development Programme MIS', - blurb: 'Read-only relay over the integrated beneficiary registry.', - probeEnv: 'PROGRAMME_MIS_RELAY_URL', + blurb: 'Relay V2 exact lookups over the programme-owned beneficiary enrolment source.', + probeEnv: 'MOSD_PROGRAMME_RELAY_URL', probeDefault: 'http://127.0.0.1:4314', - probePath: '/', - semantics: 'auth-gated', - configPaths: [ - { label: 'Registry project', path: 'projects/mosd-programme/registry-stack.yaml' }, - { label: 'Generated Relay config', path: 'runtime/registry-projects/local/mosd-programme/relay/relay.yaml' }, - { label: 'Seed and fixtures', path: 'ministries/social-development' } - ] - }, - { - id: 'sipf-pensions-relay', - label: 'SIPF pensions relay', - role: 'relay', + probePath: '/ready', + configPaths: [{ label: 'Relay V2 project', path: 'relays/mosd' }] + }), + relay({ + id: 'sipf-relay', + label: 'SIPF Relay', authority: 'Social Insurance and Pensions Fund', - blurb: 'Read-only relay over pension case records.', + blurb: 'Relay V2 exact lookups over pension payment and survivor resources.', probeEnv: 'SIPF_PENSIONS_RELAY_URL', probeDefault: 'http://127.0.0.1:4315', - probePath: '/', - semantics: 'auth-gated', - configPaths: [ - { label: 'Registry project', path: 'projects/sipf-pensions/registry-stack.yaml' }, - { label: 'Generated Relay config', path: 'runtime/registry-projects/local/sipf-pensions/relay/relay.yaml' }, - { label: 'Seed and fixtures', path: 'ministries/labour-pensions' } - ] - }, - { - id: 'nagdi-agriculture-relay', - label: 'NAgDI agriculture relay', - role: 'relay', + probePath: '/ready', + configPaths: [{ label: 'Relay V2 project', path: 'relays/sipf' }] + }), + relay({ + id: 'nagdi-relay', + label: 'NAgDI Relay', authority: 'National Agricultural Data Institute', - blurb: 'Read-only relay over farmer and livestock registries.', + blurb: 'Relay V2 exact lookups over farmer voucher and livestock movement resources.', probeEnv: 'NAGDI_AGRICULTURE_RELAY_URL', probeDefault: 'http://127.0.0.1:4316', - probePath: '/', - semantics: 'auth-gated', - configPaths: [ - { label: 'Registry project', path: 'projects/nagdi-agriculture/registry-stack.yaml' }, - { label: 'Generated Relay config', path: 'runtime/registry-projects/local/nagdi-agriculture/relay/relay.yaml' }, - { label: 'Seed and fixtures', path: 'ministries/agriculture-nagdi' } - ] - }, - { - id: 'child-benefit-federator', - label: 'Child benefit federator', - role: 'shared', - purpose: 'child-benefit-review', - blurb: 'Collects minimized source-owned predicates as ordinary application evidence. It does not make the eligibility decision.', - probeEnv: 'CHILD_BENEFIT_FEDERATOR_URL', - probeDefault: 'http://127.0.0.1:4321', - probePath: '/health', - semantics: 'health', - configPaths: [{ label: 'Federator service', path: 'scenario-runner/child_benefit_federator.py' }] - }, - { - id: 'cra-notary', - label: 'CRA notary', - role: 'notary', + probePath: '/ready', + configPaths: [{ label: 'Relay V2 project', path: 'relays/nagdi' }] + }), + evidence({ + id: 'cra-evidence', + label: 'CRA Evidence', authority: 'Civil Registration Authority', - purpose: 'child-benefit-review, pension-payment-review, citizen-self-service', - blurb: 'Answers civil-registration predicates for child benefit, pension review, and citizen services.', - probeEnv: 'CRA_NOTARY_URL', - probeDefault: 'http://127.0.0.1:4325', - probePath: '/v1/claims', - semantics: 'auth-gated', - configPaths: [{ label: 'Generated Notary config', path: 'runtime/registry-projects/local/cra-civil/notary/notary.yaml' }] - }, - { - id: 'nia-notary', - label: 'NIA notary', - role: 'notary', + blurb: 'Issues CRA-signed minimized values from reviewed immutable extracts or CRA Relay lookups.', + probeEnv: 'SOLMARA_CRA_EVIDENCE_URL', + probeDefault: 'https://cra-evidence.solmara.registrystack.org', + probePath: '/health', + configPaths: [{ label: 'Authority Evidence cell', path: 'evidence/cells/cra' }] + }), + evidence({ + id: 'nia-evidence', + label: 'NIA Evidence', authority: 'National Identity Agency', - purpose: 'child-benefit-review, citizen-self-service', - blurb: 'Answers active population-record predicates and owns the citizen population-status credential.', - probeEnv: 'NIA_NOTARY_URL', - probeDefault: 'http://127.0.0.1:4326', - probePath: '/v1/claims', - semantics: 'auth-gated', - configPaths: [{ label: 'Generated Notary config', path: 'runtime/registry-projects/local/nia-population/notary/notary.yaml' }] - }, - { - id: 'sro-notary', - label: 'SRO notary', - role: 'notary', + blurb: 'Issues NIA-signed population status values.', + probeEnv: 'SOLMARA_NIA_EVIDENCE_URL', + probeDefault: 'https://nia-evidence.solmara.registrystack.org', + probePath: '/health', + configPaths: [{ label: 'Authority Evidence cell', path: 'evidence/cells/nia' }] + }), + evidence({ + id: 'sro-evidence', + label: 'SRO Evidence', authority: 'Social Registry Office', - purpose: 'child-benefit-review', - blurb: 'Answers the household poverty-threshold predicate from the social registry.', - probeEnv: 'SRO_NOTARY_URL', - probeDefault: 'http://127.0.0.1:4327', - probePath: '/v1/claims', - semantics: 'auth-gated', - configPaths: [{ label: 'Generated Notary config', path: 'runtime/registry-projects/local/sro-social/notary/notary.yaml' }] - }, - { - id: 'programme-notary', - label: 'Programme MIS notary', - role: 'notary', + blurb: 'Issues SRO-signed household poverty values from its immutable extract.', + probeEnv: 'SOLMARA_SRO_EVIDENCE_URL', + probeDefault: 'https://sro-evidence.solmara.registrystack.org', + probePath: '/health', + configPaths: [{ label: 'Authority Evidence cell', path: 'evidence/cells/sro' }] + }), + evidence({ + id: 'mosd-programme-evidence', + label: 'MoSD Programme Evidence', authority: 'Ministry of Social Development Programme MIS', - purpose: 'child-benefit-review', - blurb: 'Answers the duplicate-enrollment predicate from programme records.', - probeEnv: 'PROGRAMME_NOTARY_URL', - probeDefault: 'http://127.0.0.1:4328', - probePath: '/v1/claims', - semantics: 'auth-gated', - configPaths: [{ label: 'Generated Notary config', path: 'runtime/registry-projects/local/mosd-programme/notary/notary.yaml' }] - }, - { - id: 'sipf-notary', - label: 'SIPF notary', - role: 'notary', + blurb: 'Issues programme-owned duplicate-enrolment values through the social development Relay.', + probeEnv: 'SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL', + probeDefault: 'https://mosd-programme-evidence.solmara.registrystack.org', + probePath: '/health', + configPaths: [{ label: 'Authority Evidence cell', path: 'evidence/cells/mosd-programme' }] + }), + evidence({ + id: 'sipf-evidence', + label: 'SIPF Evidence', authority: 'Social Insurance and Pensions Fund', - purpose: 'pension-payment-review, survivor-benefit-determination', - blurb: 'Answers pension-payment and survivor-benefit predicates and owns the survivor credential.', - probeEnv: 'SIPF_NOTARY_URL', - probeDefault: 'http://127.0.0.1:4322', - probePath: '/v1/claims', - semantics: 'auth-gated', - configPaths: [{ label: 'Generated Notary config', path: 'runtime/registry-projects/local/sipf-pensions/notary/notary.yaml' }] - }, - { - id: 'nagdi-notary', - label: 'NAgDI notary', - role: 'notary', + blurb: 'Issues SIPF-signed pension and survivor values.', + probeEnv: 'SOLMARA_SIPF_EVIDENCE_URL', + probeDefault: 'https://sipf-evidence.solmara.registrystack.org', + probePath: '/health', + configPaths: [{ label: 'Authority Evidence cell', path: 'evidence/cells/sipf' }] + }), + evidence({ + id: 'nagdi-evidence', + label: 'NAgDI Evidence', authority: 'National Agricultural Data Institute', - purpose: 'voucher-eligibility-review, livestock-movement-control', - blurb: 'Evaluates farmer voucher and livestock movement claims.', - probeEnv: 'NAGDI_NOTARY_URL', - probeDefault: 'http://127.0.0.1:4323', - probePath: '/v1/claims', - semantics: 'auth-gated', - configPaths: [{ label: 'Generated Notary config', path: 'runtime/registry-projects/local/nagdi-agriculture/notary/notary.yaml' }] - } + blurb: 'Issues NAgDI-signed voucher and livestock movement values.', + probeEnv: 'SOLMARA_NAGDI_EVIDENCE_URL', + probeDefault: 'https://nagdi-evidence.solmara.registrystack.org', + probePath: '/health', + configPaths: [{ label: 'Authority Evidence cell', path: 'evidence/cells/nagdi' }] + }), + shared({ + id: 'lab-mint', + label: 'Shared lab Mint', + blurb: 'Issues short-lived, audience-bound machine tokens for the six authority Evidence cells and five Relays.', + probeEnv: 'MINT_URL', + probeDefault: 'https://localhost:4341', + probePath: '/health', + configPaths: [{ label: 'Mint config', path: 'evidence/mint.yaml' }] + }), + shared({ + id: 'child-benefit-programme', + label: 'Child benefit programme app', + purpose: 'child-benefit-review', + blurb: 'Collects separately signed authority Evidence and makes the programme decision. It does not own registry facts.', + probeEnv: 'CHILD_BENEFIT_FEDERATOR_URL', + probeDefault: 'http://127.0.0.1:4321', + probePath: '/health', + configPaths: [{ label: 'Programme application', path: 'scenario-runner/child_benefit_federator.py' }] + }), + shared({ + id: 'scenario-runner', + label: 'Scenario runner', + blurb: 'Runs the guided stories against the authority Evidence endpoints.', + probeEnv: 'SCENARIO_RUNNER_URL', + probeDefault: 'http://127.0.0.1:4302', + probePath: '/health', + configPaths: [{ label: 'Scenario modules', path: 'scenarios' }] + }), + shared({ + id: 'esignet', + label: 'Optional eSignet', + authority: 'National Identity Agency', + blurb: 'Optional resident sign-in. It resolves identity through the NIA Relay and is not an Evidence authority.', + configPaths: [{ label: 'Portal eSignet integration', path: 'portal/src/lib/server/esignet.ts' }] + }), + shared({ + id: 'portal', + label: 'Citizen portal', + blurb: 'The resident application that presents authority Evidence without exposing selectors or raw wire material.', + probeEnv: 'PORTAL_PROBE_URL', + probeDefault: 'http://127.0.0.1:4300', + probePath: '/', + browsable: true, + configPaths: [{ label: 'Portal app', path: 'portal' }] + }), + shared({ + id: 'home', + label: 'Solmara Lab Visitor Center', + blurb: 'The out-of-fiction front door to the synthetic country.', + self: true, + browsable: true, + configPaths: [{ label: 'Home app', path: 'home' }] + }) ]; export type StatusProbe = { @@ -281,55 +225,66 @@ export type StatusProbe = { href?: string; }; -/** - * Resolve the concrete probe URL and visitor-facing "Open" link for each - * service. `portalUrl` is the browser-facing portal origin used for the Open - * link, while the probe still targets the compose-internal portal URL. - */ -export function statusProbes(portalUrl: string, readEnv: Record = env): StatusProbe[] { +export function statusProbes( + portalUrl: string, + readEnv: Record = env +): StatusProbe[] { return SERVICES.map((service) => { const base = service.probeEnv ? readEnv[service.probeEnv] ?? service.probeDefault : undefined; const probeUrl = base && service.probePath ? joinUrl(base, service.probePath) : undefined; let href: string | undefined; if (service.browsable) { - if (service.id === 'metadata') href = '/.well-known/api-catalog'; + if (service.id === 'deterministic-publisher') href = '/.well-known/api-catalog'; else if (service.id === 'portal') href = portalUrl; else if (service.id === 'home') href = '/'; } - return { id: service.id, label: service.label, role: service.role, self: Boolean(service.self), probeUrl, href }; + return { + id: service.id, + label: service.label, + role: service.role, + self: Boolean(service.self), + probeUrl, + href + }; }); } -/** - * Group the topology for the anatomy page: relays and Notaries under their - * authority, and shared services such as the child-benefit evidence collector. - * Config paths become repo links, with the - * in-repo relative path preserved as visible text. - */ export function topologyGroups(repoUrl: string): TopologyGroup[] { - const shared = SERVICES.filter((service) => service.role === 'shared'); - const relays = SERVICES.filter((service) => service.role === 'relay'); - const notaries = SERVICES.filter((service) => service.role === 'notary'); + const byId = (ids: string[]) => ids.map((id) => { + const service = SERVICES.find((candidate) => candidate.id === id); + if (!service) throw new Error(`Unknown topology service: ${id}`); + return toTopologyService(service, repoUrl); + }); return [ + { + key: 'publisher', + title: 'Deterministic publisher', + blurb: 'One reviewed synthetic input produces public metadata, authority-owned immutable extracts, and Relay SQLite sources deterministically.', + services: byId(['deterministic-publisher']) + }, { key: 'relays', - title: 'One Relay per authority', - blurb: - 'Each authority runs its own Relay over data it already holds. Nothing is copied into a central store, so a compromise or outage is contained to a single authority and each audit chain stays independent.', - services: relays.map((service) => toTopologyService(service, repoUrl)) + title: 'Five Relay V2 projects', + blurb: 'Each live source is exposed through bounded, purpose-protected exact lookups. Direct-only SRO evidence has no Relay.', + services: byId(['cra-relay', 'nia-relay', 'mosd-relay', 'sipf-relay', 'nagdi-relay']) + }, + { + key: 'evidence', + title: 'Six authority Evidence cells', + blurb: 'Each authority has its own Evidence service identity, issuer, reviewed requirements, and origin. The lab Mint is shared infrastructure, never the evidence issuer.', + services: byId(['cra-evidence', 'nia-evidence', 'sro-evidence', 'mosd-programme-evidence', 'sipf-evidence', 'nagdi-evidence', 'lab-mint']) }, { - key: 'notaries', - title: 'Source-owned Notaries', - blurb: - 'Each authority runs one Notary beside its Relay. Applications collect minimized, source-attributed predicates without moving raw rows or asking a Notary to compose the final programme decision.', - services: notaries.map((service) => toTopologyService(service, repoUrl)) + key: 'programme', + title: 'Programme and visitor applications', + blurb: 'The child benefit app combines signed authority values into a programme decision. The scenario runner, portal, and Visitor Center present the result.', + services: byId(['child-benefit-programme', 'scenario-runner', 'portal', 'home']) }, { - key: 'shared', - title: 'Shared services', - blurb: 'The pieces every authority leans on: metadata publishing, scenario execution, and the two front doors.', - services: shared.map((service) => toTopologyService(service, repoUrl)) + key: 'identity', + title: 'Optional resident identity', + blurb: 'eSignet is optional and uses the NIA Relay for identity resolution. It is not a national Evidence service.', + services: byId(['esignet']) } ]; } diff --git a/home/src/lib/server/tokens.test.ts b/home/src/lib/server/tokens.test.ts index 66cf3ab..4ecc12b 100644 --- a/home/src/lib/server/tokens.test.ts +++ b/home/src/lib/server/tokens.test.ts @@ -1,205 +1,32 @@ import { describe, expect, it } from 'vitest'; -import { buildCurlExamples, parsePublishedTokens, publishRequestTokens } from './tokens'; +import { buildCurlExamples } from './tokens'; -describe('published-token allowlist', () => { - it('renders only the tokens named in the allowlist JSON', () => { - const tokens = parsePublishedTokens('{"child-benefit-federator":"tok-child","sipf-pension-client":"tok-pension"}'); - expect(tokens.map((token) => token.name)).toEqual(['child-benefit-federator', 'sipf-pension-client']); - expect(tokens.map((token) => token.token)).toEqual(['tok-child', 'tok-pension']); - }); - - it('never surfaces a token that is not a value in the allowlist', () => { - // A container may hold many notary tokens; only those explicitly listed in - // HOME_PUBLISHED_TOKENS may ever reach page data. - const tokens = parsePublishedTokens('{"child-benefit-federator":"tok-published"}'); - const serialised = JSON.stringify(tokens); - expect(serialised).toContain('tok-published'); - expect(serialised).not.toContain('tok-secret-not-published'); - }); - - it('does not fall back to any other environment variable', () => { - // With no allowlist provided, no token is ever produced, even if individual - // *_NOTARY_TOKEN variables exist in the environment. - expect(parsePublishedTokens(undefined)).toEqual([]); - expect(parsePublishedTokens('')).toEqual([]); - }); - - it('returns an empty list for a malformed allowlist rather than throwing', () => { - expect(parsePublishedTokens('not json')).toEqual([]); - expect(parsePublishedTokens('[]')).toEqual([]); - expect(parsePublishedTokens('{"empty":""}')).toEqual([]); - }); +describe('engineer curl examples', () => { + it('uses distinct authority endpoints and JSON-body purposes', () => { + const examples = buildCurlExamples(); + const commands = examples.map((example) => example.command).join('\n'); - it('builds four curl examples including the skeptic wrong-purpose call', () => { - const tokens = parsePublishedTokens('{"child-benefit-federator":"tok-child"}'); - const examples = buildCurlExamples(tokens); expect(examples.map((example) => example.id)).toEqual([ 'metadata-get', - 'claims-get', - 'evaluate-post', + 'cra-evidence-post', + 'nia-evidence-post', + 'programme-post', 'wrong-purpose-post' ]); - const wrong = examples.find((example) => example.id === 'wrong-purpose-post'); - expect(wrong?.command).toContain('pension-payment-review'); - // The published token is inlined into the authenticated examples. - expect(examples.find((example) => example.id === 'claims-get')?.command).toContain('tok-child'); - // The unauthenticated metadata example never carries a token. - expect(examples.find((example) => example.id === 'metadata-get')?.command).not.toContain('tok-child'); - }); - - it('uses an env-var placeholder in curls when no child token is published', () => { - const examples = buildCurlExamples([]); - expect(examples.find((example) => example.id === 'claims-get')?.command).toContain('$CHILD_BENEFIT_FEDERATOR_TOKEN'); - }); - - it('republishes only allowlisted lab tokens into story request sources', () => { - const tokens = parsePublishedTokens('{"child-benefit-federator":"tok-child"}'); - const result = publishRequestTokens( - { - request_source: { - method: 'POST', - url: 'http://localhost:4321/v1/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/child-benefit-review' - } - }, - credential_source: { - method: 'POST', - url: 'http://sipf-notary:8081/v1/credentials', - headers: { 'x-api-key': '[runtime token hidden]' } - } - }, - tokens - ); - - expect(result.request_source.headers['x-api-key']).toBe('tok-child'); - expect(result.credential_source.headers['x-api-key']).toBe('[runtime token hidden]'); - }); - - it('does not publish a token from purpose alone when the authority URL is unknown', () => { - const tokens = parsePublishedTokens('{"nagdi-notary":"tok-nagdi"}'); - const result = publishRequestTokens( - { - request_source: { - method: 'POST', - url: 'https://lab.example/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/livestock-movement-control' - } - } - }, - tokens - ); - - expect(result.request_source.headers['x-api-key']).toBe('[runtime token hidden]'); - }); - - it('does not guess between authority clients when purpose or URL binding is incomplete', () => { - const tokens = parsePublishedTokens( - '{"cra-pension-client":"tok-cra-pension","cra-citizen-client":"tok-cra-citizen","nia-citizen-client":"tok-nia-citizen","sipf-pension-client":"tok-sipf-pension"}' - ); - const result = publishRequestTokens( - { - request_sources: [ - { - method: 'POST', - url: 'https://unmapped.example/v1/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/pension-payment-review' - } - }, - { - method: 'POST', - url: 'http://cra-notary:8081/v1/evaluations', - headers: { 'x-api-key': '[runtime token hidden]' } - } - ] - }, - tokens - ); - - expect(result.request_sources[0].headers['x-api-key']).toBe('[runtime token hidden]'); - expect(result.request_sources[1].headers['x-api-key']).toBe('[runtime token hidden]'); - }); - - it('does not publish a purpose token to a different known authority endpoint', () => { - const tokens = parsePublishedTokens('{"child-benefit-federator":"tok-child"}'); - const result = publishRequestTokens( - { - request_source: { - method: 'POST', - url: 'http://cra-notary:8081/v1/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/child-benefit-review' - } - } - }, - tokens - ); - - expect(result.request_source.headers['x-api-key']).toBe('[runtime token hidden]'); - }); - - it('publishes authority client tokens by both Notary URL and purpose', () => { - const tokens = parsePublishedTokens( - JSON.stringify({ - 'cra-pension-client': 'tok-cra-pension', - 'cra-citizen-client': 'tok-cra-citizen', - 'nia-citizen-client': 'tok-nia-citizen', - 'sipf-pension-client': 'tok-sipf-pension' - }) - ); - const result = publishRequestTokens( - { - request_sources: [ - { - method: 'POST', - url: 'http://cra-notary:8081/v1/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/pension-payment-review' - } - }, - { - method: 'POST', - url: 'http://nia-notary:8081/v1/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/citizen-self-service' - } - } - ], - credential_source: { - method: 'POST', - url: 'http://sipf-notary:8081/v1/credentials', - headers: { - 'x-api-key': '[runtime token hidden]', - 'data-purpose': 'https://id.registrystack.org/solmara/purpose/survivor-benefit-determination' - } - }, - source_trace: [ - { - request_source: { - method: 'POST', - url: 'http://cra-notary:8081/v1/evaluations', - headers: { - 'x-api-key': '[runtime token hidden]', - 'Data-Purpose': 'https://id.registrystack.org/solmara/purpose/citizen-self-service' - } - } - } - ] - }, - tokens - ); - - expect(result.request_sources[0].headers['x-api-key']).toBe('tok-cra-pension'); - expect(result.request_sources[1].headers['x-api-key']).toBe('tok-nia-citizen'); - expect(result.credential_source.headers['x-api-key']).toBe('tok-sipf-pension'); - expect(result.source_trace[0].request_source.headers['x-api-key']).toBe('tok-cra-citizen'); + expect(commands).toContain('cra-evidence.solmara.registrystack.org/v1/evidence'); + expect(commands).toContain('nia-evidence.solmara.registrystack.org/v1/evidence'); + expect(commands).toContain('"purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review"'); + expect(commands).toContain('"requirement":"https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1"'); + expect(commands).not.toContain('/purpose/child-benefit-review/v1'); + expect(commands).not.toContain('Data-Purpose'); + }); + + it('publishes placeholders, never token or subject values', () => { + const commands = buildCurlExamples().map((example) => example.command).join('\n'); + expect(commands).toContain('$CRA_EVIDENCE_ACCESS_TOKEN'); + expect(commands).toContain('$SOLMARA_UIN'); + expect(commands).toContain('$REQUEST_NONCE'); + expect(commands).not.toMatch(/Bearer\s+(?!\$)[A-Za-z0-9._-]+/); + expect(commands).not.toMatch(/\bFR-\d+\b|\b[2-9]\d{9}\b/); }); }); diff --git a/home/src/lib/server/tokens.ts b/home/src/lib/server/tokens.ts index 1d695be..d8bb1c2 100644 --- a/home/src/lib/server/tokens.ts +++ b/home/src/lib/server/tokens.ts @@ -1,234 +1,98 @@ -import { env } from '$env/dynamic/private'; import { buildPublicUrlMap, mapPublicUrl } from './urlmap'; -import type { CurlExample, PublishedToken } from '$lib/types'; +import type { CurlExample } from '$lib/types'; const CHILD_PURPOSE = 'https://id.registrystack.org/solmara/purpose/child-benefit-review'; const PENSION_PURPOSE = 'https://id.registrystack.org/solmara/purpose/pension-payment-review'; -const SURVIVOR_PURPOSE = 'https://id.registrystack.org/solmara/purpose/survivor-benefit-determination'; -const VOUCHER_PURPOSE = 'https://id.registrystack.org/solmara/purpose/voucher-eligibility-review'; -const LIVESTOCK_PURPOSE = 'https://id.registrystack.org/solmara/purpose/livestock-movement-control'; -const CITIZEN_PURPOSE = 'https://id.registrystack.org/solmara/purpose/citizen-self-service'; - -const TOKEN_BINDINGS: Record = { - 'child-benefit-federator': { - urlHints: ['child-benefit-federator', 'localhost:4321', '127.0.0.1:4321'], - purposes: [CHILD_PURPOSE] - }, - 'cra-pension-client': { - urlHints: ['cra-notary', 'localhost:4325', '127.0.0.1:4325'], - purposes: [PENSION_PURPOSE] - }, - 'cra-citizen-client': { - urlHints: ['cra-notary', 'localhost:4325', '127.0.0.1:4325'], - purposes: [CITIZEN_PURPOSE] - }, - 'nia-citizen-client': { - urlHints: ['nia-notary', 'localhost:4326', '127.0.0.1:4326'], - purposes: [CITIZEN_PURPOSE] - }, - 'sipf-pension-client': { - urlHints: ['sipf-notary', 'localhost:4322', '127.0.0.1:4322'], - purposes: [PENSION_PURPOSE, SURVIVOR_PURPOSE] - }, - 'nagdi-notary': { - urlHints: ['nagdi-notary', 'localhost:4323', '127.0.0.1:4323'], - purposes: [VOUCHER_PURPOSE, LIVESTOCK_PURPOSE] - } -}; - -/** - * Notes and purposes for the names the lab deliberately publishes. Only names in - * `HOME_PUBLISHED_TOKENS` are ever surfaced; this map only supplies copy. - */ -const TOKEN_NOTES: Record = { - 'child-benefit-federator': { - purpose: CHILD_PURPOSE, - note: 'Scoped to the child benefit federator. Use it to collect source-owned predicates under child-benefit-review.' - }, - 'cra-pension-client': { - purpose: PENSION_PURPOSE, - note: 'Scoped to CRA civil evidence used during pension payment review.' - }, - 'cra-citizen-client': { - purpose: CITIZEN_PURPOSE, - note: 'Scoped to CRA civil evidence used by citizen self-service.' - }, - 'nia-citizen-client': { - purpose: CITIZEN_PURPOSE, - note: 'Scoped to NIA population evidence and credential issuance used by citizen self-service.' - }, - 'sipf-pension-client': { - purpose: PENSION_PURPOSE, - note: 'Scoped to SIPF evidence for pension payment and survivor benefit review.' - }, - 'nagdi-notary': { - purpose: VOUCHER_PURPOSE, - note: 'Scoped to the NAgDI notary for farmer voucher and livestock movement review.' - } -}; - -/** - * Parse the server-side published-token allowlist. This is the ONLY source of - * tokens the page ever renders: a token that is not a value in - * `HOME_PUBLISHED_TOKENS` can never reach page data, no matter what other token - * environment variables the container holds. A malformed allowlist yields an - * empty list rather than falling back to any other env. - */ -export function parsePublishedTokens(json: string | undefined = env.HOME_PUBLISHED_TOKENS): PublishedToken[] { - if (!json) return []; - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return []; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []; - const tokens: PublishedToken[] = []; - for (const [name, value] of Object.entries(parsed as Record)) { - if (typeof value !== 'string' || value.length === 0) continue; - const meta = TOKEN_NOTES[name]; - tokens.push({ - name, - token: value, - purpose: meta?.purpose, - note: meta?.note ?? 'Synthetic lab token, safe to publish. Scoped to one notary.' - }); - } - return tokens; -} /** - * Story run results arrive from the scenario runner with runtime auth redacted. - * The visitor center may republish only the synthetic lab tokens listed in - * HOME_PUBLISHED_TOKENS so story-level copy-as-curl snippets are reproducible. + * Build copyable examples without publishing a credential, identifier, selector + * value, or nonce. Runtime credentials stay server-side; shell placeholders make + * the trust boundary visible to engineers. */ -export function publishRequestTokens>(result: T, tokens: PublishedToken[]): T { - const published = new Map(tokens.map((token) => [token.name, token.token])); - const clone: Record = { ...result }; - for (const key of ['request_source', 'credential_source']) { - clone[key] = publishRequestSourceToken(clone[key], published); - } - if (Array.isArray(clone.request_sources)) { - clone.request_sources = clone.request_sources.map((source) => publishRequestSourceToken(source, published)); - } - if (Array.isArray(clone.source_trace)) { - clone.source_trace = clone.source_trace.map((item) => { - if (!isObject(item)) return item; - return { ...item, request_source: publishRequestSourceToken(item.request_source, published) }; - }); - } - return clone as T; -} - -function publishRequestSourceToken(source: unknown, published: Map): unknown { - if (!isObject(source)) return source; - const headers = isObject(source.headers) ? stringHeaders(source.headers) : {}; - const purpose = Object.entries(headers).find(([name]) => name.toLowerCase() === 'data-purpose')?.[1]; - const token = tokenCandidates(source.url, purpose) - .map((name) => published.get(name)) - .find((value): value is string => typeof value === 'string'); - if (!token) return source; - - const apiKeyHeader = Object.keys(headers).find((key) => key.toLowerCase() === 'x-api-key'); - if (!apiKeyHeader) return source; - return { - ...source, - headers: { - ...headers, - [apiKeyHeader]: token - } - }; -} - -function tokenCandidates(url: unknown, purpose: string | undefined): string[] { - const urlNames = tokenNamesForUrl(url); - const purposeNames = tokenNamesForPurpose(purpose); - const exactNames = urlNames.filter((name) => purposeNames.includes(name)); - return exactNames.length === 1 ? exactNames : []; -} - -function tokenNamesForUrl(value: unknown): string[] { - if (typeof value !== 'string') return []; - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return []; - } - const urlText = `${parsed.host}${parsed.pathname}`.toLowerCase(); - return Object.entries(TOKEN_BINDINGS) - .filter(([, binding]) => binding.urlHints.some((hint) => urlText.includes(hint))) - .map(([name]) => name); -} - -function tokenNamesForPurpose(value: string | undefined): string[] { - if (!value) return []; - return Object.entries(TOKEN_BINDINGS) - .filter(([, binding]) => binding.purposes.includes(value)) - .map(([name]) => name); -} - -function stringHeaders(headers: Record): Record { - return Object.fromEntries( - Object.entries(headers) - .filter((entry): entry is [string, string] => typeof entry[1] === 'string') - ); -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** - * Build the four copy-as-curl examples for the engineer door: an unauthenticated - * metadata read, a claim-catalogue read with a published token, an evaluation - * POST under a permitted purpose, and the skeptic's wrong-purpose POST that gets - * a clean 403. URLs are rewritten to host-reachable origins through the same map - * the rest of the site uses. The child benefit token is only inlined when it is - * actually in the allowlist; otherwise the example references the env var name. - */ -export function buildCurlExamples(tokens: PublishedToken[]): CurlExample[] { +export function buildCurlExamples(): CurlExample[] { const map = buildPublicUrlMap(); - const metadataUrl = mapPublicUrl('http://static-metadata:8080/metadata/catalog.json', map); - const claimsUrl = mapPublicUrl('http://child-benefit-federator:8080/v1/claims', map); - const evalUrl = mapPublicUrl('http://child-benefit-federator:8080/v1/evaluations', map); - const published = tokens.find((token) => token.name === 'child-benefit-federator') ?? tokens.find((token) => token.name.includes('child')); - const tokenValue = published?.token ?? '$CHILD_BENEFIT_FEDERATOR_TOKEN'; - const body = - '{"target":{"type":"Person","identifiers":[{"scheme":"solmara_uin","value":"2300010248"}]},' + - '"claims":["birth-is-registered"],"disclosure":"predicate",' + - '"format":"application/json"}'; + const metadataUrl = mapPublicUrl('http://deterministic-publisher:8080/metadata/catalog.json', map); + const craUrl = mapPublicUrl('https://cra-evidence.solmara.registrystack.org/v1/evidence', map); + const niaUrl = mapPublicUrl('https://nia-evidence.solmara.registrystack.org/v1/evidence', map); + const programmeUrl = mapPublicUrl('http://child-benefit-federator:8080/v1/evaluations', map); + const craBody = evidenceBody( + 'https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1', + CHILD_PURPOSE + ); + const niaBody = evidenceBody( + 'https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1', + CHILD_PURPOSE + ); return [ { id: 'metadata-get', - title: 'Read the published metadata (no auth)', - note: 'The metadata bundle is public. No token, no purpose header.', + title: 'Read the deterministic publication', + note: 'Published metadata is public. The deterministic publisher is not an Evidence authority.', command: `curl -sS '${metadataUrl}'` }, { - id: 'claims-get', - title: 'List the child benefit evidence catalogue (published token)', - note: 'The token is a synthetic lab credential scoped to the child benefit federator.', - command: `curl -sS '${claimsUrl}' \\\n -H 'x-api-key: ${tokenValue}' \\\n -H 'Data-Purpose: ${CHILD_PURPOSE}'` + id: 'cra-evidence-post', + title: 'Ask CRA Evidence for one minimized value', + note: 'Purpose is a JSON member. Supply the scoped token, nonce, and UIN from your local shell.', + command: evidenceCurl(craUrl, '$CRA_EVIDENCE_ACCESS_TOKEN', craBody) }, { - id: 'evaluate-post', - title: 'Collect source-owned predicates under a permitted purpose', - note: 'The application collects purpose-limited predicates and a source trace, never source rows.', + id: 'nia-evidence-post', + title: 'Ask NIA Evidence independently', + note: 'CRA and NIA are distinct Evidence services with distinct issuers and audiences.', + command: evidenceCurl(niaUrl, '$NIA_EVIDENCE_ACCESS_TOKEN', niaBody) + }, + { + id: 'programme-post', + title: 'Run the child-benefit programme collection', + note: 'The programme sends its governed purpose in the JSON body and collects separately signed authority results.', command: - `curl -sS -X POST '${evalUrl}' \\\n -H 'x-api-key: ${tokenValue}' \\\n` + - ` -H 'Data-Purpose: ${CHILD_PURPOSE}' \\\n -H 'Accept: application/json' \\\n` + - ` -H 'Content-Type: application/json' \\\n -d '${body}'` + `curl -sS -X POST '${programmeUrl}' \\\n` + + ` -H 'x-api-key: $CHILD_BENEFIT_PROGRAMME_TOKEN' \\\n` + + ` -H 'Content-Type: application/json' \\\n` + + ` -d '${programmeBody(CHILD_PURPOSE)}'` }, { id: 'wrong-purpose-post', - title: 'Skeptic path: the same request under a wrong purpose', - note: 'Ask under a purpose this evidence collector does not permit and get a clean 403 with a stable problem code.', - command: - `curl -sS -X POST '${evalUrl}' \\\n -H 'x-api-key: ${tokenValue}' \\\n` + - ` -H 'Data-Purpose: ${PENSION_PURPOSE}' \\\n -H 'Accept: application/json' \\\n` + - ` -H 'Content-Type: application/json' \\\n -d '${body}'` + title: 'Skeptic path: an unapproved JSON purpose', + note: 'The same requirement under a pension purpose is refused with the current not_authorized problem code.', + command: evidenceCurl(craUrl, '$CRA_EVIDENCE_ACCESS_TOKEN', evidenceBody( + 'https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1', + PENSION_PURPOSE + )) } ]; } + +function evidenceBody(requirement: string, purpose: string): string { + return JSON.stringify({ + requestNonce: '$REQUEST_NONCE', + requirement, + purpose, + subjects: [{ role: 'subject', selector: { profile: 'solmara-uin-v1', values: { uin: '$SOLMARA_UIN' } } }] + }); +} + +function programmeBody(purpose: string): string { + return JSON.stringify({ + purpose, + target: { + type: 'Person', + identifiers: [{ scheme: 'solmara_uin', value: '$SOLMARA_UIN' }] + }, + claims: [ + 'birth-is-registered', + 'population-record-active', + 'child-age-under-5', + 'household-below-poverty-threshold', + 'not-already-enrolled' + ], + disclosure: 'predicate', + format: 'application/json' + }); +} + +function evidenceCurl(url: string, token: string, body: string): string { + return `curl -sS -X POST '${url}' \\\n -H 'Authorization: Bearer ${token}' \\\n -H 'Content-Type: application/json' \\\n -d '${body}'`; +} diff --git a/home/src/lib/server/urlmap.test.ts b/home/src/lib/server/urlmap.test.ts index 1286bbd..7a23124 100644 --- a/home/src/lib/server/urlmap.test.ts +++ b/home/src/lib/server/urlmap.test.ts @@ -2,104 +2,35 @@ import { describe, expect, it } from 'vitest'; import { buildPublicUrlMap, mapPublicUrl, rewriteRequestUrls } from './urlmap'; describe('public URL map', () => { - it('rewrites compose-internal notary hostnames to host-reachable localhost ports', () => { + it('keeps each public authority Evidence origin distinct', () => { const map = buildPublicUrlMap(); - expect(mapPublicUrl('http://child-benefit-federator:8080/v1/evaluations', map)).toBe( - 'http://localhost:4321/v1/evaluations' - ); - expect( - [ - 'cra-notary:8081', - 'nia-notary:8081', - 'sro-notary:8081', - 'programme-notary:8081', - 'sipf-notary:8081', - 'nagdi-notary:8081' - ].map((host) => mapPublicUrl(`http://${host}/v1/claims`, map)) - ).toEqual([ - 'http://localhost:4325/v1/claims', - 'http://localhost:4326/v1/claims', - 'http://localhost:4327/v1/claims', - 'http://localhost:4328/v1/claims', - 'http://localhost:4322/v1/claims', - 'http://localhost:4323/v1/claims' - ]); + expect(mapPublicUrl('https://cra-evidence.solmara.registrystack.org/v1/evidence', map)).toContain('cra-evidence'); + expect(mapPublicUrl('https://nia-evidence.solmara.registrystack.org/v1/evidence', map)).toContain('nia-evidence'); }); - it('rewrites relay and metadata hostnames from the same table', () => { + it('rewrites the five Relay V2 and programme hostnames', () => { const map = buildPublicUrlMap(); - expect(mapPublicUrl('http://cra-civil-relay:8080/', map)).toBe('http://localhost:4311/'); - expect(mapPublicUrl('http://static-metadata:8080/metadata/catalog.json', map)).toBe( - 'http://localhost:4331/metadata/catalog.json' - ); + expect(mapPublicUrl('http://cra-relay:8080/ready', map)).toBe('http://localhost:4311/ready'); + expect(mapPublicUrl('http://sipf-relay:8080/ready', map)).toBe('http://localhost:4315/ready'); + expect(mapPublicUrl('http://child-benefit-federator:8080/v1/evaluations', map)).toBe('http://localhost:4321/v1/evaluations'); }); - it('preserves path and query while swapping the origin', () => { - const map = buildPublicUrlMap(); - expect(mapPublicUrl('http://cra-notary:8081/v1/evaluations?trace=1', map)).toBe( - 'http://localhost:4325/v1/evaluations?trace=1' - ); - }); - - it('leaves already host-reachable URLs untouched', () => { - const map = buildPublicUrlMap(); - expect(mapPublicUrl('http://localhost:4321/v1/claims', map)).toBe('http://localhost:4321/v1/claims'); - expect(mapPublicUrl('https://child-benefit-federator.solmara.registrystack.org/v1/claims', map)).toBe( - 'https://child-benefit-federator.solmara.registrystack.org/v1/claims' - ); - }); - - it('returns non-URL strings unchanged', () => { - const map = buildPublicUrlMap(); - expect(mapPublicUrl('not a url', map)).toBe('not a url'); - expect(mapPublicUrl('', map)).toBe(''); - }); - - it('merges an env-provided JSON override over the defaults', () => { - const map = buildPublicUrlMap( - JSON.stringify({ - 'child-benefit-federator:8080': 'https://child-benefit-federator.solmara.registrystack.org' - }) - ); - expect(mapPublicUrl('http://child-benefit-federator:8080/v1/claims', map)).toBe( - 'https://child-benefit-federator.solmara.registrystack.org/v1/claims' - ); - // untouched defaults still apply - expect(mapPublicUrl('http://sipf-notary:8081/v1/claims', map)).toBe( - 'http://localhost:4322/v1/claims' - ); + it('merges an environment override and preserves path and query', () => { + const map = buildPublicUrlMap(JSON.stringify({ 'cra-evidence.solmara.registrystack.org': 'https://cra.example' })); + expect(mapPublicUrl('https://cra-evidence.solmara.registrystack.org/v1/evidence?trace=1', map)).toBe('https://cra.example/v1/evidence?trace=1'); }); - it('rewrites request_source and credential_source urls inside a run result', () => { + it('rewrites nested sources without mutating the input', () => { const map = buildPublicUrlMap(); const result = { - request_source: { method: 'POST', url: 'http://child-benefit-federator:8080/v1/evaluations', headers: {} }, - credential_source: { method: 'POST', url: 'http://sipf-notary:8081/v1/credentials', headers: {} }, - request_sources: [{ method: 'POST', url: 'http://nia-notary:8081/v1/evaluations', headers: {} }], - source_trace: [ - { - request_source: { - method: 'POST', - url: 'http://cra-notary:8081/v1/evaluations', - headers: {} - } - }, - { - request_summary: { - method: 'POST', - url: 'http://sro-notary:8081/v1/evaluations' - } - } - ], - response_source: { status: 200 } + request_source: { method: 'POST', url: 'http://cra-relay:8080/v2/resources/civil/lookup' }, + request_sources: [{ method: 'POST', url: 'http://nia-relay:8080/v2/resources/population/lookup' }], + source_trace: [{ request_source: { method: 'POST', url: 'http://sipf-relay:8080/v2/resources/pension/lookup' } }] }; const mapped = rewriteRequestUrls(result, map); - expect(mapped.request_source.url).toBe('http://localhost:4321/v1/evaluations'); - expect(mapped.credential_source.url).toBe('http://localhost:4322/v1/credentials'); - expect(mapped.request_sources[0].url).toBe('http://localhost:4326/v1/evaluations'); - expect(mapped.source_trace[0].request_source?.url).toBe('http://localhost:4325/v1/evaluations'); - expect(mapped.source_trace[1].request_summary?.url).toBe('http://localhost:4327/v1/evaluations'); - // does not mutate the original - expect(result.request_source.url).toBe('http://child-benefit-federator:8080/v1/evaluations'); + expect(mapped.request_source.url).toContain('localhost:4311'); + expect(mapped.request_sources[0].url).toContain('localhost:4312'); + expect(mapped.source_trace[0].request_source?.url).toContain('localhost:4315'); + expect(result.request_source.url).toContain('cra-relay:8080'); }); }); diff --git a/home/src/lib/server/urlmap.ts b/home/src/lib/server/urlmap.ts index aa50027..eb6e750 100644 --- a/home/src/lib/server/urlmap.ts +++ b/home/src/lib/server/urlmap.ts @@ -15,18 +15,19 @@ import { env } from '$env/dynamic/private'; */ const DEFAULT_LOCAL_MAP: Record = { 'child-benefit-federator:8080': 'http://localhost:4321', - 'cra-notary:8081': 'http://localhost:4325', - 'nia-notary:8081': 'http://localhost:4326', - 'sro-notary:8081': 'http://localhost:4327', - 'programme-notary:8081': 'http://localhost:4328', - 'sipf-notary:8081': 'http://localhost:4322', - 'nagdi-notary:8081': 'http://localhost:4323', - 'cra-civil-relay:8080': 'http://localhost:4311', - 'nia-population-relay:8080': 'http://localhost:4312', - 'sro-social-relay:8080': 'http://localhost:4313', - 'programme-mis-relay:8080': 'http://localhost:4314', - 'sipf-pensions-relay:8080': 'http://localhost:4315', - 'nagdi-agriculture-relay:8080': 'http://localhost:4316', + 'deterministic-publisher:8080': 'http://localhost:4331', + 'cra-evidence.solmara.registrystack.org': 'https://cra-evidence.solmara.registrystack.org', + 'nia-evidence.solmara.registrystack.org': 'https://nia-evidence.solmara.registrystack.org', + 'sro-evidence.solmara.registrystack.org': 'https://sro-evidence.solmara.registrystack.org', + 'mosd-programme-evidence.solmara.registrystack.org': 'https://mosd-programme-evidence.solmara.registrystack.org', + 'sipf-evidence.solmara.registrystack.org': 'https://sipf-evidence.solmara.registrystack.org', + 'nagdi-evidence.solmara.registrystack.org': 'https://nagdi-evidence.solmara.registrystack.org', + 'mint:8081': 'https://localhost:4341', + 'cra-relay:8080': 'http://localhost:4311', + 'nia-relay:8080': 'http://localhost:4312', + 'mosd-relay:8080': 'http://localhost:4314', + 'sipf-relay:8080': 'http://localhost:4315', + 'nagdi-relay:8080': 'http://localhost:4316', 'static-metadata:8080': 'http://localhost:4331', 'scenario-runner:8080': 'http://localhost:4302', 'portal:4000': 'http://localhost:4300' diff --git a/home/src/lib/types.ts b/home/src/lib/types.ts index 9e21db1..f4a6839 100644 --- a/home/src/lib/types.ts +++ b/home/src/lib/types.ts @@ -49,7 +49,7 @@ export type ConfigLink = { export type TopologyService = { id: string; label: string; - role: 'shared' | 'relay' | 'notary'; + role: 'shared' | 'relay' | 'evidence'; authority?: string; purpose?: string; blurb: string; @@ -76,13 +76,6 @@ export type SeedSummary = { observedAt?: string; }; -export type PublishedToken = { - name: string; - token: string; - purpose?: string; - note: string; -}; - export type CurlExample = { id: string; title: string; @@ -108,8 +101,15 @@ export type ScenarioStep = { request_preview?: { method: string; url: string; - headers: Record; + headers?: Record; + purpose?: string; body?: unknown; + requests?: Array<{ + method: string; + url: string; + headers?: Record; + body?: unknown; + }>; }; }; @@ -130,8 +130,10 @@ export type Scenario = { export type RequestSource = { method: string; url: string; - headers: Record; + headers?: Record; + purpose?: string; body?: unknown; + requests?: RequestSource[]; }; export type ResponseSource = { @@ -186,6 +188,20 @@ export type StepRunResult = { credential_source?: RequestSource; credential_response_source?: ResponseSource; credential?: CredentialSummary; + results?: Array>; + result_state?: string; + presentation?: EvidencePresentation; + presentations?: EvidencePresentation[]; +}; + +export type EvidencePresentation = { + authority: string; + service_id?: string; + issuer: string; + provider?: string; + source: 'immutable extract' | 'Relay lookup'; + status?: number; + claims?: string[]; }; export type StepRunEnvelope = { @@ -196,6 +212,13 @@ export type StepRunEnvelope = { }; export type MetadataAuthority = { id: string; name: string; iri?: string }; +export type MetadataDataService = { + id: string; + iri?: string; + endpoint_url?: string; + endpoint_description?: string; + title?: Record; +}; export type MetadataPublicService = { id: string; iri?: string; @@ -213,6 +236,7 @@ export type MetadataBundle = { datasets: MetadataDataset[]; gray_registries: GrayRegistry[]; authorities: MetadataAuthority[]; + data_services: MetadataDataService[]; public_services: MetadataPublicService[]; }; offerings: MetadataOffering[]; @@ -240,7 +264,7 @@ export type MetadataOffering = { public_services?: string[]; lookup_keys?: string[]; issuing_authority?: { id?: string; name?: string; iri?: string }; - access?: { endpoint_url?: string; discovery_url?: string; kind?: string; ruleset?: string }; + access?: { endpoint_url?: string; discovery_url?: string; kind?: string; ruleset?: string; source_type?: string }; semantics?: { concepts?: string[]; application_profiles?: string[] }; policy?: string; }; @@ -266,7 +290,6 @@ export type HomeData = { versions: Record; smoke: SmokeEvidence; seed: SeedSummary; - publishedTokens: PublishedToken[]; curlExamples: CurlExample[]; changelogLatest: ChangelogEntry | null; repoUrl: string; diff --git a/home/src/routes/+page.svelte b/home/src/routes/+page.svelte index a77cb54..d680e74 100644 --- a/home/src/routes/+page.svelte +++ b/home/src/routes/+page.svelte @@ -39,7 +39,7 @@ - +
diff --git a/home/src/routes/anatomy/+page.svelte b/home/src/routes/anatomy/+page.svelte index 6c9c369..48239b5 100644 --- a/home/src/routes/anatomy/+page.svelte +++ b/home/src/routes/anatomy/+page.svelte @@ -14,14 +14,14 @@ Anatomy · Solmara Lab - +
@@ -39,7 +39,7 @@

Sovereignty invariant

One authority, one source-owned boundary.

- No authority hands its rows to a central store. Each keeps an independent audit chain, so + No authority hands its rows to a central store. Each keeps an independent accountability boundary, so a fault or compromise stays contained rather than becoming a country-wide data exposure.

diff --git a/home/src/routes/api/scenarios/[scenario]/steps/[step]/run/+server.ts b/home/src/routes/api/scenarios/[scenario]/steps/[step]/run/+server.ts index 811a949..6166e8d 100644 --- a/home/src/routes/api/scenarios/[scenario]/steps/[step]/run/+server.ts +++ b/home/src/routes/api/scenarios/[scenario]/steps/[step]/run/+server.ts @@ -1,18 +1,15 @@ import { json, error } from '@sveltejs/kit'; -import { readPurposes } from '$lib/server/data'; import { joinedUrl, runtime } from '$lib/server/runtime'; -import { buildPublicUrlMap, rewriteRequestUrls } from '$lib/server/urlmap'; -import { parsePublishedTokens, publishRequestTokens } from '$lib/server/tokens'; +import { buildPublicUrlMap, mapPublicUrl } from '$lib/server/urlmap'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ fetch, params, request }) => { - const payload = await runnerPayload(request); +export const POST: RequestHandler = async ({ fetch, params }) => { const response = await fetch( joinedUrl(runtime.scenarioRunnerUrl, `/v1/scenarios/${params.scenario}/steps/${params.step}/run`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: '{}' } ); if (!response.ok) { @@ -20,28 +17,115 @@ export const POST: RequestHandler = async ({ fetch, params, request }) => { } const body = (await response.json()) as { result?: Record }; if (body && typeof body.result === 'object' && body.result) { - body.result = publishRequestTokens(rewriteRequestUrls(body.result, buildPublicUrlMap()), parsePublishedTokens()); + body.result = safeResult(body.result); } return json(body); }; -async function runnerPayload(request: Request): Promise> { - let body: unknown = {}; - try { - body = await request.json(); - } catch { - return {}; - } - if (!isObject(body) || typeof body.purpose !== 'string' || !body.purpose) { - return {}; - } - const allowedPurposes = await readPurposes(); - if (!allowedPurposes.some((purpose) => purpose.iri === body.purpose)) { - throw error(400, 'unsupported purpose'); - } - return { config: { purpose_override: body.purpose } }; +function safeResult(result: Record): Record { + const map = buildPublicUrlMap(); + const sourceTrace = Array.isArray(result.source_trace) + ? result.source_trace.map(safePresentation).filter((item): item is Record => item !== null) + : []; + return { + step_id: stringValue(result.step_id), + friendly: safeFriendly(result.friendly), + request_source: safeRequest(result.request_source, map), + request_sources: Array.isArray(result.request_sources) + ? result.request_sources.map((source) => safeRequest(source, map)) + : undefined, + response_source: safeResponse(result.response_source), + source_trace: sourceTrace, + results: safeResults(result.results), + result_state: stringValue(result.result_state), + presentation: safePresentation(result.presentation), + presentations: Array.isArray(result.presentations) + ? result.presentations.map(safePresentation).filter((item): item is Record => item !== null) + : undefined, + credential: safeCredential(result.credential) + }; +} + +function safeRequest(value: unknown, map: Record): Record { + if (!isObject(value)) return {}; + const url = stringValue(value.url); + return { + method: stringValue(value.method), + url: url ? mapPublicUrl(url, map) : undefined, + purpose: stringValue(value.purpose), + requests: Array.isArray(value.requests) ? value.requests.map((item) => safeRequest(item, map)) : undefined + }; +} + +function safeResponse(value: unknown): Record { + if (!isObject(value)) return {}; + return { + status: typeof value.status === 'number' ? value.status : null, + code: stringValue(value.code), + type: stringValue(value.type) + }; +} + +function safePresentation(value: unknown): Record | null { + if (!isObject(value)) return null; + const source = value.source === 'immutable extract' || value.source === 'Relay lookup' ? value.source : undefined; + if (!stringValue(value.authority) || !stringValue(value.issuer) || !source) return null; + return { + authority: stringValue(value.authority), + service_id: stringValue(value.service_id), + issuer: stringValue(value.issuer), + provider: stringValue(value.provider), + source, + status: typeof value.status === 'number' ? value.status : undefined, + claims: Array.isArray(value.claims) ? value.claims.filter((item): item is string => typeof item === 'string') : undefined + }; +} + +function safeResults(value: unknown): Record[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.filter(isObject).map((item) => ({ + claim_id: stringValue(item.claim_id), + concept_id: stringValue(item.concept_id), + satisfied: typeof item.satisfied === 'boolean' ? item.satisfied : null, + value: typeof item.value === 'boolean' ? item.value : undefined + })); +} + +function safeFriendly(value: unknown): Record { + if (!isObject(value)) return { title: 'Result unavailable.', message: '', status: 'needs_attention', facts: [] }; + return { + title: stringValue(value.title), + message: stringValue(value.message), + status: stringValue(value.status), + facts: Array.isArray(value.facts) + ? value.facts.filter(isObject).map((fact) => ({ label: stringValue(fact.label), value: safeFact(fact.value) })) + : [] + }; +} + +function safeCredential(value: unknown): Record | undefined { + if (!isObject(value)) return undefined; + return { + status: stringValue(value.status), + profile: stringValue(value.profile), + format: stringValue(value.format), + vct: stringValue(value.vct), + issuer: stringValue(value.issuer), + disclosures: typeof value.disclosures === 'number' ? value.disclosures : undefined, + reason: stringValue(value.reason), + http_status: typeof value.http_status === 'number' ? value.http_status : null, + message: stringValue(value.message) + }; +} + +function safeFact(value: unknown): string | number | boolean | null { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : null; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; } -function isObject(value: unknown): value is { purpose?: unknown } { - return typeof value === 'object' && value !== null; +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/home/src/routes/developers/+page.svelte b/home/src/routes/developers/+page.svelte index e2986e9..425245a 100644 --- a/home/src/routes/developers/+page.svelte +++ b/home/src/routes/developers/+page.svelte @@ -20,7 +20,7 @@

Run the country, inspect every boundary

This is the complete technical surface behind the guided homepage: a clean-checkout journey, - published metadata, source configuration, synthetic demo credentials, and runnable requests. + published metadata, source configuration, credential boundaries, and runnable requests.

total + dataset.entities.length, 0); + $: sourceTypes = new Set(metadata.offerings.map((offering) => offering.access?.source_type).filter(Boolean)).size; $: facts = metadata.available ? [ { value: apiItems.length, label: 'catalog links' }, - { value: entityCount, label: 'registry entities' }, + { value: sourceTypes, label: 'source patterns' }, { value: metadata.offerings.length, label: 'evidence offerings' }, { value: policies.length, label: 'published policies' } ] @@ -55,14 +55,14 @@ Explorer · Solmara Lab - +
@@ -76,7 +76,6 @@
-
-

Datasets (DCAT)

-

Each authority's dataset and the entities it exposes, with the purposes that may read them.

-
- {#each metadata.catalog.datasets as dataset} -
-
-
-

{dataset.title}

-

{dataset.description}

- {#if dataset.authority}

{dataset.authority.name}

{/if} -
- -
- {#each dataset.entities as entity} -
- {entity.title} - {#if entity.semantics?.concepts?.length} -

Semantics: {entity.semantics.concepts.join(', ')}

- {/if} -
- {#each entity.purposes as purpose} - {slug(purpose)} - {/each} -
-
- {/each} - Raw DCAT artifact -
- {/each} -
-
-

Public services (CPSV-AP)

The public services the evidence backs. Each names its competent authority and the evidence APIs it consumes.

diff --git a/home/src/routes/stories/[id]/+page.svelte b/home/src/routes/stories/[id]/+page.svelte index 2aaaaed..2d0a189 100644 --- a/home/src/routes/stories/[id]/+page.svelte +++ b/home/src/routes/stories/[id]/+page.svelte @@ -91,11 +91,18 @@ function orchestrationField(result: StepRunResult, field: 'service_id' | 'decision'): string { const body = result.response_source.body; - if (!body || typeof body !== 'object') return 'Not reported'; + if (!body || typeof body !== 'object') return fallbackOrchestrationField(result, field); const orchestration = (body as { orchestration?: unknown }).orchestration; - if (!orchestration || typeof orchestration !== 'object') return 'Not reported'; + if (!orchestration || typeof orchestration !== 'object') return fallbackOrchestrationField(result, field); const value = (orchestration as Record)[field]; - return typeof value === 'string' && value ? value : 'Not reported'; + return typeof value === 'string' && value ? value : fallbackOrchestrationField(result, field); + } + + function fallbackOrchestrationField(result: StepRunResult, field: 'service_id' | 'decision'): string { + if (result.request_source?.url?.includes('/v1/evaluations') && result.source_trace?.length) { + return field === 'service_id' ? 'child-benefit-federator' : 'not_composed'; + } + return 'Not reported'; } function isCollectedApplicationEvidence(result: StepRunResult | null): result is StepRunResult { @@ -195,11 +202,11 @@

{requestSources.length === 1 - ? 'Request (published lab token)' - : `Requests (${requestSources.length} authority calls, published lab tokens)`} + ? 'Sanitized request skeleton' + : `Sanitized requests (${requestSources.length} authority calls)`}

{#if requestSources.length === 1} - + {/if}
{#if requestSources.length > 1} @@ -208,7 +215,7 @@
Authority request {requestIndex + 1} of {requestSources.length}
- +
{sourceBlock(source)}
@@ -325,7 +332,7 @@

Accountability

-

{applicationEvidence ? 'What the source trace recorded about this access' : 'What the Notary recorded about this access'}

+

{applicationEvidence ? 'What the source trace recorded about this access' : 'What the authority Evidence service recorded about this access'}

{#if accountability} {@const first = claimResults(accountability)[0]?.raw ?? {}}
@@ -344,7 +351,7 @@ {:else}

Run an evaluation step to see the proof trace.

{/if} -

Reading the registry authority's own audit log is a product capability candidate, tracked separately.

+

Authority-side operational logs remain outside the visitor-facing proof view.

diff --git a/justfile b/justfile index 7167187..0be2f77 100644 --- a/justfile +++ b/justfile @@ -1,192 +1,129 @@ -set dotenv-load := true -set positional-arguments := true +set dotenv-load +set positional-arguments compose_project_name := `python3 scripts/compose_project_name.py` default: @just --list -# Install local development dependencies when subprojects define them. setup: - @if [ -f pyproject.toml ]; then uv sync; fi - @if [ -f generator/pyproject.toml ]; then cd generator && uv sync; fi - @if [ -f portal/package.json ]; then cd portal && pnpm install --frozen-lockfile; fi - @if [ -f home/package.json ]; then cd home && pnpm install --frozen-lockfile; fi + uv sync + cd generator && uv sync + cd portal && pnpm install --frozen-lockfile + cd home && pnpm install --frozen-lockfile -# Generate deterministic fixtures and local secrets. +# Generate deterministic fixtures plus ignored operator/runtime material. generate: - @if [ -f generator/pyproject.toml ]; then cd generator && uv run python -m solmara_lab.generate; else echo "generator/pyproject.toml missing"; exit 1; fi - scripts/gen-secrets.py + cd generator && uv run python -m solmara_lab.generate + uv run scripts/gen-secrets.py + @if test -d runtime/evidence-cells; then chmod -R u+w runtime/evidence-cells && rm -rf -- runtime/evidence-cells; fi + uv run python evidence/scripts/build-cells.py --private-key-root config/evidence/local/cells --output runtime/evidence-cells + uv run python scripts/project-runtime-secrets.py + uv run scripts/check-signer-public-keys.py -# Generate a clean checkout, verify compiler output, and start the local topology. -up-generated: - just generate - just registry-projects-runtime-check - just up +build-runtime-images: + scripts/build-registry-stack-runtime.sh -# Validate every authority-owned Registry project in both deployment profiles. -registry-projects-check: - scripts/registry-projects.sh check +# Compile production Relay packages and publish deterministic SQLite sources. +prepare-runtime: build-runtime-images + uv run scripts/check-signer-public-keys.py + scripts/prepare-authority-runtime.sh -# Print the complete redacted acquisition and disclosure plan for every authority. -registry-projects-review: - scripts/registry-projects.sh review +prepare: generate prepare-runtime -# Inspect compiled, declared, enabled, used, and missing capabilities. -registry-projects-capabilities: - scripts/registry-projects.sh capabilities - -# Install or refresh version-matched VS Code and Zed schema mappings. -registry-projects-editor: - scripts/registry-projects.sh editor - -# Run every synthetic authority integration fixture offline. -registry-projects-test: - scripts/registry-projects.sh test - -# Build private Relay and Notary inputs for every authority-owned project. -registry-projects-build environment="local": - scripts/registry-projects.sh build {{environment}} - -# Refresh the committed runtime closure from all authored authority projects. -registry-projects-sync: - scripts/registry-projects.sh sync-runtime - -# Prove the committed runtime closure matches the authored authority projects. -registry-projects-runtime-check: - scripts/registry-projects.sh check-runtime - -# Verify signed hosted Relay closures against their public trust anchors. -hosted-relay-bundles-check: - uv run --locked scripts/check-hosted-relay-bundles.py - -# Exercise one complete compiler-generated SRO blue-green release transition. -contract-generation-proof: - uv run --locked scripts/contract-generation-proof.py - -# Generate only local secrets. -gen-secrets: - scripts/gen-secrets.py - -# Publish the static metadata bundle served by static-metadata. metadata-publish: - @if command -v registry-manifest-cli >/dev/null 2>&1; then registry-manifest-cli publish metadata/solmara-wave1.metadata.yaml --out metadata/public/metadata --site-root metadata/public; fi uv run scripts/publish-metadata.py -# Check that the committed static metadata bundle is up to date. metadata-publish-check: uv run scripts/publish-metadata.py --check -# Lint the published metadata bundle. -metadata-lint: - uv run scripts/metadata-lint.py - -# Static repository checks. lint: + uvx ruff check --select E4,E7,E9,F --exclude vendor . scripts/check-fiction.sh - scripts/check-image-pins.py scripts/check-config-secrets.py - just hosted-relay-bundles-check + scripts/check-image-pins.py + scripts/hosted-image-manifest.py inventory + scripts/check-runtime-topology.py + scripts/check-registry-stack-release-pin.py + uv run scripts/check-signer-public-keys.py just metadata-publish-check - just metadata-lint - @if [ -f portal/package.json ]; then cd portal && pnpm check; fi - @if [ -f home/package.json ]; then cd home && pnpm check; fi + uv run scripts/metadata-lint.py + cd portal && pnpm check + cd home && pnpm check -# Unit and integration tests that can run without a full Compose stack. test: - @if [ -f generator/pyproject.toml ]; then cd generator && uv run python -m unittest discover -s tests; fi - uv run python3 -m unittest discover -s scenario-runner -p 'test_*.py' - @if [ -f portal/package.json ]; then cd portal && pnpm test; fi - @if [ -f home/package.json ]; then cd home && pnpm test; fi - uv run python3 -m unittest discover -s scripts -p 'test_*.py' + cd generator && uv run python -m unittest discover -s tests + uv run python -m unittest discover -s scenario-runner -p 'test_*.py' + uv run python -m unittest relays/test_relay_projects.py evidence/tests/test_cells.py scripts/test_metadata_authority_contracts.py scripts/test_image_pins.py scripts/test_build_registry_stack_runtime.py scripts/test_hosted_image_manifest.py scripts/test_hosted_provisioning_topology.py scripts/test_hosted_network_isolation.py scripts/test_hosted_home_topology.py scripts/test_hosted_esignet_topology.py scripts/test_hosted_evidence_routes.py scripts/test_hosted_runtime_assets.py scripts/test_provision_hosted_runtime.py scripts/test_hosted_transit_signer.py scripts/test_smoke_hosted_provisioner_image.py scripts/test_runtime_topology.py scripts/test_registry_stack_release_pin.py scripts/test_hosted_authority_rollout.py scripts/test_local_relay_runtime_stager.py scripts/test_local_transit_proxy.py scripts/test_local_transit_signers.py scripts/test_local_transit_providers.py scripts/test_signer_public_keys.py scripts/test_project_runtime_secrets.py scripts/test_gen_secrets.py scripts/test_publish_runtime_extracts.py scripts/test_lifecycle_proof.py scripts/test_live_lifecycle_proof.py scripts/test_local_relay_source_publisher.py scripts/test_smoke_programme_acceptance.py scripts/test_smoke_esignet.py + cd portal && pnpm test + cd home && pnpm test -# Validate Compose files without starting services. +# Validate local, hosted, eSignet, and every Coolify topology without starting it. compose: - @if [ ! -f .env ]; then echo ".env is missing; run 'just gen-secrets' first" >&2; exit 1; fi - @if [ -f compose.yaml ]; then COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose --env-file versions.env --env-file .env -f compose.yaml config >/dev/null; fi - @if [ -f compose.hosted.yaml ]; then COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.hosted.yaml config >/dev/null; fi - @if [ -f compose.esignet.yaml ]; then COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml config >/dev/null; fi - scripts/check-coolify-compose.sh - -# Start the local topology. -up: - @env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml up -d --build - -# Build Registry Relay from the pinned source for explicit local development. -up-dev: - scripts/build-relay-runtime.sh - @set -a; . ./versions.env; set +a; env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; REGISTRY_RELAY_IMAGE="$SOLMARA_RELAY_DEV_IMAGE" COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml up -d --build - -# Stop the local topology without removing local volumes. -down: - @env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml down + @test -f .env || { echo ".env is missing; run just gen-secrets" >&2; exit 1; } + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml config >/dev/null + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml config >/dev/null + @scripts/check-hosted-compose.sh + @scripts/check-coolify-compose.sh + +# Verify that every Registry Stack v0.22.0 release reference is public and immutable. +hosted-pin-check: build-runtime-images + scripts/check-image-pins.py + scripts/check-registry-stack-release-pin.py --require-public + +# Run all four relayctl production gates against the five authored projects. +relay-check: build-runtime-images + scripts/check-relay-projects.sh + +# Check the six running Evidence deployments and all eleven authored fixtures. +evidence-check: + scripts/check-evidence-cells.sh -# Start the local topology with eSignet-backed portal login. -up-esignet: - @env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml -f compose.esignet.yaml up -d --build +signers-up: + uv run scripts/local-transit-signers.py up + scripts/check-local-transit-providers.py -# Build Registry Relay from the pinned source for explicit local eSignet development. -up-esignet-dev: - scripts/build-relay-runtime.sh - @set -a; . ./versions.env; set +a; env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; REGISTRY_RELAY_IMAGE="$SOLMARA_RELAY_DEV_IMAGE" COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml -f compose.esignet.yaml up -d --build +signers-down: + uv run scripts/local-transit-signers.py down + +up: prepare + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml up -d --build --force-recreate + +up-esignet: prepare + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml up -d --build --force-recreate + +down: + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml down + just signers-down -# Stop the local eSignet topology without removing local volumes. down-esignet: - @env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml -f compose.esignet.yaml down + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml down + just signers-down -# Stop the local topology and remove this checkout's local volumes. +# Destructive reset is intentionally local-only. Hosted rollout never deletes volumes. reset: - @env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml down -v + COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml down -v -# Stop the local eSignet topology and remove this checkout's local eSignet volumes. -reset-esignet: - @env_args="--env-file versions.env"; if [ -f .env ]; then env_args="$env_args --env-file .env"; fi; COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose $env_args -f compose.yaml -f compose.esignet.yaml down -v +rollout phase: + scripts/hosted-authority-rollout.py {{ phase }} -# Run story and authority-application smokes against the running local topology. smoke: scripts/smoke.sh -# Run only live HTTP checks against the running local topology. -smoke-live: - uv run --locked scripts/smoke-live.py - -# Prove all six Notary databases survive exact `just down` / `just up` recreation. -notary-state-restart-proof: - uv run --locked scripts/notary_state_restart.py +smoke-esignet: + @uv run scripts/smoke-esignet.py >/dev/null && node scripts/smoke-esignet-login.mjs || { echo "smoke-esignet: FAIL"; exit 1; } -# Smoke eSignet discovery; portal login proves the NIA attribute-release path end to end. -smoke-esignet *args: - uv run scripts/smoke-esignet.py {{args}} +programme-acceptance: + @COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml exec -T scenario-runner python - < scripts/smoke-programme-acceptance.py 2>/dev/null || { echo "programme-acceptance: FAIL scenario-runner-execution"; exit 1; } -# Probe Relay source endpoints used by live Notary smoke. -relay-source-smoke: - scripts/smoke-relay-sources.py +lifecycle-proof: + uv run scripts/live-lifecycle-proof.py -# Smoke the Compose portal service and live BFF wiring. -portal-compose-smoke: - scripts/smoke-portal-compose.py +lifecycle-fixture-proof: + uv run scripts/lifecycle_proof.py -# Run browser e2e against the live local topology. portal-live-e2e: - @cd portal && PORT="${PORT:-4001}" PORTAL_PROVIDER=live pnpm e2e + cd portal && SOLMARA_PORTAL_E2E_MODE=hosted PLAYWRIGHT_BASE_URL="http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}" pnpm e2e -# Run browser e2e against the Visitor's Center. home-live-e2e: - @cd home && SOLMARA_HOME_E2E_MODE=live PLAYWRIGHT_BASE_URL="http://127.0.0.1:${SOLMARA_HOME_PORT:-4301}" pnpm e2e - -# Run public hosted health, endpoint, scenario, and portal smoke checks. -hosted-smoke *args: - uv run scripts/smoke-hosted.py {{args}} - -# Verify committed Registry Stack release inputs match a candidate or release tag. -release-pins tag: - scripts/check-release-pins.py "$1" - -# Run release-readiness and security-oriented checks. -review: - scripts/review.sh - -# Run release review against an explicit Registry Stack candidate or release tag. -review-release tag: - scripts/check-release-pins.py "$1" - scripts/review.sh + cd home && SOLMARA_HOME_E2E_MODE=live PLAYWRIGHT_BASE_URL="http://127.0.0.1:${SOLMARA_HOME_PORT:-4301}" pnpm e2e diff --git a/metadata/public/metadata/catalog.json b/metadata/public/metadata/catalog.json index ede1e9b..ad05ede 100644 --- a/metadata/public/metadata/catalog.json +++ b/metadata/public/metadata/catalog.json @@ -51,76 +51,63 @@ ], "data_services": [ { - "conforms_to": "https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1", - "description": { - "en": "Returns application/json collections assembled from source-Notary predicates without composing an eligibility decision." - }, - "endpoint_description": "https://child-benefit-federator.solmara.registrystack.org/v1/claims", - "endpoint_url": "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - "id": "child-benefit-evidence-collector-api", - "iri": "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_description": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", + "id": "cra-evidence-api", + "iri": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "title": { - "en": "Child benefit authority predicate collection API" + "en": "CRA Evidence cell" } }, { - "conforms_to": "https://id.registrystack.org/spec/registry-notary/evidence/v1", - "endpoint_description": "https://cra-notary.solmara.registrystack.org/openapi.json", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", - "id": "cra-notary-api", - "iri": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_description": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "id": "nia-evidence-api", + "iri": "https://nia-evidence.solmara.registrystack.org/v1/evidence", "title": { - "en": "CRA evidence API" + "en": "NIA Evidence cell" } }, { - "conforms_to": "https://id.registrystack.org/spec/registry-notary/evidence/v1", - "endpoint_description": "https://nia-notary.solmara.registrystack.org/openapi.json", - "endpoint_url": "https://nia-notary.solmara.registrystack.org/v1/evaluations", - "id": "nia-notary-api", - "iri": "https://nia-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_description": "https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence", + "id": "sro-evidence-api", + "iri": "https://sro-evidence.solmara.registrystack.org/v1/evidence", "title": { - "en": "NIA evidence API" + "en": "SRO Evidence cell" } }, { - "conforms_to": "https://id.registrystack.org/spec/registry-notary/evidence/v1", - "endpoint_description": "https://sro-notary.solmara.registrystack.org/openapi.json", - "endpoint_url": "https://sro-notary.solmara.registrystack.org/v1/evaluations", - "id": "sro-notary-api", - "iri": "https://sro-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_description": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence", + "id": "mosd-programme-evidence-api", + "iri": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence", "title": { - "en": "SRO evidence API" + "en": "MoSD Programme Evidence cell" } }, { - "conforms_to": "https://id.registrystack.org/spec/registry-notary/evidence/v1", - "endpoint_description": "https://programme-notary.solmara.registrystack.org/openapi.json", - "endpoint_url": "https://programme-notary.solmara.registrystack.org/v1/evaluations", - "id": "programme-notary-api", - "iri": "https://programme-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_description": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "id": "sipf-evidence-api", + "iri": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", "title": { - "en": "Programme MIS evidence API" + "en": "SIPF Evidence cell" } }, { - "conforms_to": "https://id.registrystack.org/spec/registry-notary/evidence/v1", - "endpoint_description": "https://sipf-notary.solmara.registrystack.org/openapi.json", - "endpoint_url": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", - "id": "sipf-notary-api", - "iri": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_description": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "id": "nagdi-evidence-api", + "iri": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", "title": { - "en": "SIPF evidence API" - } - }, - { - "conforms_to": "https://id.registrystack.org/spec/registry-notary/evidence/v1", - "endpoint_description": "https://nagdi-notary.solmara.registrystack.org/openapi.json", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "id": "nagdi-notary-api", - "iri": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "title": { - "en": "NAgDI evidence API" + "en": "NAgDI Evidence cell" } } ], @@ -138,39 +125,15 @@ "iri": "did:web:id.registrystack.org:solmara:authority:cra", "name": "Civil Registration Authority" }, - "description": "Cra civil published metadata.", + "description": "CRA-owned immutable birth extract and governed civil-person Relay source.", "entities": [ { "description": "Civil person entity metadata.", "fields": [ { - "concepts": [ - "ps:Person.identifier" - ], "name": "uin", - "required": false, - "type": "string" - }, - { - "concepts": [ - "ps:crvs/Birth.registrationNumber" - ], - "name": "birth_brn", - "required": false, - "type": "string" - }, - { - "concepts": [ - "ps:crvs/Death.registrationNumber" - ], - "name": "death_drn", - "required": false, - "type": "string" - }, - { - "name": "deceased", "required": true, - "type": "boolean" + "type": "string" } ], "identifiers": [ @@ -190,9 +153,8 @@ "cpsv-ap" ], "concepts": [ - "ps:Person.identifier", - "ps:crvs/Birth.registrationNumber", - "ps:crvs/Death.registrationNumber" + "https://publicschema.org/crvs/Birth", + "https://publicschema.org/crvs/Death" ] }, "title": "Civil person" @@ -204,7 +166,7 @@ "https://id.registrystack.org/solmara/purpose/pension-payment-review", "https://id.registrystack.org/solmara/purpose/citizen-self-service" ], - "title": "Civil registration" + "title": "CRA civil registration publication" }, { "access_rights": "restricted", @@ -219,13 +181,24 @@ "iri": "did:web:id.registrystack.org:solmara:authority:nia", "name": "National Identity Agency" }, - "description": "Nia population published metadata.", + "description": "NIA-owned immutable population extract and governed eSignet Relay source.", "entities": [ { - "description": "Person entity metadata.", - "fields": [], - "identifiers": [], - "name": "person", + "description": "Population person entity metadata.", + "fields": [ + { + "name": "uin", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "uin", + "name": "uin" + } + ], + "name": "population_person", "purposes": [ "https://id.registrystack.org/solmara/purpose/child-benefit-review", "https://id.registrystack.org/solmara/purpose/citizen-self-service" @@ -238,7 +211,7 @@ "https://publicschema.org/Person" ] }, - "title": "Person" + "title": "Population person" } ], "id": "nia-population", @@ -246,7 +219,7 @@ "https://id.registrystack.org/solmara/purpose/child-benefit-review", "https://id.registrystack.org/solmara/purpose/citizen-self-service" ], - "title": "Population register" + "title": "NIA population publication" }, { "access_rights": "restricted", @@ -261,13 +234,24 @@ "iri": "did:web:id.registrystack.org:solmara:authority:sro", "name": "Social Registry Office" }, - "description": "Sro social published metadata.", + "description": "SRO-owned immutable poverty publication.", "entities": [ { - "description": "Household entity metadata.", - "fields": [], - "identifiers": [], - "name": "household", + "description": "Poverty record entity metadata.", + "fields": [ + { + "name": "uin", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "uin", + "name": "uin" + } + ], + "name": "poverty_record", "purposes": [ "https://id.registrystack.org/solmara/purpose/child-benefit-review" ], @@ -280,14 +264,14 @@ "https://publicschema.org/SocioEconomicProfile" ] }, - "title": "Household" + "title": "Poverty record" } ], "id": "sro-social", "purposes": [ "https://id.registrystack.org/solmara/purpose/child-benefit-review" ], - "title": "Social registry" + "title": "SRO poverty extract" }, { "access_rights": "restricted", @@ -302,13 +286,24 @@ "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", "name": "Ministry of Social Development Programme MIS" }, - "description": "Mosd programme published metadata.", + "description": "MoSD-owned mutable SQLite source consulted through Relay V2.", "entities": [ { - "description": "Enrollment entity metadata.", - "fields": [], - "identifiers": [], - "name": "enrollment", + "description": "Beneficiary enrolment entity metadata.", + "fields": [ + { + "name": "uin", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "uin", + "name": "uin" + } + ], + "name": "beneficiary_enrolment", "purposes": [ "https://id.registrystack.org/solmara/purpose/child-benefit-review" ], @@ -320,14 +315,14 @@ "https://publicschema.org/sp/Enrollment" ] }, - "title": "Enrollment" + "title": "Beneficiary enrolment" } ], "id": "mosd-programme", "purposes": [ "https://id.registrystack.org/solmara/purpose/child-benefit-review" ], - "title": "Integrated beneficiary registry" + "title": "MoSD programme enrolment publication" }, { "access_rights": "restricted", @@ -342,13 +337,24 @@ "iri": "did:web:id.registrystack.org:solmara:authority:sipf", "name": "Social Insurance and Pensions Fund" }, - "description": "Sipf pensions published metadata.", + "description": "SIPF-owned mutable SQLite source consulted through Relay V2.", "entities": [ { - "description": "Pension case entity metadata.", - "fields": [], - "identifiers": [], - "name": "pension_case", + "description": "Pension payment entity metadata.", + "fields": [ + { + "name": "pensioner_uin", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "uin", + "name": "pensioner_uin" + } + ], + "name": "pension_payment", "purposes": [ "https://id.registrystack.org/solmara/purpose/pension-payment-review", "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" @@ -361,7 +367,37 @@ "https://id.registrystack.org/solmara/semantics/pension-case" ] }, - "title": "Pension case" + "title": "Pension payment" + }, + { + "description": "Survivor case entity metadata.", + "fields": [ + { + "name": "spouse_uin", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "uin", + "name": "spouse_uin" + } + ], + "name": "survivor_case", + "purposes": [ + "https://id.registrystack.org/solmara/purpose/pension-payment-review", + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/semantics/pension-case" + ] + }, + "title": "Survivor case" } ], "id": "sipf-pensions", @@ -369,7 +405,7 @@ "https://id.registrystack.org/solmara/purpose/pension-payment-review", "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" ], - "title": "Pensions and social insurance" + "title": "SIPF pension and survivor publication" }, { "access_rights": "restricted", @@ -384,12 +420,23 @@ "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", "name": "National Agricultural Data Institute" }, - "description": "Nagdi agriculture published metadata.", + "description": "NAgDI-owned mutable SQLite source consulted through Relay V2.", "entities": [ { "description": "Farmer voucher entity metadata.", - "fields": [], - "identifiers": [], + "fields": [ + { + "name": "farmer_id", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "local", + "name": "farmer_id" + } + ], "name": "farmer_voucher", "purposes": [ "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", @@ -407,8 +454,19 @@ }, { "description": "Livestock movement entity metadata.", - "fields": [], - "identifiers": [], + "fields": [ + { + "name": "farmer_id", + "required": true, + "type": "string" + } + ], + "identifiers": [ + { + "kind": "local", + "name": "farmer_id" + } + ], "name": "livestock_movement", "purposes": [ "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", @@ -430,124 +488,157 @@ "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", "https://id.registrystack.org/solmara/purpose/livestock-movement-control" ], - "title": "Farmer and livestock registries" + "title": "NAgDI agriculture publication" } ], - "description": "One merged CPSV-AP metadata publish for Solmara Wave 1 registry and evidence services.", + "description": "Six authority-owned Evidence cells, five governed Relay V2 services, and the programme applications that compose signed assertions.", "evidence_types": [ { - "id": "birth-registration-evidence", + "id": "cra-child-benefit-v1", "information_concepts": [ - "ps:crvs/Birth" + "https://id.registrystack.org/solmara/concept/birth-is-registered", + "https://id.registrystack.org/solmara/concept/child-age-under-5" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/birth-registration", + "iri": "https://id.registrystack.org/solmara/evidence-type/cra-child-benefit/v1", "proves": [ - "child-benefit-review-requirement" + "cra-child-benefit-v1" ], "title": { - "en": "Birth registration evidence" + "en": "CRA child benefit assertion" } }, { - "id": "population-status-evidence", + "id": "cra-death-status-v1", "information_concepts": [ - "ps:Person" + "https://id.registrystack.org/solmara/concept/person-is-deceased" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/population-status", + "iri": "https://id.registrystack.org/solmara/evidence-type/cra-death-status/v1", "proves": [ - "child-benefit-review-requirement" + "cra-pension-death-v1" ], "title": { - "en": "Population status evidence" + "en": "CRA death-status assertion" } }, { - "id": "household-poverty-evidence", + "id": "civil-record-linked-v1", "information_concepts": [ - "ps:Household", - "ps:SocioEconomicProfile" + "https://id.registrystack.org/solmara/concept/civil-record-linked" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/household-poverty", + "iri": "https://id.registrystack.org/solmara/evidence-type/civil-record-linked/v1", "proves": [ - "child-benefit-review-requirement" + "cra-citizen-record-v1" ], "title": { - "en": "Household poverty evidence" + "en": "CRA civil-record assertion" } }, { - "id": "beneficiary-enrollment-evidence", + "id": "population-active-v1", "information_concepts": [ - "ps:sp/Enrollment" + "https://id.registrystack.org/solmara/concept/population-record-active" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/beneficiary-enrollment", + "iri": "https://id.registrystack.org/solmara/evidence-type/population-active/v1", "proves": [ - "child-benefit-review-requirement" + "nia-child-benefit-v1" ], "title": { - "en": "Beneficiary enrollment evidence" + "en": "NIA active-population assertion" } }, { - "description": { - "en": "A transient collection of source-owned child benefit predicates with no copied source rows or eligibility decision." - }, - "id": "child-benefit-authority-predicate-collection-evidence", + "id": "citizen-population-active-v1", + "information_concepts": [ + "https://id.registrystack.org/solmara/concept/citizen-population-record-active" + ], + "iri": "https://id.registrystack.org/solmara/evidence-type/citizen-population-active/v1", + "proves": [ + "nia-citizen-status-v1" + ], + "title": { + "en": "NIA active-citizen assertion" + } + }, + { + "id": "poverty-priority-v1", + "information_concepts": [ + "https://id.registrystack.org/solmara/concept/household-below-poverty-threshold" + ], + "iri": "https://id.registrystack.org/solmara/evidence-type/poverty-priority/v1", + "proves": [ + "sro-child-benefit-v1" + ], + "title": { + "en": "SRO poverty-priority assertion" + } + }, + { + "id": "not-enrolled-v1", "information_concepts": [ - "ps:Person" + "https://id.registrystack.org/solmara/concept/not-already-enrolled" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/child-benefit-authority-predicate-collection", + "iri": "https://id.registrystack.org/solmara/evidence-type/not-enrolled/v1", "proves": [ - "child-benefit-review-requirement" + "mosd-child-benefit-v1" ], "title": { - "en": "Child benefit authority predicate collection" + "en": "MoSD not-enrolled assertion" } }, { - "id": "death-registration-evidence", + "id": "pension-payment-active-v1", "information_concepts": [ - "ps:crvs/Death" + "https://id.registrystack.org/solmara/concept/pension-payment-active" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/death-registration", + "iri": "https://id.registrystack.org/solmara/evidence-type/pension-payment-active/v1", "proves": [ - "pension-survivor-review-requirement" + "sipf-pension-payment-v1" ], "title": { - "en": "Death registration evidence" + "en": "SIPF active-payment assertion" } }, { - "id": "pension-case-evidence", - "iri": "https://id.registrystack.org/solmara/evidence-types/pension-case", + "id": "survivor-benefit-v1", + "information_concepts": [ + "https://id.registrystack.org/solmara/concept/survivor-is-eligible" + ], + "iri": "https://id.registrystack.org/solmara/evidence-type/survivor-benefit/v1", "proves": [ - "pension-survivor-review-requirement" + "sipf-survivor-benefit-v1" ], "title": { - "en": "Pension case evidence" + "en": "SIPF survivor-benefit assertion" } }, { - "id": "farmer-voucher-evidence", + "id": "climate-smart-voucher-v1", "information_concepts": [ - "ps:Farm" + "https://id.registrystack.org/solmara/concept/farmer-registered", + "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", + "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher" ], - "iri": "https://id.registrystack.org/solmara/evidence-types/farmer-voucher", + "iri": "https://id.registrystack.org/solmara/evidence-type/climate-smart-voucher/v1", "proves": [ - "nagdi-voucher-requirement" + "nagdi-voucher-v1" ], "title": { - "en": "Farmer voucher evidence" + "en": "NAgDI climate-smart voucher assertion" } }, { - "id": "livestock-movement-evidence", - "iri": "https://id.registrystack.org/solmara/evidence-types/livestock-movement", + "id": "livestock-movement-v1", + "information_concepts": [ + "https://id.registrystack.org/solmara/concept/registered-herd", + "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", + "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit" + ], + "iri": "https://id.registrystack.org/solmara/evidence-type/livestock-movement/v1", "proves": [ - "livestock-movement-requirement" + "nagdi-livestock-v1" ], "title": { - "en": "Livestock movement evidence" + "en": "NAgDI livestock movement assertion" } } ], @@ -613,22 +704,24 @@ "wave": null } ], - "id": "solmara-wave1-multi-authority", + "id": "solmara-authority-cells", "public_services": [ { "competent_authority": "mosd-programme-mis", "data_services": [ - "child-benefit-evidence-collector-api", - "cra-notary-api", - "nia-notary-api", - "sro-notary-api", - "programme-notary-api" + "cra-evidence-api", + "nia-evidence-api", + "sro-evidence-api", + "mosd-programme-evidence-api" ], "description": { - "en": "MoSD review of child benefit eligibility using minimized registry evidence." + "en": "The programme composes four independently signed authority assertions into five reviewed concepts." }, "holds_requirements": [ - "child-benefit-review-requirement" + "cra-child-benefit-v1", + "nia-child-benefit-v1", + "sro-child-benefit-v1", + "mosd-child-benefit-v1" ], "id": "child-benefit-review", "iri": "https://id.registrystack.org/solmara/services/child-benefit-review", @@ -640,14 +733,16 @@ { "competent_authority": "sipf", "data_services": [ - "cra-notary-api", - "sipf-notary-api" + "cra-evidence-api", + "sipf-evidence-api" ], "description": { - "en": "SIPF review for pension payment stop and survivor benefit eligibility." + "en": "The programme composes CRA and SIPF signed assertions without exposing unrelated civil data." }, "holds_requirements": [ - "pension-survivor-review-requirement" + "cra-pension-death-v1", + "sipf-pension-payment-v1", + "sipf-survivor-benefit-v1" ], "id": "pension-survivor-review", "iri": "https://id.registrystack.org/solmara/services/pension-survivor-review", @@ -657,39 +752,42 @@ } }, { - "competent_authority": "nagdi", + "competent_authority": "nia", "data_services": [ - "nagdi-notary-api" + "cra-evidence-api", + "nia-evidence-api" ], "description": { - "en": "NAgDI review of farmer eligibility for climate-smart input support." + "en": "The application composes separately signed CRA and NIA citizen assertions." }, "holds_requirements": [ - "nagdi-voucher-requirement" + "cra-citizen-record-v1", + "nia-citizen-status-v1" ], - "id": "nagdi-voucher-review", - "iri": "https://id.registrystack.org/solmara/services/nagdi-voucher-review", + "id": "citizen-self-service", + "iri": "https://id.registrystack.org/solmara/services/citizen-self-service", "jurisdiction": "https://id.registrystack.org/solmara/jurisdiction/XS", "title": { - "en": "Farmer climate-smart voucher review" + "en": "Citizen self-service" } }, { "competent_authority": "nagdi", "data_services": [ - "nagdi-notary-api" + "nagdi-evidence-api" ], "description": { - "en": "NAgDI review of livestock movement permit eligibility and quarantine controls." + "en": "NAgDI applications consume independently signed voucher and livestock assertions." }, "holds_requirements": [ - "livestock-movement-requirement" + "nagdi-voucher-v1", + "nagdi-livestock-v1" ], - "id": "livestock-movement-control", - "iri": "https://id.registrystack.org/solmara/services/livestock-movement-control", + "id": "agriculture-review", + "iri": "https://id.registrystack.org/solmara/services/agriculture-review", "jurisdiction": "https://id.registrystack.org/solmara/jurisdiction/XS", "title": { - "en": "Livestock movement control" + "en": "Agriculture voucher and livestock review" } } ], @@ -699,5 +797,5 @@ "name": "Solmara Interoperability Metadata Publisher" }, "schema_version": "registry-manifest-catalog/v1", - "title": "Solmara Lab Wave 1 Service Catalogue" + "title": "Solmara authority-owned Evidence catalogue" } diff --git a/metadata/public/metadata/cpsv-ap b/metadata/public/metadata/cpsv-ap index 78e7a64..71fa3b0 100644 --- a/metadata/public/metadata/cpsv-ap +++ b/metadata/public/metadata/cpsv-ap @@ -9,17 +9,19 @@ { "competent_authority": "mosd-programme-mis", "data_services": [ - "child-benefit-evidence-collector-api", - "cra-notary-api", - "nia-notary-api", - "sro-notary-api", - "programme-notary-api" + "cra-evidence-api", + "nia-evidence-api", + "sro-evidence-api", + "mosd-programme-evidence-api" ], "description": { - "en": "MoSD review of child benefit eligibility using minimized registry evidence." + "en": "The programme composes four independently signed authority assertions into five reviewed concepts." }, "holds_requirements": [ - "child-benefit-review-requirement" + "cra-child-benefit-v1", + "nia-child-benefit-v1", + "sro-child-benefit-v1", + "mosd-child-benefit-v1" ], "id": "child-benefit-review", "iri": "https://id.registrystack.org/solmara/services/child-benefit-review", @@ -31,14 +33,16 @@ { "competent_authority": "sipf", "data_services": [ - "cra-notary-api", - "sipf-notary-api" + "cra-evidence-api", + "sipf-evidence-api" ], "description": { - "en": "SIPF review for pension payment stop and survivor benefit eligibility." + "en": "The programme composes CRA and SIPF signed assertions without exposing unrelated civil data." }, "holds_requirements": [ - "pension-survivor-review-requirement" + "cra-pension-death-v1", + "sipf-pension-payment-v1", + "sipf-survivor-benefit-v1" ], "id": "pension-survivor-review", "iri": "https://id.registrystack.org/solmara/services/pension-survivor-review", @@ -48,39 +52,42 @@ } }, { - "competent_authority": "nagdi", + "competent_authority": "nia", "data_services": [ - "nagdi-notary-api" + "cra-evidence-api", + "nia-evidence-api" ], "description": { - "en": "NAgDI review of farmer eligibility for climate-smart input support." + "en": "The application composes separately signed CRA and NIA citizen assertions." }, "holds_requirements": [ - "nagdi-voucher-requirement" + "cra-citizen-record-v1", + "nia-citizen-status-v1" ], - "id": "nagdi-voucher-review", - "iri": "https://id.registrystack.org/solmara/services/nagdi-voucher-review", + "id": "citizen-self-service", + "iri": "https://id.registrystack.org/solmara/services/citizen-self-service", "jurisdiction": "https://id.registrystack.org/solmara/jurisdiction/XS", "title": { - "en": "Farmer climate-smart voucher review" + "en": "Citizen self-service" } }, { "competent_authority": "nagdi", "data_services": [ - "nagdi-notary-api" + "nagdi-evidence-api" ], "description": { - "en": "NAgDI review of livestock movement permit eligibility and quarantine controls." + "en": "NAgDI applications consume independently signed voucher and livestock assertions." }, "holds_requirements": [ - "livestock-movement-requirement" + "nagdi-voucher-v1", + "nagdi-livestock-v1" ], - "id": "livestock-movement-control", - "iri": "https://id.registrystack.org/solmara/services/livestock-movement-control", + "id": "agriculture-review", + "iri": "https://id.registrystack.org/solmara/services/agriculture-review", "jurisdiction": "https://id.registrystack.org/solmara/jurisdiction/XS", "title": { - "en": "Livestock movement control" + "en": "Agriculture voucher and livestock review" } } ], @@ -88,18 +95,18 @@ "solmara:evidenceOfferings": [ { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "birth-is-registered" + "source_type": "immutable extract" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed immutable extract assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "birth-registration-evidence", - "id": "cra-birth-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration", + "evidence_type": "cra-child-benefit-v1", + "id": "cra-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -110,7 +117,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-birth-registration-offering-policy", + "policy": "cra-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -122,26 +129,26 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/birth-is-registered", + "https://id.registrystack.org/solmara/concept/child-age-under-5" ] }, - "title": "CRA birth registration evidence offering" + "title": "Civil Registration Authority Cra child benefit v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "person-is-deceased" + "source_type": "Relay lookup" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "death-registration-evidence", - "id": "cra-death-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration", + "evidence_type": "cra-death-status-v1", + "id": "cra-pension-death-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-pension-death-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -152,7 +159,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-death-registration-offering-policy", + "policy": "cra-pension-death-v1-offering-policy", "public_services": [ "pension-survivor-review" ], @@ -164,25 +171,66 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/person-is-deceased" ] }, - "title": "CRA death registration evidence offering" + "title": "Civil Registration Authority Cra death status v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nia-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nia-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "cra-civil", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", + "entity": "civil_person", + "evidence_type": "civil-record-linked-v1", + "id": "cra-citizen-record-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-citizen-record-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "cra", + "iri": "did:web:id.registrystack.org:solmara:authority:cra", + "name": "Civil Registration Authority", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "cra-citizen-record-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/civil-record-linked" + ] + }, + "title": "Civil Registration Authority Civil record linked v1" + }, + { + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" }, "dataset": "nia-population", - "description": "Purpose-limited evidence predicates from Population register.", - "entity": "person", - "evidence_type": "population-status-evidence", - "id": "nia-population-population-status-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-population-population-status-offering", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "population-active-v1", + "id": "nia-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "nia", @@ -193,7 +241,7 @@ "lookup_keys": [ "uin" ], - "policy": "nia-population-population-status-offering-policy", + "policy": "nia-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -205,24 +253,66 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Person" + "https://id.registrystack.org/solmara/concept/population-record-active" ] }, - "title": "Population register evidence offering" + "title": "National Identity Agency Population active v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sro-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sro-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "nia-population", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "citizen-population-active-v1", + "id": "nia-citizen-status-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-citizen-status-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nia", + "iri": "did:web:id.registrystack.org:solmara:authority:nia", + "name": "National Identity Agency", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "nia-citizen-status-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/citizen-population-record-active" + ] + }, + "title": "National Identity Agency Citizen population active v1" + }, + { + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" }, "dataset": "sro-social", - "description": "Purpose-limited evidence predicates from Social registry.", - "entity": "household", - "evidence_type": "household-poverty-evidence", - "id": "sro-social-household-poverty-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-social-household-poverty-offering", + "description": "An independently signed immutable extract assertion issued by Social Registry Office.", + "entity": "poverty_record", + "evidence_type": "poverty-priority-v1", + "id": "sro-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "sro", @@ -233,7 +323,7 @@ "lookup_keys": [ "uin" ], - "policy": "sro-social-household-poverty-offering-policy", + "policy": "sro-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -245,25 +335,25 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Household", - "https://publicschema.org/SocioEconomicProfile" + "https://id.registrystack.org/solmara/concept/household-below-poverty-threshold" ] }, - "title": "Social registry evidence offering" + "title": "Social Registry Office Poverty priority v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://programme-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://programme-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "mosd-programme", - "description": "Purpose-limited evidence predicates from Integrated beneficiary registry.", - "entity": "enrollment", - "evidence_type": "beneficiary-enrollment-evidence", - "id": "mosd-programme-beneficiary-enrollment-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-programme-beneficiary-enrollment-offering", + "description": "An independently signed Relay lookup assertion issued by Ministry of Social Development Programme MIS.", + "entity": "beneficiary_enrolment", + "evidence_type": "not-enrolled-v1", + "id": "mosd-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "mosd-programme-mis", @@ -274,7 +364,7 @@ "lookup_keys": [ "uin" ], - "policy": "mosd-programme-beneficiary-enrollment-offering-policy", + "policy": "mosd-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -286,24 +376,25 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/sp/Enrollment" + "https://id.registrystack.org/solmara/concept/not-already-enrolled" ] }, - "title": "Integrated beneficiary registry evidence offering" + "title": "Ministry of Social Development Programme MIS Not enrolled v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sipf-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "sipf-pensions", - "description": "Purpose-limited evidence predicates from Pensions and social insurance.", - "entity": "pension_case", - "evidence_type": "pension-case-evidence", - "id": "sipf-pensions-pension-case-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pensions-pension-case-offering", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "pension_payment", + "evidence_type": "pension-payment-active-v1", + "id": "sipf-pension-payment-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pension-payment-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "sipf", @@ -312,79 +403,80 @@ "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "uin" + "pensioner_uin" ], - "policy": "sipf-pensions-pension-case-offering-policy", + "policy": "sipf-pension-payment-v1-offering-policy", "public_services": [ "pension-survivor-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + "https://id.registrystack.org/solmara/purpose/pension-payment-review" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://id.registrystack.org/solmara/semantics/pension-case" + "https://id.registrystack.org/solmara/concept/pension-payment-active" ] }, - "title": "Pensions and social insurance evidence offering" + "title": "Social Insurance and Pensions Fund Pension payment active v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, - "dataset": "nagdi-agriculture", - "description": "Purpose-limited evidence predicates from Farmer and livestock registries.", - "entity": "farmer_voucher", - "evidence_type": "farmer-voucher-evidence", - "id": "nagdi-agriculture-farmer-voucher-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-farmer-voucher-offering", + "dataset": "sipf-pensions", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "survivor_case", + "evidence_type": "survivor-benefit-v1", + "id": "sipf-survivor-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-survivor-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", - "id": "nagdi", - "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", - "name": "National Agricultural Data Institute", + "id": "sipf", + "iri": "did:web:id.registrystack.org:solmara:authority:sipf", + "name": "Social Insurance and Pensions Fund", "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "farmer_id" + "spouse_uin" ], - "policy": "nagdi-agriculture-farmer-voucher-offering-policy", + "policy": "sipf-survivor-benefit-v1-offering-policy", "public_services": [ - "nagdi-voucher-review" + "pension-survivor-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Farm" + "https://id.registrystack.org/solmara/concept/survivor-is-eligible" ] }, - "title": "Farmer and livestock registries evidence offering" + "title": "Social Insurance and Pensions Fund Survivor benefit v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "nagdi-agriculture", - "description": "Livestock movement-control predicates for permit checks.", - "entity": "livestock_movement", - "evidence_type": "livestock-movement-evidence", - "id": "nagdi-agriculture-livestock-movement-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-livestock-movement-offering", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "farmer_voucher", + "evidence_type": "climate-smart-voucher-v1", + "id": "nagdi-voucher-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-voucher-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "nagdi", @@ -395,67 +487,67 @@ "lookup_keys": [ "farmer_id" ], - "policy": "nagdi-agriculture-livestock-movement-offering-policy", + "policy": "nagdi-voucher-v1-offering-policy", "public_services": [ - "livestock-movement-control" + "agriculture-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/livestock-movement-control" + "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/livestock-type" + "https://id.registrystack.org/solmara/concept/farmer-registered", + "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", + "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher" ] }, - "title": "NAgDI livestock movement evidence offering" + "title": "National Agricultural Data Institute Climate smart voucher v1" }, { "access": { - "conforms_to": "https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1", - "discovery_url": "https://child-benefit-federator.solmara.registrystack.org/v1/claims", - "endpoint_url": "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - "kind": "authority-predicate-collection-api", - "media_type": "application/json", - "ruleset": "source-owned-child-benefit-predicates-v1" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, - "dataset": "mosd-programme", - "description": "A transient collection of source-owned child benefit predicates. It contains no copied source rows and no composed eligibility decision.", - "entity": "enrollment", - "evidence_type": "child-benefit-authority-predicate-collection-evidence", - "id": "solmara.child-benefit.authority-predicate-collection", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", + "dataset": "nagdi-agriculture", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "livestock_movement", + "evidence_type": "livestock-movement-v1", + "id": "nagdi-livestock-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-livestock-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", - "id": "mosd-programme-mis", - "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", - "name": "Ministry of Social Development Programme MIS", + "id": "nagdi", + "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", + "name": "National Agricultural Data Institute", "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "uin" + "farmer_id" ], - "policy": "solmara-child-benefit-authority-predicate-collection-policy", + "policy": "nagdi-livestock-v1-offering-policy", "public_services": [ - "child-benefit-review" + "agriculture-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" + "https://id.registrystack.org/solmara/purpose/livestock-movement-control" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Person", - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/Household", - "https://publicschema.org/sp/Enrollment" + "https://id.registrystack.org/solmara/concept/registered-herd", + "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", + "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit" ] }, - "title": "Child Benefit Authority Predicate Collection" + "title": "National Agricultural Data Institute Livestock movement v1" } ] } diff --git a/metadata/public/metadata/cpsv-ap.jsonld b/metadata/public/metadata/cpsv-ap.jsonld index 78e7a64..71fa3b0 100644 --- a/metadata/public/metadata/cpsv-ap.jsonld +++ b/metadata/public/metadata/cpsv-ap.jsonld @@ -9,17 +9,19 @@ { "competent_authority": "mosd-programme-mis", "data_services": [ - "child-benefit-evidence-collector-api", - "cra-notary-api", - "nia-notary-api", - "sro-notary-api", - "programme-notary-api" + "cra-evidence-api", + "nia-evidence-api", + "sro-evidence-api", + "mosd-programme-evidence-api" ], "description": { - "en": "MoSD review of child benefit eligibility using minimized registry evidence." + "en": "The programme composes four independently signed authority assertions into five reviewed concepts." }, "holds_requirements": [ - "child-benefit-review-requirement" + "cra-child-benefit-v1", + "nia-child-benefit-v1", + "sro-child-benefit-v1", + "mosd-child-benefit-v1" ], "id": "child-benefit-review", "iri": "https://id.registrystack.org/solmara/services/child-benefit-review", @@ -31,14 +33,16 @@ { "competent_authority": "sipf", "data_services": [ - "cra-notary-api", - "sipf-notary-api" + "cra-evidence-api", + "sipf-evidence-api" ], "description": { - "en": "SIPF review for pension payment stop and survivor benefit eligibility." + "en": "The programme composes CRA and SIPF signed assertions without exposing unrelated civil data." }, "holds_requirements": [ - "pension-survivor-review-requirement" + "cra-pension-death-v1", + "sipf-pension-payment-v1", + "sipf-survivor-benefit-v1" ], "id": "pension-survivor-review", "iri": "https://id.registrystack.org/solmara/services/pension-survivor-review", @@ -48,39 +52,42 @@ } }, { - "competent_authority": "nagdi", + "competent_authority": "nia", "data_services": [ - "nagdi-notary-api" + "cra-evidence-api", + "nia-evidence-api" ], "description": { - "en": "NAgDI review of farmer eligibility for climate-smart input support." + "en": "The application composes separately signed CRA and NIA citizen assertions." }, "holds_requirements": [ - "nagdi-voucher-requirement" + "cra-citizen-record-v1", + "nia-citizen-status-v1" ], - "id": "nagdi-voucher-review", - "iri": "https://id.registrystack.org/solmara/services/nagdi-voucher-review", + "id": "citizen-self-service", + "iri": "https://id.registrystack.org/solmara/services/citizen-self-service", "jurisdiction": "https://id.registrystack.org/solmara/jurisdiction/XS", "title": { - "en": "Farmer climate-smart voucher review" + "en": "Citizen self-service" } }, { "competent_authority": "nagdi", "data_services": [ - "nagdi-notary-api" + "nagdi-evidence-api" ], "description": { - "en": "NAgDI review of livestock movement permit eligibility and quarantine controls." + "en": "NAgDI applications consume independently signed voucher and livestock assertions." }, "holds_requirements": [ - "livestock-movement-requirement" + "nagdi-voucher-v1", + "nagdi-livestock-v1" ], - "id": "livestock-movement-control", - "iri": "https://id.registrystack.org/solmara/services/livestock-movement-control", + "id": "agriculture-review", + "iri": "https://id.registrystack.org/solmara/services/agriculture-review", "jurisdiction": "https://id.registrystack.org/solmara/jurisdiction/XS", "title": { - "en": "Livestock movement control" + "en": "Agriculture voucher and livestock review" } } ], @@ -88,18 +95,18 @@ "solmara:evidenceOfferings": [ { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "birth-is-registered" + "source_type": "immutable extract" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed immutable extract assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "birth-registration-evidence", - "id": "cra-birth-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration", + "evidence_type": "cra-child-benefit-v1", + "id": "cra-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -110,7 +117,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-birth-registration-offering-policy", + "policy": "cra-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -122,26 +129,26 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/birth-is-registered", + "https://id.registrystack.org/solmara/concept/child-age-under-5" ] }, - "title": "CRA birth registration evidence offering" + "title": "Civil Registration Authority Cra child benefit v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "person-is-deceased" + "source_type": "Relay lookup" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "death-registration-evidence", - "id": "cra-death-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration", + "evidence_type": "cra-death-status-v1", + "id": "cra-pension-death-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-pension-death-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -152,7 +159,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-death-registration-offering-policy", + "policy": "cra-pension-death-v1-offering-policy", "public_services": [ "pension-survivor-review" ], @@ -164,25 +171,66 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/person-is-deceased" ] }, - "title": "CRA death registration evidence offering" + "title": "Civil Registration Authority Cra death status v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nia-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nia-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "cra-civil", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", + "entity": "civil_person", + "evidence_type": "civil-record-linked-v1", + "id": "cra-citizen-record-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-citizen-record-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "cra", + "iri": "did:web:id.registrystack.org:solmara:authority:cra", + "name": "Civil Registration Authority", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "cra-citizen-record-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/civil-record-linked" + ] + }, + "title": "Civil Registration Authority Civil record linked v1" + }, + { + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" }, "dataset": "nia-population", - "description": "Purpose-limited evidence predicates from Population register.", - "entity": "person", - "evidence_type": "population-status-evidence", - "id": "nia-population-population-status-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-population-population-status-offering", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "population-active-v1", + "id": "nia-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "nia", @@ -193,7 +241,7 @@ "lookup_keys": [ "uin" ], - "policy": "nia-population-population-status-offering-policy", + "policy": "nia-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -205,24 +253,66 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Person" + "https://id.registrystack.org/solmara/concept/population-record-active" ] }, - "title": "Population register evidence offering" + "title": "National Identity Agency Population active v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sro-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sro-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "nia-population", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "citizen-population-active-v1", + "id": "nia-citizen-status-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-citizen-status-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nia", + "iri": "did:web:id.registrystack.org:solmara:authority:nia", + "name": "National Identity Agency", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "nia-citizen-status-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/citizen-population-record-active" + ] + }, + "title": "National Identity Agency Citizen population active v1" + }, + { + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" }, "dataset": "sro-social", - "description": "Purpose-limited evidence predicates from Social registry.", - "entity": "household", - "evidence_type": "household-poverty-evidence", - "id": "sro-social-household-poverty-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-social-household-poverty-offering", + "description": "An independently signed immutable extract assertion issued by Social Registry Office.", + "entity": "poverty_record", + "evidence_type": "poverty-priority-v1", + "id": "sro-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "sro", @@ -233,7 +323,7 @@ "lookup_keys": [ "uin" ], - "policy": "sro-social-household-poverty-offering-policy", + "policy": "sro-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -245,25 +335,25 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Household", - "https://publicschema.org/SocioEconomicProfile" + "https://id.registrystack.org/solmara/concept/household-below-poverty-threshold" ] }, - "title": "Social registry evidence offering" + "title": "Social Registry Office Poverty priority v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://programme-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://programme-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "mosd-programme", - "description": "Purpose-limited evidence predicates from Integrated beneficiary registry.", - "entity": "enrollment", - "evidence_type": "beneficiary-enrollment-evidence", - "id": "mosd-programme-beneficiary-enrollment-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-programme-beneficiary-enrollment-offering", + "description": "An independently signed Relay lookup assertion issued by Ministry of Social Development Programme MIS.", + "entity": "beneficiary_enrolment", + "evidence_type": "not-enrolled-v1", + "id": "mosd-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "mosd-programme-mis", @@ -274,7 +364,7 @@ "lookup_keys": [ "uin" ], - "policy": "mosd-programme-beneficiary-enrollment-offering-policy", + "policy": "mosd-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -286,24 +376,25 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/sp/Enrollment" + "https://id.registrystack.org/solmara/concept/not-already-enrolled" ] }, - "title": "Integrated beneficiary registry evidence offering" + "title": "Ministry of Social Development Programme MIS Not enrolled v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sipf-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "sipf-pensions", - "description": "Purpose-limited evidence predicates from Pensions and social insurance.", - "entity": "pension_case", - "evidence_type": "pension-case-evidence", - "id": "sipf-pensions-pension-case-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pensions-pension-case-offering", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "pension_payment", + "evidence_type": "pension-payment-active-v1", + "id": "sipf-pension-payment-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pension-payment-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "sipf", @@ -312,79 +403,80 @@ "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "uin" + "pensioner_uin" ], - "policy": "sipf-pensions-pension-case-offering-policy", + "policy": "sipf-pension-payment-v1-offering-policy", "public_services": [ "pension-survivor-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + "https://id.registrystack.org/solmara/purpose/pension-payment-review" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://id.registrystack.org/solmara/semantics/pension-case" + "https://id.registrystack.org/solmara/concept/pension-payment-active" ] }, - "title": "Pensions and social insurance evidence offering" + "title": "Social Insurance and Pensions Fund Pension payment active v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, - "dataset": "nagdi-agriculture", - "description": "Purpose-limited evidence predicates from Farmer and livestock registries.", - "entity": "farmer_voucher", - "evidence_type": "farmer-voucher-evidence", - "id": "nagdi-agriculture-farmer-voucher-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-farmer-voucher-offering", + "dataset": "sipf-pensions", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "survivor_case", + "evidence_type": "survivor-benefit-v1", + "id": "sipf-survivor-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-survivor-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", - "id": "nagdi", - "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", - "name": "National Agricultural Data Institute", + "id": "sipf", + "iri": "did:web:id.registrystack.org:solmara:authority:sipf", + "name": "Social Insurance and Pensions Fund", "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "farmer_id" + "spouse_uin" ], - "policy": "nagdi-agriculture-farmer-voucher-offering-policy", + "policy": "sipf-survivor-benefit-v1-offering-policy", "public_services": [ - "nagdi-voucher-review" + "pension-survivor-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Farm" + "https://id.registrystack.org/solmara/concept/survivor-is-eligible" ] }, - "title": "Farmer and livestock registries evidence offering" + "title": "Social Insurance and Pensions Fund Survivor benefit v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "nagdi-agriculture", - "description": "Livestock movement-control predicates for permit checks.", - "entity": "livestock_movement", - "evidence_type": "livestock-movement-evidence", - "id": "nagdi-agriculture-livestock-movement-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-livestock-movement-offering", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "farmer_voucher", + "evidence_type": "climate-smart-voucher-v1", + "id": "nagdi-voucher-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-voucher-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "nagdi", @@ -395,67 +487,67 @@ "lookup_keys": [ "farmer_id" ], - "policy": "nagdi-agriculture-livestock-movement-offering-policy", + "policy": "nagdi-voucher-v1-offering-policy", "public_services": [ - "livestock-movement-control" + "agriculture-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/livestock-movement-control" + "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/livestock-type" + "https://id.registrystack.org/solmara/concept/farmer-registered", + "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", + "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher" ] }, - "title": "NAgDI livestock movement evidence offering" + "title": "National Agricultural Data Institute Climate smart voucher v1" }, { "access": { - "conforms_to": "https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1", - "discovery_url": "https://child-benefit-federator.solmara.registrystack.org/v1/claims", - "endpoint_url": "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - "kind": "authority-predicate-collection-api", - "media_type": "application/json", - "ruleset": "source-owned-child-benefit-predicates-v1" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, - "dataset": "mosd-programme", - "description": "A transient collection of source-owned child benefit predicates. It contains no copied source rows and no composed eligibility decision.", - "entity": "enrollment", - "evidence_type": "child-benefit-authority-predicate-collection-evidence", - "id": "solmara.child-benefit.authority-predicate-collection", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", + "dataset": "nagdi-agriculture", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "livestock_movement", + "evidence_type": "livestock-movement-v1", + "id": "nagdi-livestock-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-livestock-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", - "id": "mosd-programme-mis", - "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", - "name": "Ministry of Social Development Programme MIS", + "id": "nagdi", + "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", + "name": "National Agricultural Data Institute", "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "uin" + "farmer_id" ], - "policy": "solmara-child-benefit-authority-predicate-collection-policy", + "policy": "nagdi-livestock-v1-offering-policy", "public_services": [ - "child-benefit-review" + "agriculture-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" + "https://id.registrystack.org/solmara/purpose/livestock-movement-control" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Person", - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/Household", - "https://publicschema.org/sp/Enrollment" + "https://id.registrystack.org/solmara/concept/registered-herd", + "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", + "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit" ] }, - "title": "Child Benefit Authority Predicate Collection" + "title": "National Agricultural Data Institute Livestock movement v1" } ] } diff --git a/metadata/public/metadata/dcat.jsonld b/metadata/public/metadata/dcat.jsonld index cfe1c09..2001947 100644 --- a/metadata/public/metadata/dcat.jsonld +++ b/metadata/public/metadata/dcat.jsonld @@ -8,29 +8,29 @@ "dcat:dataset": [ { "@id": "https://id.registrystack.org/solmara/datasets/cra-civil", - "dct:title": "Civil registration" + "dct:title": "CRA civil registration publication" }, { "@id": "https://id.registrystack.org/solmara/datasets/nia-population", - "dct:title": "Population register" + "dct:title": "NIA population publication" }, { "@id": "https://id.registrystack.org/solmara/datasets/sro-social", - "dct:title": "Social registry" + "dct:title": "SRO poverty extract" }, { "@id": "https://id.registrystack.org/solmara/datasets/mosd-programme", - "dct:title": "Integrated beneficiary registry" + "dct:title": "MoSD programme enrolment publication" }, { "@id": "https://id.registrystack.org/solmara/datasets/sipf-pensions", - "dct:title": "Pensions and social insurance" + "dct:title": "SIPF pension and survivor publication" }, { "@id": "https://id.registrystack.org/solmara/datasets/nagdi-agriculture", - "dct:title": "Farmer and livestock registries" + "dct:title": "NAgDI agriculture publication" } ], - "dct:description": "One merged CPSV-AP metadata publish for Solmara Wave 1 registry and evidence services.", - "dct:title": "Solmara Lab Wave 1 Service Catalogue" + "dct:description": "Six authority-owned Evidence cells, five governed Relay V2 services, and the programme applications that compose signed assertions.", + "dct:title": "Solmara authority-owned Evidence catalogue" } diff --git a/metadata/public/metadata/evidence-offerings.json b/metadata/public/metadata/evidence-offerings.json index ceb4c8f..b1bc293 100644 --- a/metadata/public/metadata/evidence-offerings.json +++ b/metadata/public/metadata/evidence-offerings.json @@ -2,18 +2,18 @@ "offerings": [ { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "birth-is-registered" + "source_type": "immutable extract" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed immutable extract assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "birth-registration-evidence", - "id": "cra-birth-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration", + "evidence_type": "cra-child-benefit-v1", + "id": "cra-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -24,7 +24,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-birth-registration-offering-policy", + "policy": "cra-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -36,26 +36,26 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/birth-is-registered", + "https://id.registrystack.org/solmara/concept/child-age-under-5" ] }, - "title": "CRA birth registration evidence offering" + "title": "Civil Registration Authority Cra child benefit v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "person-is-deceased" + "source_type": "Relay lookup" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "death-registration-evidence", - "id": "cra-death-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration", + "evidence_type": "cra-death-status-v1", + "id": "cra-pension-death-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-pension-death-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -66,7 +66,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-death-registration-offering-policy", + "policy": "cra-pension-death-v1-offering-policy", "public_services": [ "pension-survivor-review" ], @@ -78,25 +78,66 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/person-is-deceased" ] }, - "title": "CRA death registration evidence offering" + "title": "Civil Registration Authority Cra death status v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nia-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nia-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "cra-civil", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", + "entity": "civil_person", + "evidence_type": "civil-record-linked-v1", + "id": "cra-citizen-record-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-citizen-record-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "cra", + "iri": "did:web:id.registrystack.org:solmara:authority:cra", + "name": "Civil Registration Authority", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "cra-citizen-record-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/civil-record-linked" + ] + }, + "title": "Civil Registration Authority Civil record linked v1" + }, + { + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" }, "dataset": "nia-population", - "description": "Purpose-limited evidence predicates from Population register.", - "entity": "person", - "evidence_type": "population-status-evidence", - "id": "nia-population-population-status-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-population-population-status-offering", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "population-active-v1", + "id": "nia-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "nia", @@ -107,7 +148,7 @@ "lookup_keys": [ "uin" ], - "policy": "nia-population-population-status-offering-policy", + "policy": "nia-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -119,24 +160,66 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Person" + "https://id.registrystack.org/solmara/concept/population-record-active" + ] + }, + "title": "National Identity Agency Population active v1" + }, + { + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "nia-population", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "citizen-population-active-v1", + "id": "nia-citizen-status-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-citizen-status-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nia", + "iri": "did:web:id.registrystack.org:solmara:authority:nia", + "name": "National Identity Agency", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "nia-citizen-status-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/citizen-population-record-active" ] }, - "title": "Population register evidence offering" + "title": "National Identity Agency Citizen population active v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sro-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sro-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" }, "dataset": "sro-social", - "description": "Purpose-limited evidence predicates from Social registry.", - "entity": "household", - "evidence_type": "household-poverty-evidence", - "id": "sro-social-household-poverty-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-social-household-poverty-offering", + "description": "An independently signed immutable extract assertion issued by Social Registry Office.", + "entity": "poverty_record", + "evidence_type": "poverty-priority-v1", + "id": "sro-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "sro", @@ -147,7 +230,7 @@ "lookup_keys": [ "uin" ], - "policy": "sro-social-household-poverty-offering-policy", + "policy": "sro-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -159,25 +242,25 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Household", - "https://publicschema.org/SocioEconomicProfile" + "https://id.registrystack.org/solmara/concept/household-below-poverty-threshold" ] }, - "title": "Social registry evidence offering" + "title": "Social Registry Office Poverty priority v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://programme-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://programme-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "mosd-programme", - "description": "Purpose-limited evidence predicates from Integrated beneficiary registry.", - "entity": "enrollment", - "evidence_type": "beneficiary-enrollment-evidence", - "id": "mosd-programme-beneficiary-enrollment-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-programme-beneficiary-enrollment-offering", + "description": "An independently signed Relay lookup assertion issued by Ministry of Social Development Programme MIS.", + "entity": "beneficiary_enrolment", + "evidence_type": "not-enrolled-v1", + "id": "mosd-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-child-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "mosd-programme-mis", @@ -188,7 +271,7 @@ "lookup_keys": [ "uin" ], - "policy": "mosd-programme-beneficiary-enrollment-offering-policy", + "policy": "mosd-child-benefit-v1-offering-policy", "public_services": [ "child-benefit-review" ], @@ -200,24 +283,25 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/sp/Enrollment" + "https://id.registrystack.org/solmara/concept/not-already-enrolled" ] }, - "title": "Integrated beneficiary registry evidence offering" + "title": "Ministry of Social Development Programme MIS Not enrolled v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sipf-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "sipf-pensions", - "description": "Purpose-limited evidence predicates from Pensions and social insurance.", - "entity": "pension_case", - "evidence_type": "pension-case-evidence", - "id": "sipf-pensions-pension-case-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pensions-pension-case-offering", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "pension_payment", + "evidence_type": "pension-payment-active-v1", + "id": "sipf-pension-payment-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pension-payment-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "sipf", @@ -226,79 +310,80 @@ "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "uin" + "pensioner_uin" ], - "policy": "sipf-pensions-pension-case-offering-policy", + "policy": "sipf-pension-payment-v1-offering-policy", "public_services": [ "pension-survivor-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + "https://id.registrystack.org/solmara/purpose/pension-payment-review" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://id.registrystack.org/solmara/semantics/pension-case" + "https://id.registrystack.org/solmara/concept/pension-payment-active" ] }, - "title": "Pensions and social insurance evidence offering" + "title": "Social Insurance and Pensions Fund Pension payment active v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, - "dataset": "nagdi-agriculture", - "description": "Purpose-limited evidence predicates from Farmer and livestock registries.", - "entity": "farmer_voucher", - "evidence_type": "farmer-voucher-evidence", - "id": "nagdi-agriculture-farmer-voucher-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-farmer-voucher-offering", + "dataset": "sipf-pensions", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "survivor_case", + "evidence_type": "survivor-benefit-v1", + "id": "sipf-survivor-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-survivor-benefit-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", - "id": "nagdi", - "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", - "name": "National Agricultural Data Institute", + "id": "sipf", + "iri": "did:web:id.registrystack.org:solmara:authority:sipf", + "name": "Social Insurance and Pensions Fund", "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "farmer_id" + "spouse_uin" ], - "policy": "nagdi-agriculture-farmer-voucher-offering-policy", + "policy": "sipf-survivor-benefit-v1-offering-policy", "public_services": [ - "nagdi-voucher-review" + "pension-survivor-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Farm" + "https://id.registrystack.org/solmara/concept/survivor-is-eligible" ] }, - "title": "Farmer and livestock registries evidence offering" + "title": "Social Insurance and Pensions Fund Survivor benefit v1" }, { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, "dataset": "nagdi-agriculture", - "description": "Livestock movement-control predicates for permit checks.", - "entity": "livestock_movement", - "evidence_type": "livestock-movement-evidence", - "id": "nagdi-agriculture-livestock-movement-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-livestock-movement-offering", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "farmer_voucher", + "evidence_type": "climate-smart-voucher-v1", + "id": "nagdi-voucher-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-voucher-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "nagdi", @@ -309,67 +394,67 @@ "lookup_keys": [ "farmer_id" ], - "policy": "nagdi-agriculture-livestock-movement-offering-policy", + "policy": "nagdi-voucher-v1-offering-policy", "public_services": [ - "livestock-movement-control" + "agriculture-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/livestock-movement-control" + "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/livestock-type" + "https://id.registrystack.org/solmara/concept/farmer-registered", + "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", + "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher" ] }, - "title": "NAgDI livestock movement evidence offering" + "title": "National Agricultural Data Institute Climate smart voucher v1" }, { "access": { - "conforms_to": "https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1", - "discovery_url": "https://child-benefit-federator.solmara.registrystack.org/v1/claims", - "endpoint_url": "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - "kind": "authority-predicate-collection-api", - "media_type": "application/json", - "ruleset": "source-owned-child-benefit-predicates-v1" + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" }, - "dataset": "mosd-programme", - "description": "A transient collection of source-owned child benefit predicates. It contains no copied source rows and no composed eligibility decision.", - "entity": "enrollment", - "evidence_type": "child-benefit-authority-predicate-collection-evidence", - "id": "solmara.child-benefit.authority-predicate-collection", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", + "dataset": "nagdi-agriculture", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "livestock_movement", + "evidence_type": "livestock-movement-v1", + "id": "nagdi-livestock-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-livestock-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", - "id": "mosd-programme-mis", - "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", - "name": "Ministry of Social Development Programme MIS", + "id": "nagdi", + "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", + "name": "National Agricultural Data Institute", "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" }, "lookup_keys": [ - "uin" + "farmer_id" ], - "policy": "solmara-child-benefit-authority-predicate-collection-policy", + "policy": "nagdi-livestock-v1-offering-policy", "public_services": [ - "child-benefit-review" + "agriculture-review" ], "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" + "https://id.registrystack.org/solmara/purpose/livestock-movement-control" ], "semantics": { "application_profiles": [ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/Person", - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/Household", - "https://publicschema.org/sp/Enrollment" + "https://id.registrystack.org/solmara/concept/registered-herd", + "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", + "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit" ] }, - "title": "Child Benefit Authority Predicate Collection" + "title": "National Agricultural Data Institute Livestock movement v1" } ], "schema_version": "registry-manifest-evidence-offerings/v1" diff --git a/metadata/public/metadata/evidence-offerings/cra-birth-registration-offering.json b/metadata/public/metadata/evidence-offerings/cra-birth-registration-offering.json deleted file mode 100644 index 70700bb..0000000 --- a/metadata/public/metadata/evidence-offerings/cra-birth-registration-offering.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api", - "ruleset": "birth-is-registered" - }, - "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", - "entity": "civil_person", - "evidence_type": "birth-registration-evidence", - "id": "cra-birth-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "cra", - "iri": "did:web:id.registrystack.org:solmara:authority:cra", - "name": "Civil Registration Authority", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "uin" - ], - "policy": "cra-birth-registration-offering-policy", - "public_services": [ - "child-benefit-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" - ] - }, - "title": "CRA birth registration evidence offering" -} diff --git a/metadata/public/metadata/evidence-offerings/cra-child-benefit-v1-offering.json b/metadata/public/metadata/evidence-offerings/cra-child-benefit-v1-offering.json new file mode 100644 index 0000000..aad2fb3 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/cra-child-benefit-v1-offering.json @@ -0,0 +1,42 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "cra-civil", + "description": "An independently signed immutable extract assertion issued by Civil Registration Authority.", + "entity": "civil_person", + "evidence_type": "cra-child-benefit-v1", + "id": "cra-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-child-benefit-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "cra", + "iri": "did:web:id.registrystack.org:solmara:authority:cra", + "name": "Civil Registration Authority", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "cra-child-benefit-v1-offering-policy", + "public_services": [ + "child-benefit-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/child-benefit-review" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/birth-is-registered", + "https://id.registrystack.org/solmara/concept/child-age-under-5" + ] + }, + "title": "Civil Registration Authority Cra child benefit v1" +} diff --git a/metadata/public/metadata/evidence-offerings/cra-citizen-record-v1-offering.json b/metadata/public/metadata/evidence-offerings/cra-citizen-record-v1-offering.json new file mode 100644 index 0000000..c79ef22 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/cra-citizen-record-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "cra-civil", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", + "entity": "civil_person", + "evidence_type": "civil-record-linked-v1", + "id": "cra-citizen-record-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-citizen-record-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "cra", + "iri": "did:web:id.registrystack.org:solmara:authority:cra", + "name": "Civil Registration Authority", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "cra-citizen-record-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/civil-record-linked" + ] + }, + "title": "Civil Registration Authority Civil record linked v1" +} diff --git a/metadata/public/metadata/evidence-offerings/cra-death-registration-offering.json b/metadata/public/metadata/evidence-offerings/cra-pension-death-v1-offering.json similarity index 51% rename from metadata/public/metadata/evidence-offerings/cra-death-registration-offering.json rename to metadata/public/metadata/evidence-offerings/cra-pension-death-v1-offering.json index 41806f1..f92b0ff 100644 --- a/metadata/public/metadata/evidence-offerings/cra-death-registration-offering.json +++ b/metadata/public/metadata/evidence-offerings/cra-pension-death-v1-offering.json @@ -1,17 +1,17 @@ { "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://cra-notary.solmara.registrystack.org/v1/evaluations", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://cra-evidence.solmara.registrystack.org/v1/evidence", "kind": "evidence-verification-api", - "ruleset": "person-is-deceased" + "source_type": "Relay lookup" }, "dataset": "cra-civil", - "description": "Purpose-limited evidence predicates from Civil registration.", + "description": "An independently signed Relay lookup assertion issued by Civil Registration Authority.", "entity": "civil_person", - "evidence_type": "death-registration-evidence", - "id": "cra-death-registration-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration", + "evidence_type": "cra-death-status-v1", + "id": "cra-pension-death-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/cra-pension-death-v1-offering", "issuing_authority": { "authority_type": "eli:PublicAuthority", "id": "cra", @@ -22,7 +22,7 @@ "lookup_keys": [ "uin" ], - "policy": "cra-death-registration-offering-policy", + "policy": "cra-pension-death-v1-offering-policy", "public_services": [ "pension-survivor-review" ], @@ -34,9 +34,8 @@ "cpsv-ap" ], "concepts": [ - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/crvs/Death" + "https://id.registrystack.org/solmara/concept/person-is-deceased" ] }, - "title": "CRA death registration evidence offering" + "title": "Civil Registration Authority Cra death status v1" } diff --git a/metadata/public/metadata/evidence-offerings/mosd-child-benefit-v1-offering.json b/metadata/public/metadata/evidence-offerings/mosd-child-benefit-v1-offering.json new file mode 100644 index 0000000..c3995c0 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/mosd-child-benefit-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "mosd-programme", + "description": "An independently signed Relay lookup assertion issued by Ministry of Social Development Programme MIS.", + "entity": "beneficiary_enrolment", + "evidence_type": "not-enrolled-v1", + "id": "mosd-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-child-benefit-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "mosd-programme-mis", + "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", + "name": "Ministry of Social Development Programme MIS", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "mosd-child-benefit-v1-offering-policy", + "public_services": [ + "child-benefit-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/child-benefit-review" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/not-already-enrolled" + ] + }, + "title": "Ministry of Social Development Programme MIS Not enrolled v1" +} diff --git a/metadata/public/metadata/evidence-offerings/mosd-programme-beneficiary-enrollment-offering.json b/metadata/public/metadata/evidence-offerings/mosd-programme-beneficiary-enrollment-offering.json deleted file mode 100644 index b125b75..0000000 --- a/metadata/public/metadata/evidence-offerings/mosd-programme-beneficiary-enrollment-offering.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://programme-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://programme-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" - }, - "dataset": "mosd-programme", - "description": "Purpose-limited evidence predicates from Integrated beneficiary registry.", - "entity": "enrollment", - "evidence_type": "beneficiary-enrollment-evidence", - "id": "mosd-programme-beneficiary-enrollment-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/mosd-programme-beneficiary-enrollment-offering", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "mosd-programme-mis", - "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", - "name": "Ministry of Social Development Programme MIS", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "uin" - ], - "policy": "mosd-programme-beneficiary-enrollment-offering-policy", - "public_services": [ - "child-benefit-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/sp/Enrollment" - ] - }, - "title": "Integrated beneficiary registry evidence offering" -} diff --git a/metadata/public/metadata/evidence-offerings/nagdi-agriculture-farmer-voucher-offering.json b/metadata/public/metadata/evidence-offerings/nagdi-agriculture-farmer-voucher-offering.json deleted file mode 100644 index ad27091..0000000 --- a/metadata/public/metadata/evidence-offerings/nagdi-agriculture-farmer-voucher-offering.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" - }, - "dataset": "nagdi-agriculture", - "description": "Purpose-limited evidence predicates from Farmer and livestock registries.", - "entity": "farmer_voucher", - "evidence_type": "farmer-voucher-evidence", - "id": "nagdi-agriculture-farmer-voucher-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-farmer-voucher-offering", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "nagdi", - "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", - "name": "National Agricultural Data Institute", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "farmer_id" - ], - "policy": "nagdi-agriculture-farmer-voucher-offering-policy", - "public_services": [ - "nagdi-voucher-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/Farm" - ] - }, - "title": "Farmer and livestock registries evidence offering" -} diff --git a/metadata/public/metadata/evidence-offerings/nagdi-agriculture-livestock-movement-offering.json b/metadata/public/metadata/evidence-offerings/nagdi-agriculture-livestock-movement-offering.json deleted file mode 100644 index e5ce172..0000000 --- a/metadata/public/metadata/evidence-offerings/nagdi-agriculture-livestock-movement-offering.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" - }, - "dataset": "nagdi-agriculture", - "description": "Livestock movement-control predicates for permit checks.", - "entity": "livestock_movement", - "evidence_type": "livestock-movement-evidence", - "id": "nagdi-agriculture-livestock-movement-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-livestock-movement-offering", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "nagdi", - "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", - "name": "National Agricultural Data Institute", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "farmer_id" - ], - "policy": "nagdi-agriculture-livestock-movement-offering-policy", - "public_services": [ - "livestock-movement-control" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/livestock-movement-control" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/livestock-type" - ] - }, - "title": "NAgDI livestock movement evidence offering" -} diff --git a/metadata/public/metadata/evidence-offerings/nagdi-livestock-v1-offering.json b/metadata/public/metadata/evidence-offerings/nagdi-livestock-v1-offering.json new file mode 100644 index 0000000..15b9358 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/nagdi-livestock-v1-offering.json @@ -0,0 +1,43 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "nagdi-agriculture", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "livestock_movement", + "evidence_type": "livestock-movement-v1", + "id": "nagdi-livestock-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-livestock-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nagdi", + "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", + "name": "National Agricultural Data Institute", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "farmer_id" + ], + "policy": "nagdi-livestock-v1-offering-policy", + "public_services": [ + "agriculture-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/livestock-movement-control" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/registered-herd", + "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", + "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit" + ] + }, + "title": "National Agricultural Data Institute Livestock movement v1" +} diff --git a/metadata/public/metadata/evidence-offerings/nagdi-voucher-v1-offering.json b/metadata/public/metadata/evidence-offerings/nagdi-voucher-v1-offering.json new file mode 100644 index 0000000..03e5f91 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/nagdi-voucher-v1-offering.json @@ -0,0 +1,43 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nagdi-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "nagdi-agriculture", + "description": "An independently signed Relay lookup assertion issued by National Agricultural Data Institute.", + "entity": "farmer_voucher", + "evidence_type": "climate-smart-voucher-v1", + "id": "nagdi-voucher-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-voucher-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nagdi", + "iri": "did:web:id.registrystack.org:solmara:authority:nagdi", + "name": "National Agricultural Data Institute", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "farmer_id" + ], + "policy": "nagdi-voucher-v1-offering-policy", + "public_services": [ + "agriculture-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/farmer-registered", + "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", + "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher" + ] + }, + "title": "National Agricultural Data Institute Climate smart voucher v1" +} diff --git a/metadata/public/metadata/evidence-offerings/nia-child-benefit-v1-offering.json b/metadata/public/metadata/evidence-offerings/nia-child-benefit-v1-offering.json new file mode 100644 index 0000000..f3024e5 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/nia-child-benefit-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "nia-population", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "population-active-v1", + "id": "nia-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-child-benefit-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nia", + "iri": "did:web:id.registrystack.org:solmara:authority:nia", + "name": "National Identity Agency", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "nia-child-benefit-v1-offering-policy", + "public_services": [ + "child-benefit-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/child-benefit-review" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/population-record-active" + ] + }, + "title": "National Identity Agency Population active v1" +} diff --git a/metadata/public/metadata/evidence-offerings/nia-citizen-status-v1-offering.json b/metadata/public/metadata/evidence-offerings/nia-citizen-status-v1-offering.json new file mode 100644 index 0000000..dc9e703 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/nia-citizen-status-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://nia-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "nia-population", + "description": "An independently signed immutable extract assertion issued by National Identity Agency.", + "entity": "population_person", + "evidence_type": "citizen-population-active-v1", + "id": "nia-citizen-status-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-citizen-status-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "nia", + "iri": "did:web:id.registrystack.org:solmara:authority:nia", + "name": "National Identity Agency", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "nia-citizen-status-v1-offering-policy", + "public_services": [ + "citizen-self-service" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/citizen-population-record-active" + ] + }, + "title": "National Identity Agency Citizen population active v1" +} diff --git a/metadata/public/metadata/evidence-offerings/nia-population-population-status-offering.json b/metadata/public/metadata/evidence-offerings/nia-population-population-status-offering.json deleted file mode 100644 index de06c1c..0000000 --- a/metadata/public/metadata/evidence-offerings/nia-population-population-status-offering.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://nia-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://nia-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" - }, - "dataset": "nia-population", - "description": "Purpose-limited evidence predicates from Population register.", - "entity": "person", - "evidence_type": "population-status-evidence", - "id": "nia-population-population-status-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/nia-population-population-status-offering", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "nia", - "iri": "did:web:id.registrystack.org:solmara:authority:nia", - "name": "National Identity Agency", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "uin" - ], - "policy": "nia-population-population-status-offering-policy", - "public_services": [ - "child-benefit-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/Person" - ] - }, - "title": "Population register evidence offering" -} diff --git a/metadata/public/metadata/evidence-offerings/sipf-pension-payment-v1-offering.json b/metadata/public/metadata/evidence-offerings/sipf-pension-payment-v1-offering.json new file mode 100644 index 0000000..10b76b6 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/sipf-pension-payment-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "sipf-pensions", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "pension_payment", + "evidence_type": "pension-payment-active-v1", + "id": "sipf-pension-payment-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pension-payment-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "sipf", + "iri": "did:web:id.registrystack.org:solmara:authority:sipf", + "name": "Social Insurance and Pensions Fund", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "pensioner_uin" + ], + "policy": "sipf-pension-payment-v1-offering-policy", + "public_services": [ + "pension-survivor-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/pension-payment-review" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/pension-payment-active" + ] + }, + "title": "Social Insurance and Pensions Fund Pension payment active v1" +} diff --git a/metadata/public/metadata/evidence-offerings/sipf-pensions-pension-case-offering.json b/metadata/public/metadata/evidence-offerings/sipf-pensions-pension-case-offering.json deleted file mode 100644 index 5388236..0000000 --- a/metadata/public/metadata/evidence-offerings/sipf-pensions-pension-case-offering.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sipf-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" - }, - "dataset": "sipf-pensions", - "description": "Purpose-limited evidence predicates from Pensions and social insurance.", - "entity": "pension_case", - "evidence_type": "pension-case-evidence", - "id": "sipf-pensions-pension-case-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pensions-pension-case-offering", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "sipf", - "iri": "did:web:id.registrystack.org:solmara:authority:sipf", - "name": "Social Insurance and Pensions Fund", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "uin" - ], - "policy": "sipf-pensions-pension-case-offering-policy", - "public_services": [ - "pension-survivor-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://id.registrystack.org/solmara/semantics/pension-case" - ] - }, - "title": "Pensions and social insurance evidence offering" -} diff --git a/metadata/public/metadata/evidence-offerings/sipf-survivor-benefit-v1-offering.json b/metadata/public/metadata/evidence-offerings/sipf-survivor-benefit-v1-offering.json new file mode 100644 index 0000000..9961793 --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/sipf-survivor-benefit-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sipf-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "Relay lookup" + }, + "dataset": "sipf-pensions", + "description": "An independently signed Relay lookup assertion issued by Social Insurance and Pensions Fund.", + "entity": "survivor_case", + "evidence_type": "survivor-benefit-v1", + "id": "sipf-survivor-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sipf-survivor-benefit-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "sipf", + "iri": "did:web:id.registrystack.org:solmara:authority:sipf", + "name": "Social Insurance and Pensions Fund", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "spouse_uin" + ], + "policy": "sipf-survivor-benefit-v1-offering-policy", + "public_services": [ + "pension-survivor-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/survivor-is-eligible" + ] + }, + "title": "Social Insurance and Pensions Fund Survivor benefit v1" +} diff --git a/metadata/public/metadata/evidence-offerings/solmara.child-benefit.authority-predicate-collection.json b/metadata/public/metadata/evidence-offerings/solmara.child-benefit.authority-predicate-collection.json deleted file mode 100644 index f9b5d50..0000000 --- a/metadata/public/metadata/evidence-offerings/solmara.child-benefit.authority-predicate-collection.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "access": { - "conforms_to": "https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1", - "discovery_url": "https://child-benefit-federator.solmara.registrystack.org/v1/claims", - "endpoint_url": "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - "kind": "authority-predicate-collection-api", - "media_type": "application/json", - "ruleset": "source-owned-child-benefit-predicates-v1" - }, - "dataset": "mosd-programme", - "description": "A transient collection of source-owned child benefit predicates. It contains no copied source rows and no composed eligibility decision.", - "entity": "enrollment", - "evidence_type": "child-benefit-authority-predicate-collection-evidence", - "id": "solmara.child-benefit.authority-predicate-collection", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "mosd-programme-mis", - "iri": "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", - "name": "Ministry of Social Development Programme MIS", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "uin" - ], - "policy": "solmara-child-benefit-authority-predicate-collection-policy", - "public_services": [ - "child-benefit-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/Person", - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/Household", - "https://publicschema.org/sp/Enrollment" - ] - }, - "title": "Child Benefit Authority Predicate Collection" -} diff --git a/metadata/public/metadata/evidence-offerings/sro-child-benefit-v1-offering.json b/metadata/public/metadata/evidence-offerings/sro-child-benefit-v1-offering.json new file mode 100644 index 0000000..906d5cb --- /dev/null +++ b/metadata/public/metadata/evidence-offerings/sro-child-benefit-v1-offering.json @@ -0,0 +1,41 @@ +{ + "access": { + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "discovery_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions", + "endpoint_url": "https://sro-evidence.solmara.registrystack.org/v1/evidence", + "kind": "evidence-verification-api", + "source_type": "immutable extract" + }, + "dataset": "sro-social", + "description": "An independently signed immutable extract assertion issued by Social Registry Office.", + "entity": "poverty_record", + "evidence_type": "poverty-priority-v1", + "id": "sro-child-benefit-v1-offering", + "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-child-benefit-v1-offering", + "issuing_authority": { + "authority_type": "eli:PublicAuthority", + "id": "sro", + "iri": "did:web:id.registrystack.org:solmara:authority:sro", + "name": "Social Registry Office", + "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" + }, + "lookup_keys": [ + "uin" + ], + "policy": "sro-child-benefit-v1-offering-policy", + "public_services": [ + "child-benefit-review" + ], + "purposes": [ + "https://id.registrystack.org/solmara/purpose/child-benefit-review" + ], + "semantics": { + "application_profiles": [ + "cpsv-ap" + ], + "concepts": [ + "https://id.registrystack.org/solmara/concept/household-below-poverty-threshold" + ] + }, + "title": "Social Registry Office Poverty priority v1" +} diff --git a/metadata/public/metadata/evidence-offerings/sro-social-household-poverty-offering.json b/metadata/public/metadata/evidence-offerings/sro-social-household-poverty-offering.json deleted file mode 100644 index 1a05692..0000000 --- a/metadata/public/metadata/evidence-offerings/sro-social-household-poverty-offering.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "access": { - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "discovery_url": "https://sro-notary.solmara.registrystack.org/.well-known/evidence-service", - "endpoint_url": "https://sro-notary.solmara.registrystack.org/v1/evaluations", - "kind": "evidence-verification-api" - }, - "dataset": "sro-social", - "description": "Purpose-limited evidence predicates from Social registry.", - "entity": "household", - "evidence_type": "household-poverty-evidence", - "id": "sro-social-household-poverty-offering", - "iri": "https://id.registrystack.org/solmara/evidence-offerings/sro-social-household-poverty-offering", - "issuing_authority": { - "authority_type": "eli:PublicAuthority", - "id": "sro", - "iri": "did:web:id.registrystack.org:solmara:authority:sro", - "name": "Social Registry Office", - "spatial": "https://id.registrystack.org/solmara/jurisdiction/XS" - }, - "lookup_keys": [ - "uin" - ], - "policy": "sro-social-household-poverty-offering-policy", - "public_services": [ - "child-benefit-review" - ], - "purposes": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ], - "semantics": { - "application_profiles": [ - "cpsv-ap" - ], - "concepts": [ - "https://publicschema.org/Household", - "https://publicschema.org/SocioEconomicProfile" - ] - }, - "title": "Social registry evidence offering" -} diff --git a/metadata/public/metadata/index.json b/metadata/public/metadata/index.json index 135a6ce..7efa2ef 100644 --- a/metadata/public/metadata/index.json +++ b/metadata/public/metadata/index.json @@ -9,132 +9,152 @@ { "media_type": "application/json", "path": "metadata/catalog.json", - "sha256": "sha256:2db419cefb6b0659792ac1ceb803d21a8fafd54eb75eefdced7496fdc8a480f4" + "sha256": "sha256:49604351742db066f104f1e648e383228bd786c0b264c1a4370331f8dc9f3e6e" }, { "media_type": "application/json", "path": "metadata/cpsv-ap", - "sha256": "sha256:ddfccb6700668ba4753a0ddfe00e80f3c2cfc60192863f84e1f76cd4529aa01d" + "sha256": "sha256:845c0707695d38306a78d5a5bc7edf051ece9db6f940f1b873520ef63fc5a8b6" }, { "media_type": "application/ld+json", "path": "metadata/cpsv-ap.jsonld", - "sha256": "sha256:ddfccb6700668ba4753a0ddfe00e80f3c2cfc60192863f84e1f76cd4529aa01d" + "sha256": "sha256:845c0707695d38306a78d5a5bc7edf051ece9db6f940f1b873520ef63fc5a8b6" }, { "media_type": "application/ld+json", "path": "metadata/dcat.jsonld", - "sha256": "sha256:6f0e778b71be0f94dd97dc2d7e87ce5850a363efd0ebdfb99b745f2ab5848b92" + "sha256": "sha256:3747beabd9d30201a2065963cdeefeb45a1b26c9ae380d787f1da7d511e81d32" }, { "media_type": "application/json", "path": "metadata/evidence-offerings.json", - "sha256": "sha256:bb079ab2166568c5e175438da8e6fd37edb5dc48bc2a93f38f409f4445b0db89" + "sha256": "sha256:f3d995ca9ce9f2ed0b083094763560dd2f8ea606edaa7f66f89713fd057abd67" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/cra-birth-registration-offering.json", - "sha256": "sha256:519d0e195db9fe938057c7dd2c4819b31c237cb7a7da06228a1cab3fc287fafd" + "path": "metadata/evidence-offerings/cra-child-benefit-v1-offering.json", + "sha256": "sha256:4945af2a2ce39cd876464f36b7c461241a533c0a439139ed40cf3499578fc1a0" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/cra-death-registration-offering.json", - "sha256": "sha256:8231e762fefa587af31560c759916754d53a5f742ad923385fd214eeac6884f1" + "path": "metadata/evidence-offerings/cra-citizen-record-v1-offering.json", + "sha256": "sha256:323c53c57e38fcb0a1763fbcf599bf1d8a9eab269c2800bce66ced3ad78a168a" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/mosd-programme-beneficiary-enrollment-offering.json", - "sha256": "sha256:90a2f91491e0d32615016c663cba787fad9c225287af741b7dfa4fa0f2c94fef" + "path": "metadata/evidence-offerings/cra-pension-death-v1-offering.json", + "sha256": "sha256:6546f52ad662d882796ea27cbab3205caee415b66183ae3563dc55433539a451" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/nagdi-agriculture-farmer-voucher-offering.json", - "sha256": "sha256:4920058bf44aa821eb48c8723072ffac8d1e1691e4ca2028b596a401169b4286" + "path": "metadata/evidence-offerings/mosd-child-benefit-v1-offering.json", + "sha256": "sha256:9eb7f35fb2efbb3f7b221dd87fd9f99f62f8d45639b8e304b832b7c57c671802" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/nagdi-agriculture-livestock-movement-offering.json", - "sha256": "sha256:62a87e2b217ae731012a82d3c7e660e1430dbe0cfdbfe0711e16a049e4ec47d2" + "path": "metadata/evidence-offerings/nagdi-livestock-v1-offering.json", + "sha256": "sha256:26379f6e3780710b0cd9bb44081b0c674e5819086eaec49da12e6ab919c726be" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/nia-population-population-status-offering.json", - "sha256": "sha256:ce917bdb721637857697e7ce6f13dc2ddb4c4ed584a353da22cc253561cd2e42" + "path": "metadata/evidence-offerings/nagdi-voucher-v1-offering.json", + "sha256": "sha256:1cd41c0b7ad9c669dd5bc2668dfc64266b06dd6f6093bb4e386bc81109a59873" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/sipf-pensions-pension-case-offering.json", - "sha256": "sha256:75ba6b9f33fc855e83bd1459fe6ab229b8b624477c7f3d36c00ad97ca0abd930" + "path": "metadata/evidence-offerings/nia-child-benefit-v1-offering.json", + "sha256": "sha256:8829626e163fa2050505296b3b6d564f0348747ca7368d8418f834d6572e0d4c" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/solmara.child-benefit.authority-predicate-collection.json", - "sha256": "sha256:1b59dd02b2730d034b180123098768117eceaca0504e4c25846105e978956cfb" + "path": "metadata/evidence-offerings/nia-citizen-status-v1-offering.json", + "sha256": "sha256:1603eafad482dfc6ac8e7c9fe8523e3950d1b40ee836d43b9c360373555378c3" }, { "media_type": "application/json", - "path": "metadata/evidence-offerings/sro-social-household-poverty-offering.json", - "sha256": "sha256:b2593c8bfe333bcbffff3f5d91f9ce1930654ddc0db86209263b616e915d15c2" + "path": "metadata/evidence-offerings/sipf-pension-payment-v1-offering.json", + "sha256": "sha256:1b73e1397709a63fc9c7a5715797427e8c679ec4b6c4a770bf8be32f0c892ee7" + }, + { + "media_type": "application/json", + "path": "metadata/evidence-offerings/sipf-survivor-benefit-v1-offering.json", + "sha256": "sha256:07365e008affe512ea996059b85623e4026e7d225b9484d89059616ea09577fa" + }, + { + "media_type": "application/json", + "path": "metadata/evidence-offerings/sro-child-benefit-v1-offering.json", + "sha256": "sha256:078cfc456039f1ca832c5544eab3429f8477a9488d3a20b6ceb47da2403be97b" }, { "media_type": "application/yaml", "path": "metadata/metadata.yaml", - "sha256": "sha256:3961e5a3c7d19688039db461f8d8585e6b5d42a4c1c5a58cb759557829d30cc5" + "sha256": "sha256:4c765f6526a95a84d59485b94e1fd57f677df824b91ecd5a3b00651cf006d191" }, { "media_type": "application/json", "path": "metadata/ogc-records/items.json", - "sha256": "sha256:aeb387ef3c8821b1e9cd1b40b1720c080cb59e686aefa2c3654493e28cda0d33" + "sha256": "sha256:2b924e2f6fa8f95275ec0a175e165276866d75a4ab176b2c5a0453d3fae0cde7" }, { "media_type": "application/ld+json", "path": "metadata/policies.jsonld", - "sha256": "sha256:9f6b4698c3f2c2f3ba11ea79c64f7cc9c9809c1821c3cd65b35b563ad2537aaf" + "sha256": "sha256:4b4402dbd21c787c85d42fbad8919beeb170fd9abc3278c6974c2f9d758b5cba" + }, + { + "media_type": "application/ld+json", + "path": "metadata/policies/cra-child-benefit-v1-offering-policy.jsonld", + "sha256": "sha256:fd48f0c5c5801e1b1d0ba72322c1cbe801af46ca4232dbaa9c0ace3ca589ac08" + }, + { + "media_type": "application/ld+json", + "path": "metadata/policies/cra-citizen-record-v1-offering-policy.jsonld", + "sha256": "sha256:3580c537bba48557e4ab3f431f55f45cc94e2e558a3e9fc547c7bd255088f9bc" }, { "media_type": "application/ld+json", - "path": "metadata/policies/cra-birth-registration-offering-policy.jsonld", - "sha256": "sha256:5765930b8ba06eb475e1cbea73299bb218da725b0ad0d632d46d2772b0a557ee" + "path": "metadata/policies/cra-pension-death-v1-offering-policy.jsonld", + "sha256": "sha256:bb2ae74ceaa689b39edac3161e2e3f5d85fce0b90786202ce3424616b25943cc" }, { "media_type": "application/ld+json", - "path": "metadata/policies/cra-death-registration-offering-policy.jsonld", - "sha256": "sha256:91e8caf238fab79fcb995d2181d3c0d27486e2f0c8a865891f0196d653a850cd" + "path": "metadata/policies/mosd-child-benefit-v1-offering-policy.jsonld", + "sha256": "sha256:d209ff4a6c3a6a4f7b3d69f53fdcb7fe55bc68fdbd9f7180f2374a34c0b6c789" }, { "media_type": "application/ld+json", - "path": "metadata/policies/mosd-programme-beneficiary-enrollment-offering-policy.jsonld", - "sha256": "sha256:2f10bcafccfb352e80cd01d48acd0a5552ee26bc2ba42b599fe7fbef08a82d84" + "path": "metadata/policies/nagdi-livestock-v1-offering-policy.jsonld", + "sha256": "sha256:e6d2e623aeb6fb683e8181c10c65c725fb9574b890968519d6c44fbc0bff0b60" }, { "media_type": "application/ld+json", - "path": "metadata/policies/nagdi-agriculture-farmer-voucher-offering-policy.jsonld", - "sha256": "sha256:a1146d20b753132190483bac84b96a2d681e8e2c3ccf5f1b78d8506f4862b322" + "path": "metadata/policies/nagdi-voucher-v1-offering-policy.jsonld", + "sha256": "sha256:e3a854169bf138c8195c994121cbace6e4ed84e147b3e6017e590612e3f50f62" }, { "media_type": "application/ld+json", - "path": "metadata/policies/nagdi-agriculture-livestock-movement-offering-policy.jsonld", - "sha256": "sha256:6c3f849025434b9abc5be8644bfd29011bb519914b293e8f3445845ab53376ea" + "path": "metadata/policies/nia-child-benefit-v1-offering-policy.jsonld", + "sha256": "sha256:fd0c92c384820881bbabf8fee115825d1197563263f6eb815135c311d409f033" }, { "media_type": "application/ld+json", - "path": "metadata/policies/nia-population-population-status-offering-policy.jsonld", - "sha256": "sha256:2d3a0ff6df7f37513a0ec334e933ae27a3412db2a5e2fd569a1f6be5081bbb9d" + "path": "metadata/policies/nia-citizen-status-v1-offering-policy.jsonld", + "sha256": "sha256:f24586f80452144f90e3816d926921ce6ec211e78685992bcbd8c0bbe8e34ff1" }, { "media_type": "application/ld+json", - "path": "metadata/policies/sipf-pensions-pension-case-offering-policy.jsonld", - "sha256": "sha256:743025c16e905b278546623c1d2f268502b6c013b667c56ed8d46fd603bc36d9" + "path": "metadata/policies/sipf-pension-payment-v1-offering-policy.jsonld", + "sha256": "sha256:9dc670d33ae13a2e1063eea6e3ab6ce11e409912f3c2833ed67d4417e36971ed" }, { "media_type": "application/ld+json", - "path": "metadata/policies/solmara-child-benefit-authority-predicate-collection-policy.jsonld", - "sha256": "sha256:7b14a9bf40cdc5da8da1c3dae62c554c19eff14d8b0255984081216b651c57ca" + "path": "metadata/policies/sipf-survivor-benefit-v1-offering-policy.jsonld", + "sha256": "sha256:cd7fd749cb732565369d7dadd2dd06da4366c169545261dd1c8f60bc584f900a" }, { "media_type": "application/ld+json", - "path": "metadata/policies/sro-social-household-poverty-offering-policy.jsonld", - "sha256": "sha256:2b99e4de49c24e68fb5b203cc43709aa285faad8dd2f0a3760c59846ccc6ae63" + "path": "metadata/policies/sro-child-benefit-v1-offering-policy.jsonld", + "sha256": "sha256:47aa3d94c91cc97a63141cb67e59c017f2a4bcb4bcc11f31d42096d808c52542" }, { "media_type": "application/ld+json", @@ -143,7 +163,7 @@ } ], "catalog": "/metadata/catalog.json", - "catalog_id": "solmara-wave1-multi-authority", + "catalog_id": "solmara-authority-cells", "dcat": "/metadata/dcat.jsonld", "evidence_offerings": "/metadata/evidence-offerings.json", "manifest": "/metadata/metadata.yaml", diff --git a/metadata/public/metadata/metadata.yaml b/metadata/public/metadata/metadata.yaml index e613e67..219ad77 100644 --- a/metadata/public/metadata/metadata.yaml +++ b/metadata/public/metadata/metadata.yaml @@ -1,11 +1,11 @@ schema_version: registry-manifest/v1 catalog: - id: solmara-wave1-multi-authority + id: solmara-authority-cells base_url: https://metadata.solmara.registrystack.org title: - en: Solmara Lab Wave 1 Service Catalogue + en: Solmara authority-owned Evidence catalogue description: - en: One merged CPSV-AP metadata publish for Solmara Wave 1 registry and evidence services. + en: Six authority-owned Evidence cells, five governed Relay V2 services, and the programme applications that compose signed assertions. publisher: name: Solmara Interoperability Metadata Publisher iri: did:web:id.registrystack.org:solmara:authority:metadata @@ -50,385 +50,280 @@ authorities: authority_type: eli:PublicAuthority spatial: https://id.registrystack.org/solmara/jurisdiction/XS requirements: - - id: child-benefit-review-requirement - iri: https://id.registrystack.org/solmara/requirements/child-benefit-review - title: - en: Child benefit eligibility review - description: - en: Confirm registered birth, under-five age, household poverty status, and duplicate enrollment status. + - id: cra-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1 + title: {en: CRA child benefit evidence} + description: {en: Confirm registered birth and age under five from CRA's immutable birth extract.} evidence_type_lists: - - id: child-benefit-wave1-evidence - title: - en: Child benefit registry evidence - evidence_types: - - birth-registration-evidence - - population-status-evidence - - household-poverty-evidence - - beneficiary-enrollment-evidence - - id: pension-survivor-review-requirement - iri: https://id.registrystack.org/solmara/requirements/pension-survivor-review - title: - en: Pension stop and survivor benefit review - description: - en: Confirm death registration, active pension payment state, and survivor eligibility. + - id: cra-child-benefit-list + title: {en: CRA child benefit evidence} + evidence_types: [cra-child-benefit-v1] + - id: cra-pension-death-v1 + iri: https://id.registrystack.org/solmara/requirement/cra-pension-death/v1 + title: {en: CRA pension death status} + description: {en: Confirm death registration through CRA's governed Relay lookup.} evidence_type_lists: - - id: pension-wave1-evidence - title: - en: Pension and survivor registry evidence - evidence_types: - - death-registration-evidence - - pension-case-evidence - - id: nagdi-voucher-requirement - iri: https://id.registrystack.org/solmara/requirements/nagdi-voucher - title: - en: Farmer climate-smart voucher review - description: - en: Confirm farmer registration, data-use authorization, parcel, crop, climate-risk, and redemption predicates. + - id: cra-pension-death-list + title: {en: CRA death-status evidence} + evidence_types: [cra-death-status-v1] + - id: cra-citizen-record-v1 + iri: https://id.registrystack.org/solmara/requirement/cra-citizen-record/v1 + title: {en: CRA citizen record link} + description: {en: Confirm a linked civil record through CRA's governed Relay lookup.} evidence_type_lists: - - id: nagdi-voucher-evidence - title: - en: Farmer voucher evidence - evidence_types: - - farmer-voucher-evidence - - id: livestock-movement-requirement - iri: https://id.registrystack.org/solmara/requirements/livestock-movement - title: - en: Livestock movement control review - description: - en: Confirm herd registration, vaccination, species-aware quarantine, destination, and open permit predicates. + - id: cra-citizen-record-list + title: {en: CRA civil-record evidence} + evidence_types: [civil-record-linked-v1] + - id: nia-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1 + title: {en: NIA child benefit population status} + description: {en: Confirm active population status from NIA's immutable population extract.} evidence_type_lists: - - id: livestock-movement-evidence-list - title: - en: Livestock movement evidence - evidence_types: - - livestock-movement-evidence + - id: nia-child-benefit-list + title: {en: NIA population evidence} + evidence_types: [population-active-v1] + - id: nia-citizen-status-v1 + iri: https://id.registrystack.org/solmara/requirement/nia-citizen-status/v1 + title: {en: NIA citizen population status} + description: {en: Confirm active citizen population status from NIA's immutable population extract.} + evidence_type_lists: + - id: nia-citizen-status-list + title: {en: NIA citizen-status evidence} + evidence_types: [citizen-population-active-v1] + - id: sro-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/sro-child-benefit/v1 + title: {en: SRO poverty priority} + description: {en: Confirm household poverty priority from SRO's immutable poverty extract.} + evidence_type_lists: + - id: sro-child-benefit-list + title: {en: SRO poverty evidence} + evidence_types: [poverty-priority-v1] + - id: mosd-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/mosd-child-benefit/v1 + title: {en: MoSD programme enrolment status} + description: {en: Confirm that the person is not already enrolled through MoSD's governed Relay lookup.} + evidence_type_lists: + - id: mosd-child-benefit-list + title: {en: MoSD enrolment evidence} + evidence_types: [not-enrolled-v1] + - id: sipf-pension-payment-v1 + iri: https://id.registrystack.org/solmara/requirement/sipf-pension-payment/v1 + title: {en: SIPF pension payment status} + description: {en: Confirm active pension payment through SIPF's governed Relay lookup.} + evidence_type_lists: + - id: sipf-pension-payment-list + title: {en: SIPF pension payment evidence} + evidence_types: [pension-payment-active-v1] + - id: sipf-survivor-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/sipf-survivor-benefit/v1 + title: {en: SIPF survivor eligibility} + description: {en: Confirm survivor eligibility through SIPF's governed Relay lookup.} + evidence_type_lists: + - id: sipf-survivor-benefit-list + title: {en: SIPF survivor evidence} + evidence_types: [survivor-benefit-v1] + - id: nagdi-voucher-v1 + iri: https://id.registrystack.org/solmara/requirement/nagdi-voucher/v1 + title: {en: NAgDI climate-smart voucher} + description: {en: Confirm climate-smart voucher eligibility through NAgDI's governed Relay lookup.} + evidence_type_lists: + - id: nagdi-voucher-list + title: {en: NAgDI voucher evidence} + evidence_types: [climate-smart-voucher-v1] + - id: nagdi-livestock-v1 + iri: https://id.registrystack.org/solmara/requirement/nagdi-livestock/v1 + title: {en: NAgDI livestock movement} + description: {en: Confirm livestock movement eligibility through NAgDI's governed Relay lookup.} + evidence_type_lists: + - id: nagdi-livestock-list + title: {en: NAgDI livestock evidence} + evidence_types: [livestock-movement-v1] evidence_types: - - id: birth-registration-evidence - iri: https://id.registrystack.org/solmara/evidence-types/birth-registration - title: - en: Birth registration evidence - proves: - - child-benefit-review-requirement - information_concepts: - - ps:crvs/Birth - - id: population-status-evidence - iri: https://id.registrystack.org/solmara/evidence-types/population-status - title: - en: Population status evidence - proves: - - child-benefit-review-requirement - information_concepts: - - ps:Person - - id: household-poverty-evidence - iri: https://id.registrystack.org/solmara/evidence-types/household-poverty - title: - en: Household poverty evidence - proves: - - child-benefit-review-requirement + - id: cra-child-benefit-v1 + iri: https://id.registrystack.org/solmara/evidence-type/cra-child-benefit/v1 + title: {en: CRA child benefit assertion} + proves: [cra-child-benefit-v1] information_concepts: - - ps:Household - - ps:SocioEconomicProfile - - id: beneficiary-enrollment-evidence - iri: https://id.registrystack.org/solmara/evidence-types/beneficiary-enrollment - title: - en: Beneficiary enrollment evidence - proves: - - child-benefit-review-requirement + - https://id.registrystack.org/solmara/concept/birth-is-registered + - https://id.registrystack.org/solmara/concept/child-age-under-5 + - id: cra-death-status-v1 + iri: https://id.registrystack.org/solmara/evidence-type/cra-death-status/v1 + title: {en: CRA death-status assertion} + proves: [cra-pension-death-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/person-is-deceased] + - id: civil-record-linked-v1 + iri: https://id.registrystack.org/solmara/evidence-type/civil-record-linked/v1 + title: {en: CRA civil-record assertion} + proves: [cra-citizen-record-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/civil-record-linked] + - id: population-active-v1 + iri: https://id.registrystack.org/solmara/evidence-type/population-active/v1 + title: {en: NIA active-population assertion} + proves: [nia-child-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/population-record-active] + - id: citizen-population-active-v1 + iri: https://id.registrystack.org/solmara/evidence-type/citizen-population-active/v1 + title: {en: NIA active-citizen assertion} + proves: [nia-citizen-status-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/citizen-population-record-active] + - id: poverty-priority-v1 + iri: https://id.registrystack.org/solmara/evidence-type/poverty-priority/v1 + title: {en: SRO poverty-priority assertion} + proves: [sro-child-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/household-below-poverty-threshold] + - id: not-enrolled-v1 + iri: https://id.registrystack.org/solmara/evidence-type/not-enrolled/v1 + title: {en: MoSD not-enrolled assertion} + proves: [mosd-child-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/not-already-enrolled] + - id: pension-payment-active-v1 + iri: https://id.registrystack.org/solmara/evidence-type/pension-payment-active/v1 + title: {en: SIPF active-payment assertion} + proves: [sipf-pension-payment-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/pension-payment-active] + - id: survivor-benefit-v1 + iri: https://id.registrystack.org/solmara/evidence-type/survivor-benefit/v1 + title: {en: SIPF survivor-benefit assertion} + proves: [sipf-survivor-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/survivor-is-eligible] + - id: climate-smart-voucher-v1 + iri: https://id.registrystack.org/solmara/evidence-type/climate-smart-voucher/v1 + title: {en: NAgDI climate-smart voucher assertion} + proves: [nagdi-voucher-v1] information_concepts: - - ps:sp/Enrollment - - id: child-benefit-authority-predicate-collection-evidence - iri: https://id.registrystack.org/solmara/evidence-types/child-benefit-authority-predicate-collection - title: - en: Child benefit authority predicate collection - description: - en: A transient collection of source-owned child benefit predicates with no copied source rows or eligibility decision. - proves: - - child-benefit-review-requirement + - https://id.registrystack.org/solmara/concept/farmer-registered + - https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose + - https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher + - id: livestock-movement-v1 + iri: https://id.registrystack.org/solmara/evidence-type/livestock-movement/v1 + title: {en: NAgDI livestock movement assertion} + proves: [nagdi-livestock-v1] information_concepts: - - ps:Person - - id: death-registration-evidence - iri: https://id.registrystack.org/solmara/evidence-types/death-registration - title: - en: Death registration evidence - proves: - - pension-survivor-review-requirement - information_concepts: - - ps:crvs/Death - - id: pension-case-evidence - iri: https://id.registrystack.org/solmara/evidence-types/pension-case - title: - en: Pension case evidence - proves: - - pension-survivor-review-requirement - - id: farmer-voucher-evidence - iri: https://id.registrystack.org/solmara/evidence-types/farmer-voucher - title: - en: Farmer voucher evidence - proves: - - nagdi-voucher-requirement - information_concepts: - - ps:Farm - - id: livestock-movement-evidence - iri: https://id.registrystack.org/solmara/evidence-types/livestock-movement - title: - en: Livestock movement evidence - proves: - - livestock-movement-requirement + - https://id.registrystack.org/solmara/concept/registered-herd + - https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species + - https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit public_services: - id: child-benefit-review iri: https://id.registrystack.org/solmara/services/child-benefit-review - title: - en: Child benefit eligibility review - description: - en: MoSD review of child benefit eligibility using minimized registry evidence. + title: {en: Child benefit eligibility review} + description: {en: The programme composes four independently signed authority assertions into five reviewed concepts.} competent_authority: mosd-programme-mis jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - child-benefit-review-requirement - data_services: - - child-benefit-evidence-collector-api - - cra-notary-api - - nia-notary-api - - sro-notary-api - - programme-notary-api + holds_requirements: [cra-child-benefit-v1, nia-child-benefit-v1, sro-child-benefit-v1, mosd-child-benefit-v1] + data_services: [cra-evidence-api, nia-evidence-api, sro-evidence-api, mosd-programme-evidence-api] - id: pension-survivor-review iri: https://id.registrystack.org/solmara/services/pension-survivor-review - title: - en: Pension stop and survivor benefit review - description: - en: SIPF review for pension payment stop and survivor benefit eligibility. + title: {en: Pension stop and survivor benefit review} + description: {en: The programme composes CRA and SIPF signed assertions without exposing unrelated civil data.} competent_authority: sipf jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - pension-survivor-review-requirement - data_services: - - cra-notary-api - - sipf-notary-api - - id: nagdi-voucher-review - iri: https://id.registrystack.org/solmara/services/nagdi-voucher-review - title: - en: Farmer climate-smart voucher review - description: - en: NAgDI review of farmer eligibility for climate-smart input support. - competent_authority: nagdi + holds_requirements: [cra-pension-death-v1, sipf-pension-payment-v1, sipf-survivor-benefit-v1] + data_services: [cra-evidence-api, sipf-evidence-api] + - id: citizen-self-service + iri: https://id.registrystack.org/solmara/services/citizen-self-service + title: {en: Citizen self-service} + description: {en: The application composes separately signed CRA and NIA citizen assertions.} + competent_authority: nia jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - nagdi-voucher-requirement - data_services: - - nagdi-notary-api - - id: livestock-movement-control - iri: https://id.registrystack.org/solmara/services/livestock-movement-control - title: - en: Livestock movement control - description: - en: NAgDI review of livestock movement permit eligibility and quarantine controls. + holds_requirements: [cra-citizen-record-v1, nia-citizen-status-v1] + data_services: [cra-evidence-api, nia-evidence-api] + - id: agriculture-review + iri: https://id.registrystack.org/solmara/services/agriculture-review + title: {en: Agriculture voucher and livestock review} + description: {en: NAgDI applications consume independently signed voucher and livestock assertions.} competent_authority: nagdi jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - livestock-movement-requirement - data_services: - - nagdi-notary-api + holds_requirements: [nagdi-voucher-v1, nagdi-livestock-v1] + data_services: [nagdi-evidence-api] data_services: - - id: child-benefit-evidence-collector-api - iri: https://child-benefit-federator.solmara.registrystack.org/v1/evaluations - title: - en: Child benefit authority predicate collection API - description: - en: Returns application/json collections assembled from source-Notary predicates without composing an eligibility decision. - endpoint_url: https://child-benefit-federator.solmara.registrystack.org/v1/evaluations - endpoint_description: https://child-benefit-federator.solmara.registrystack.org/v1/claims - conforms_to: https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1 - - id: cra-notary-api - iri: https://cra-notary.solmara.registrystack.org/v1/evaluations - title: - en: CRA evidence API - endpoint_url: https://cra-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://cra-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: nia-notary-api - iri: https://nia-notary.solmara.registrystack.org/v1/evaluations - title: - en: NIA evidence API - endpoint_url: https://nia-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://nia-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: sro-notary-api - iri: https://sro-notary.solmara.registrystack.org/v1/evaluations - title: - en: SRO evidence API - endpoint_url: https://sro-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://sro-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: programme-notary-api - iri: https://programme-notary.solmara.registrystack.org/v1/evaluations - title: - en: Programme MIS evidence API - endpoint_url: https://programme-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://programme-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: sipf-notary-api - iri: https://sipf-notary.solmara.registrystack.org/v1/evaluations - title: - en: SIPF evidence API - endpoint_url: https://sipf-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://sipf-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: nagdi-notary-api - iri: https://nagdi-notary.solmara.registrystack.org/v1/evaluations - title: - en: NAgDI evidence API - endpoint_url: https://nagdi-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://nagdi-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 + - id: cra-evidence-api + iri: https://cra-evidence.solmara.registrystack.org/v1/evidence + title: {en: CRA Evidence cell} + endpoint_url: https://cra-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: nia-evidence-api + iri: https://nia-evidence.solmara.registrystack.org/v1/evidence + title: {en: NIA Evidence cell} + endpoint_url: https://nia-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: sro-evidence-api + iri: https://sro-evidence.solmara.registrystack.org/v1/evidence + title: {en: SRO Evidence cell} + endpoint_url: https://sro-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: mosd-programme-evidence-api + iri: https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence + title: {en: MoSD Programme Evidence cell} + endpoint_url: https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: sipf-evidence-api + iri: https://sipf-evidence.solmara.registrystack.org/v1/evidence + title: {en: SIPF Evidence cell} + endpoint_url: https://sipf-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: nagdi-evidence-api + iri: https://nagdi-evidence.solmara.registrystack.org/v1/evidence + title: {en: NAgDI Evidence cell} + endpoint_url: https://nagdi-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 datasets: - id: cra-civil - title: - en: Civil registration + title: {en: CRA civil registration publication} + description: {en: CRA-owned immutable birth extract and governed civil-person Relay source.} access_rights: restricted entities: - name: civil_person - title: - en: Civil person - identifiers: - - name: uin - kind: uin - fields: - - name: uin - type: string - required: false - concepts: - - ps:Person.identifier - - name: birth_brn - type: string - required: false - concepts: - - ps:crvs/Birth.registrationNumber - - name: death_drn - type: string - required: false - concepts: - - ps:crvs/Death.registrationNumber - - name: deceased - type: boolean - required: true - evidence_offerings: - - id: cra-birth-registration-offering - iri: https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration - title: - en: CRA birth registration evidence offering - evidence_type: birth-registration-evidence - issuing_authority: - id: cra - iri: did:web:id.registrystack.org:solmara:authority:cra - name: Civil Registration Authority - country: XS - entity: civil_person - lookup_keys: - - uin - procedure_contexts: - - child-benefit-review - access: - kind: evidence-verification-api - conforms_to: https://spec.openapis.org/oas/v3.1.0 - endpoint_url: https://cra-notary.solmara.registrystack.org/v1/evaluations - discovery_url: https://cra-notary.solmara.registrystack.org/.well-known/evidence-service - ruleset: birth-is-registered - policy: - purpose: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - - id: cra-death-registration-offering - iri: https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration - title: - en: CRA death registration evidence offering - evidence_type: death-registration-evidence - issuing_authority: - id: cra - iri: did:web:id.registrystack.org:solmara:authority:cra - name: Civil Registration Authority - country: XS - entity: civil_person - lookup_keys: - - uin - procedure_contexts: - - pension-survivor-review - access: - kind: evidence-verification-api - conforms_to: https://spec.openapis.org/oas/v3.1.0 - endpoint_url: https://cra-notary.solmara.registrystack.org/v1/evaluations - discovery_url: https://cra-notary.solmara.registrystack.org/.well-known/evidence-service - ruleset: person-is-deceased - policy: - purpose: - - https://id.registrystack.org/solmara/purpose/pension-payment-review + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: nia-population - title: - en: Population register + title: {en: NIA population publication} + description: {en: NIA-owned immutable population extract and governed eSignet Relay source.} access_rights: restricted entities: - - name: person + - name: population_person + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: sro-social - title: - en: Social registry + title: {en: SRO poverty extract} + description: {en: SRO-owned immutable poverty publication.} access_rights: restricted entities: - - name: household + - name: poverty_record + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: mosd-programme - title: - en: Integrated beneficiary registry + title: {en: MoSD programme enrolment publication} + description: {en: MoSD-owned mutable SQLite source consulted through Relay V2.} access_rights: restricted entities: - - name: enrollment + - name: beneficiary_enrolment + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: sipf-pensions - title: - en: Pensions and social insurance + title: {en: SIPF pension and survivor publication} + description: {en: SIPF-owned mutable SQLite source consulted through Relay V2.} access_rights: restricted entities: - - name: pension_case + - name: pension_payment + identifiers: [{name: pensioner_uin, kind: uin}] + fields: [{name: pensioner_uin, type: string, required: true}] + - name: survivor_case + identifiers: [{name: spouse_uin, kind: uin}] + fields: [{name: spouse_uin, type: string, required: true}] - id: nagdi-agriculture - title: - en: Farmer and livestock registries + title: {en: NAgDI agriculture publication} + description: {en: NAgDI-owned mutable SQLite source consulted through Relay V2.} access_rights: restricted entities: - name: farmer_voucher + identifiers: [{name: farmer_id, kind: local}] + fields: [{name: farmer_id, type: string, required: true}] - name: livestock_movement -evaluation_profiles: - - id: birth-is-registered - ruleset: birth-is-registered-v1 - claim_id: birth-is-registered - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: population-record-active - ruleset: population-record-active-v1 - claim_id: population-record-active - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: child-age-under-5 - ruleset: child-age-under-5-v1 - claim_id: child-age-under-5 - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: household-below-poverty-threshold - ruleset: household-below-poverty-threshold-v1 - claim_id: household-below-poverty-threshold - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: not-already-enrolled - ruleset: not-already-enrolled-v1 - claim_id: not-already-enrolled - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: survivor-benefit-eligibility - ruleset: survivor-benefit-eligibility-v1 - claim_id: survivor-is-eligible - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: climate-smart-voucher-eligibility - ruleset: climate-smart-voucher-eligibility-v1 - claim_id: eligible-for-climate-smart-input-voucher - subject_id_type: farmer_id - max_source_observed_age_seconds: 86400 - - id: livestock-movement-permit - ruleset: livestock-movement-permit-v1 - claim_id: eligible-for-livestock-movement-permit - subject_id_type: farmer_id - max_source_observed_age_seconds: 86400 + identifiers: [{name: farmer_id, kind: local}] + fields: [{name: farmer_id, type: string, required: true}] diff --git a/metadata/public/metadata/ogc-records/items.json b/metadata/public/metadata/ogc-records/items.json index b7a46e9..9362d37 100644 --- a/metadata/public/metadata/ogc-records/items.json +++ b/metadata/public/metadata/ogc-records/items.json @@ -5,7 +5,7 @@ "id": "cra-civil", "properties": { "authority": "Civil Registration Authority", - "title": "Civil registration" + "title": "CRA civil registration publication" }, "type": "Feature" }, @@ -14,7 +14,7 @@ "id": "nia-population", "properties": { "authority": "National Identity Agency", - "title": "Population register" + "title": "NIA population publication" }, "type": "Feature" }, @@ -23,7 +23,7 @@ "id": "sro-social", "properties": { "authority": "Social Registry Office", - "title": "Social registry" + "title": "SRO poverty extract" }, "type": "Feature" }, @@ -32,7 +32,7 @@ "id": "mosd-programme", "properties": { "authority": "Ministry of Social Development Programme MIS", - "title": "Integrated beneficiary registry" + "title": "MoSD programme enrolment publication" }, "type": "Feature" }, @@ -41,7 +41,7 @@ "id": "sipf-pensions", "properties": { "authority": "Social Insurance and Pensions Fund", - "title": "Pensions and social insurance" + "title": "SIPF pension and survivor publication" }, "type": "Feature" }, @@ -50,7 +50,7 @@ "id": "nagdi-agriculture", "properties": { "authority": "National Agricultural Data Institute", - "title": "Farmer and livestock registries" + "title": "NAgDI agriculture publication" }, "type": "Feature" } diff --git a/metadata/public/metadata/policies.jsonld b/metadata/public/metadata/policies.jsonld index b355a54..e2d3bca 100644 --- a/metadata/public/metadata/policies.jsonld +++ b/metadata/public/metadata/policies.jsonld @@ -4,7 +4,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "cra-birth-registration-offering-policy", + "id": "cra-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -20,15 +20,15 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration", + "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/cra-birth-registration-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/cra-child-benefit-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "cra-death-registration-offering-policy", + "id": "cra-pension-death-v1-offering-policy", "permission": [ { "action": "use", @@ -44,15 +44,39 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration", + "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-pension-death-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/cra-death-registration-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/cra-pension-death-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "nia-population-population-status-offering-policy", + "id": "cra-citizen-record-v1-offering-policy", + "permission": [ + { + "action": "use", + "constraint": [ + { + "leftOperand": "purpose", + "operator": "isAnyOf", + "rightOperand": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ] + } + ] + } + ], + "profile": "https://www.w3.org/TR/odrl-model/", + "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-citizen-record-v1-offering", + "type": "odrl:Set", + "uid": "https://id.registrystack.org/solmara/policies/cra-citizen-record-v1-offering-policy" + }, + { + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "id": "nia-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -68,15 +92,39 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/nia-population-population-status-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nia-child-benefit-v1-offering", + "type": "odrl:Set", + "uid": "https://id.registrystack.org/solmara/policies/nia-child-benefit-v1-offering-policy" + }, + { + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "id": "nia-citizen-status-v1-offering-policy", + "permission": [ + { + "action": "use", + "constraint": [ + { + "leftOperand": "purpose", + "operator": "isAnyOf", + "rightOperand": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ] + } + ] + } + ], + "profile": "https://www.w3.org/TR/odrl-model/", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nia-citizen-status-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/nia-population-population-status-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/nia-citizen-status-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "sro-social-household-poverty-offering-policy", + "id": "sro-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -92,15 +140,15 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/sro-social-household-poverty-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/sro-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/sro-social-household-poverty-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/sro-child-benefit-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "mosd-programme-beneficiary-enrollment-offering-policy", + "id": "mosd-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -116,15 +164,15 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/mosd-programme-beneficiary-enrollment-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/mosd-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/mosd-programme-beneficiary-enrollment-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/mosd-child-benefit-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "sipf-pensions-pension-case-offering-policy", + "id": "sipf-pension-payment-v1-offering-policy", "permission": [ { "action": "use", @@ -133,23 +181,22 @@ "leftOperand": "purpose", "operator": "isAnyOf", "rightOperand": [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + "https://id.registrystack.org/solmara/purpose/pension-payment-review" ] } ] } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pensions-pension-case-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pension-payment-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/sipf-pensions-pension-case-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/sipf-pension-payment-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "nagdi-agriculture-farmer-voucher-offering-policy", + "id": "sipf-survivor-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -158,22 +205,22 @@ "leftOperand": "purpose", "operator": "isAnyOf", "rightOperand": [ - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" ] } ] } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-farmer-voucher-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/sipf-survivor-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/nagdi-agriculture-farmer-voucher-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/sipf-survivor-benefit-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "nagdi-agriculture-livestock-movement-offering-policy", + "id": "nagdi-voucher-v1-offering-policy", "permission": [ { "action": "use", @@ -182,22 +229,22 @@ "leftOperand": "purpose", "operator": "isAnyOf", "rightOperand": [ - "https://id.registrystack.org/solmara/purpose/livestock-movement-control" + "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" ] } ] } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-livestock-movement-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-voucher-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/nagdi-agriculture-livestock-movement-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/nagdi-voucher-v1-offering-policy" }, { "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "solmara-child-benefit-authority-predicate-collection-policy", + "id": "nagdi-livestock-v1-offering-policy", "permission": [ { "action": "use", @@ -206,16 +253,16 @@ "leftOperand": "purpose", "operator": "isAnyOf", "rightOperand": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" + "https://id.registrystack.org/solmara/purpose/livestock-movement-control" ] } ] } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-livestock-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/solmara-child-benefit-authority-predicate-collection-policy" + "uid": "https://id.registrystack.org/solmara/policies/nagdi-livestock-v1-offering-policy" } ] } diff --git a/metadata/public/metadata/policies/cra-birth-registration-offering-policy.jsonld b/metadata/public/metadata/policies/cra-child-benefit-v1-offering-policy.jsonld similarity index 73% rename from metadata/public/metadata/policies/cra-birth-registration-offering-policy.jsonld rename to metadata/public/metadata/policies/cra-child-benefit-v1-offering-policy.jsonld index e7f5d23..dc4a9d6 100644 --- a/metadata/public/metadata/policies/cra-birth-registration-offering-policy.jsonld +++ b/metadata/public/metadata/policies/cra-child-benefit-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "cra-birth-registration-offering-policy", + "id": "cra-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration", + "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/cra-birth-registration-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/cra-child-benefit-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/cra-citizen-record-v1-offering-policy.jsonld b/metadata/public/metadata/policies/cra-citizen-record-v1-offering-policy.jsonld new file mode 100644 index 0000000..3ce686b --- /dev/null +++ b/metadata/public/metadata/policies/cra-citizen-record-v1-offering-policy.jsonld @@ -0,0 +1,24 @@ +{ + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "id": "cra-citizen-record-v1-offering-policy", + "permission": [ + { + "action": "use", + "constraint": [ + { + "leftOperand": "purpose", + "operator": "isAnyOf", + "rightOperand": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ] + } + ] + } + ], + "profile": "https://www.w3.org/TR/odrl-model/", + "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-citizen-record-v1-offering", + "type": "odrl:Set", + "uid": "https://id.registrystack.org/solmara/policies/cra-citizen-record-v1-offering-policy" +} diff --git a/metadata/public/metadata/policies/cra-death-registration-offering-policy.jsonld b/metadata/public/metadata/policies/cra-pension-death-v1-offering-policy.jsonld similarity index 73% rename from metadata/public/metadata/policies/cra-death-registration-offering-policy.jsonld rename to metadata/public/metadata/policies/cra-pension-death-v1-offering-policy.jsonld index 71eea68..413be1b 100644 --- a/metadata/public/metadata/policies/cra-death-registration-offering-policy.jsonld +++ b/metadata/public/metadata/policies/cra-pension-death-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "cra-death-registration-offering-policy", + "id": "cra-pension-death-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration", + "target": "https://id.registrystack.org/solmara/evidence-offerings/cra-pension-death-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/cra-death-registration-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/cra-pension-death-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/sro-social-household-poverty-offering-policy.jsonld b/metadata/public/metadata/policies/mosd-child-benefit-v1-offering-policy.jsonld similarity index 71% rename from metadata/public/metadata/policies/sro-social-household-poverty-offering-policy.jsonld rename to metadata/public/metadata/policies/mosd-child-benefit-v1-offering-policy.jsonld index b0abf5c..7a96382 100644 --- a/metadata/public/metadata/policies/sro-social-household-poverty-offering-policy.jsonld +++ b/metadata/public/metadata/policies/mosd-child-benefit-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "sro-social-household-poverty-offering-policy", + "id": "mosd-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/sro-social-household-poverty-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/mosd-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/sro-social-household-poverty-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/mosd-child-benefit-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/nagdi-agriculture-livestock-movement-offering-policy.jsonld b/metadata/public/metadata/policies/nagdi-livestock-v1-offering-policy.jsonld similarity index 69% rename from metadata/public/metadata/policies/nagdi-agriculture-livestock-movement-offering-policy.jsonld rename to metadata/public/metadata/policies/nagdi-livestock-v1-offering-policy.jsonld index 809e3ba..0ecf1a5 100644 --- a/metadata/public/metadata/policies/nagdi-agriculture-livestock-movement-offering-policy.jsonld +++ b/metadata/public/metadata/policies/nagdi-livestock-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "nagdi-agriculture-livestock-movement-offering-policy", + "id": "nagdi-livestock-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-livestock-movement-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-livestock-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/nagdi-agriculture-livestock-movement-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/nagdi-livestock-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/nagdi-agriculture-farmer-voucher-offering-policy.jsonld b/metadata/public/metadata/policies/nagdi-voucher-v1-offering-policy.jsonld similarity index 70% rename from metadata/public/metadata/policies/nagdi-agriculture-farmer-voucher-offering-policy.jsonld rename to metadata/public/metadata/policies/nagdi-voucher-v1-offering-policy.jsonld index 96f9226..5596c55 100644 --- a/metadata/public/metadata/policies/nagdi-agriculture-farmer-voucher-offering-policy.jsonld +++ b/metadata/public/metadata/policies/nagdi-voucher-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "nagdi-agriculture-farmer-voucher-offering-policy", + "id": "nagdi-voucher-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-agriculture-farmer-voucher-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nagdi-voucher-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/nagdi-agriculture-farmer-voucher-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/nagdi-voucher-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/nia-population-population-status-offering-policy.jsonld b/metadata/public/metadata/policies/nia-child-benefit-v1-offering-policy.jsonld similarity index 69% rename from metadata/public/metadata/policies/nia-population-population-status-offering-policy.jsonld rename to metadata/public/metadata/policies/nia-child-benefit-v1-offering-policy.jsonld index 8190c7f..f694e6a 100644 --- a/metadata/public/metadata/policies/nia-population-population-status-offering-policy.jsonld +++ b/metadata/public/metadata/policies/nia-child-benefit-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "nia-population-population-status-offering-policy", + "id": "nia-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/nia-population-population-status-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nia-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/nia-population-population-status-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/nia-child-benefit-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/nia-citizen-status-v1-offering-policy.jsonld b/metadata/public/metadata/policies/nia-citizen-status-v1-offering-policy.jsonld new file mode 100644 index 0000000..df1dbd7 --- /dev/null +++ b/metadata/public/metadata/policies/nia-citizen-status-v1-offering-policy.jsonld @@ -0,0 +1,24 @@ +{ + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "id": "nia-citizen-status-v1-offering-policy", + "permission": [ + { + "action": "use", + "constraint": [ + { + "leftOperand": "purpose", + "operator": "isAnyOf", + "rightOperand": [ + "https://id.registrystack.org/solmara/purpose/citizen-self-service" + ] + } + ] + } + ], + "profile": "https://www.w3.org/TR/odrl-model/", + "target": "https://id.registrystack.org/solmara/evidence-offerings/nia-citizen-status-v1-offering", + "type": "odrl:Set", + "uid": "https://id.registrystack.org/solmara/policies/nia-citizen-status-v1-offering-policy" +} diff --git a/metadata/public/metadata/policies/sipf-pensions-pension-case-offering-policy.jsonld b/metadata/public/metadata/policies/sipf-pension-payment-v1-offering-policy.jsonld similarity index 69% rename from metadata/public/metadata/policies/sipf-pensions-pension-case-offering-policy.jsonld rename to metadata/public/metadata/policies/sipf-pension-payment-v1-offering-policy.jsonld index ac79939..0161f51 100644 --- a/metadata/public/metadata/policies/sipf-pensions-pension-case-offering-policy.jsonld +++ b/metadata/public/metadata/policies/sipf-pension-payment-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "sipf-pensions-pension-case-offering-policy", + "id": "sipf-pension-payment-v1-offering-policy", "permission": [ { "action": "use", @@ -11,15 +11,14 @@ "leftOperand": "purpose", "operator": "isAnyOf", "rightOperand": [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + "https://id.registrystack.org/solmara/purpose/pension-payment-review" ] } ] } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pensions-pension-case-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/sipf-pension-payment-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/sipf-pensions-pension-case-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/sipf-pension-payment-v1-offering-policy" } diff --git a/metadata/public/metadata/policies/sipf-survivor-benefit-v1-offering-policy.jsonld b/metadata/public/metadata/policies/sipf-survivor-benefit-v1-offering-policy.jsonld new file mode 100644 index 0000000..d4b75b1 --- /dev/null +++ b/metadata/public/metadata/policies/sipf-survivor-benefit-v1-offering-policy.jsonld @@ -0,0 +1,24 @@ +{ + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "id": "sipf-survivor-benefit-v1-offering-policy", + "permission": [ + { + "action": "use", + "constraint": [ + { + "leftOperand": "purpose", + "operator": "isAnyOf", + "rightOperand": [ + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" + ] + } + ] + } + ], + "profile": "https://www.w3.org/TR/odrl-model/", + "target": "https://id.registrystack.org/solmara/evidence-offerings/sipf-survivor-benefit-v1-offering", + "type": "odrl:Set", + "uid": "https://id.registrystack.org/solmara/policies/sipf-survivor-benefit-v1-offering-policy" +} diff --git a/metadata/public/metadata/policies/solmara-child-benefit-authority-predicate-collection-policy.jsonld b/metadata/public/metadata/policies/solmara-child-benefit-authority-predicate-collection-policy.jsonld deleted file mode 100644 index faaf612..0000000 --- a/metadata/public/metadata/policies/solmara-child-benefit-authority-predicate-collection-policy.jsonld +++ /dev/null @@ -1,24 +0,0 @@ -{ - "@context": { - "odrl": "http://www.w3.org/ns/odrl/2/" - }, - "id": "solmara-child-benefit-authority-predicate-collection-policy", - "permission": [ - { - "action": "use", - "constraint": [ - { - "leftOperand": "purpose", - "operator": "isAnyOf", - "rightOperand": [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ] - } - ] - } - ], - "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", - "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/solmara-child-benefit-authority-predicate-collection-policy" -} diff --git a/metadata/public/metadata/policies/mosd-programme-beneficiary-enrollment-offering-policy.jsonld b/metadata/public/metadata/policies/sro-child-benefit-v1-offering-policy.jsonld similarity index 68% rename from metadata/public/metadata/policies/mosd-programme-beneficiary-enrollment-offering-policy.jsonld rename to metadata/public/metadata/policies/sro-child-benefit-v1-offering-policy.jsonld index 20e9063..e73b8b9 100644 --- a/metadata/public/metadata/policies/mosd-programme-beneficiary-enrollment-offering-policy.jsonld +++ b/metadata/public/metadata/policies/sro-child-benefit-v1-offering-policy.jsonld @@ -2,7 +2,7 @@ "@context": { "odrl": "http://www.w3.org/ns/odrl/2/" }, - "id": "mosd-programme-beneficiary-enrollment-offering-policy", + "id": "sro-child-benefit-v1-offering-policy", "permission": [ { "action": "use", @@ -18,7 +18,7 @@ } ], "profile": "https://www.w3.org/TR/odrl-model/", - "target": "https://id.registrystack.org/solmara/evidence-offerings/mosd-programme-beneficiary-enrollment-offering", + "target": "https://id.registrystack.org/solmara/evidence-offerings/sro-child-benefit-v1-offering", "type": "odrl:Set", - "uid": "https://id.registrystack.org/solmara/policies/mosd-programme-beneficiary-enrollment-offering-policy" + "uid": "https://id.registrystack.org/solmara/policies/sro-child-benefit-v1-offering-policy" } diff --git a/metadata/solmara-wave1.metadata.yaml b/metadata/solmara-wave1.metadata.yaml index e613e67..219ad77 100644 --- a/metadata/solmara-wave1.metadata.yaml +++ b/metadata/solmara-wave1.metadata.yaml @@ -1,11 +1,11 @@ schema_version: registry-manifest/v1 catalog: - id: solmara-wave1-multi-authority + id: solmara-authority-cells base_url: https://metadata.solmara.registrystack.org title: - en: Solmara Lab Wave 1 Service Catalogue + en: Solmara authority-owned Evidence catalogue description: - en: One merged CPSV-AP metadata publish for Solmara Wave 1 registry and evidence services. + en: Six authority-owned Evidence cells, five governed Relay V2 services, and the programme applications that compose signed assertions. publisher: name: Solmara Interoperability Metadata Publisher iri: did:web:id.registrystack.org:solmara:authority:metadata @@ -50,385 +50,280 @@ authorities: authority_type: eli:PublicAuthority spatial: https://id.registrystack.org/solmara/jurisdiction/XS requirements: - - id: child-benefit-review-requirement - iri: https://id.registrystack.org/solmara/requirements/child-benefit-review - title: - en: Child benefit eligibility review - description: - en: Confirm registered birth, under-five age, household poverty status, and duplicate enrollment status. + - id: cra-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/cra-child-benefit/v1 + title: {en: CRA child benefit evidence} + description: {en: Confirm registered birth and age under five from CRA's immutable birth extract.} evidence_type_lists: - - id: child-benefit-wave1-evidence - title: - en: Child benefit registry evidence - evidence_types: - - birth-registration-evidence - - population-status-evidence - - household-poverty-evidence - - beneficiary-enrollment-evidence - - id: pension-survivor-review-requirement - iri: https://id.registrystack.org/solmara/requirements/pension-survivor-review - title: - en: Pension stop and survivor benefit review - description: - en: Confirm death registration, active pension payment state, and survivor eligibility. + - id: cra-child-benefit-list + title: {en: CRA child benefit evidence} + evidence_types: [cra-child-benefit-v1] + - id: cra-pension-death-v1 + iri: https://id.registrystack.org/solmara/requirement/cra-pension-death/v1 + title: {en: CRA pension death status} + description: {en: Confirm death registration through CRA's governed Relay lookup.} evidence_type_lists: - - id: pension-wave1-evidence - title: - en: Pension and survivor registry evidence - evidence_types: - - death-registration-evidence - - pension-case-evidence - - id: nagdi-voucher-requirement - iri: https://id.registrystack.org/solmara/requirements/nagdi-voucher - title: - en: Farmer climate-smart voucher review - description: - en: Confirm farmer registration, data-use authorization, parcel, crop, climate-risk, and redemption predicates. + - id: cra-pension-death-list + title: {en: CRA death-status evidence} + evidence_types: [cra-death-status-v1] + - id: cra-citizen-record-v1 + iri: https://id.registrystack.org/solmara/requirement/cra-citizen-record/v1 + title: {en: CRA citizen record link} + description: {en: Confirm a linked civil record through CRA's governed Relay lookup.} evidence_type_lists: - - id: nagdi-voucher-evidence - title: - en: Farmer voucher evidence - evidence_types: - - farmer-voucher-evidence - - id: livestock-movement-requirement - iri: https://id.registrystack.org/solmara/requirements/livestock-movement - title: - en: Livestock movement control review - description: - en: Confirm herd registration, vaccination, species-aware quarantine, destination, and open permit predicates. + - id: cra-citizen-record-list + title: {en: CRA civil-record evidence} + evidence_types: [civil-record-linked-v1] + - id: nia-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/nia-child-benefit/v1 + title: {en: NIA child benefit population status} + description: {en: Confirm active population status from NIA's immutable population extract.} evidence_type_lists: - - id: livestock-movement-evidence-list - title: - en: Livestock movement evidence - evidence_types: - - livestock-movement-evidence + - id: nia-child-benefit-list + title: {en: NIA population evidence} + evidence_types: [population-active-v1] + - id: nia-citizen-status-v1 + iri: https://id.registrystack.org/solmara/requirement/nia-citizen-status/v1 + title: {en: NIA citizen population status} + description: {en: Confirm active citizen population status from NIA's immutable population extract.} + evidence_type_lists: + - id: nia-citizen-status-list + title: {en: NIA citizen-status evidence} + evidence_types: [citizen-population-active-v1] + - id: sro-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/sro-child-benefit/v1 + title: {en: SRO poverty priority} + description: {en: Confirm household poverty priority from SRO's immutable poverty extract.} + evidence_type_lists: + - id: sro-child-benefit-list + title: {en: SRO poverty evidence} + evidence_types: [poverty-priority-v1] + - id: mosd-child-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/mosd-child-benefit/v1 + title: {en: MoSD programme enrolment status} + description: {en: Confirm that the person is not already enrolled through MoSD's governed Relay lookup.} + evidence_type_lists: + - id: mosd-child-benefit-list + title: {en: MoSD enrolment evidence} + evidence_types: [not-enrolled-v1] + - id: sipf-pension-payment-v1 + iri: https://id.registrystack.org/solmara/requirement/sipf-pension-payment/v1 + title: {en: SIPF pension payment status} + description: {en: Confirm active pension payment through SIPF's governed Relay lookup.} + evidence_type_lists: + - id: sipf-pension-payment-list + title: {en: SIPF pension payment evidence} + evidence_types: [pension-payment-active-v1] + - id: sipf-survivor-benefit-v1 + iri: https://id.registrystack.org/solmara/requirement/sipf-survivor-benefit/v1 + title: {en: SIPF survivor eligibility} + description: {en: Confirm survivor eligibility through SIPF's governed Relay lookup.} + evidence_type_lists: + - id: sipf-survivor-benefit-list + title: {en: SIPF survivor evidence} + evidence_types: [survivor-benefit-v1] + - id: nagdi-voucher-v1 + iri: https://id.registrystack.org/solmara/requirement/nagdi-voucher/v1 + title: {en: NAgDI climate-smart voucher} + description: {en: Confirm climate-smart voucher eligibility through NAgDI's governed Relay lookup.} + evidence_type_lists: + - id: nagdi-voucher-list + title: {en: NAgDI voucher evidence} + evidence_types: [climate-smart-voucher-v1] + - id: nagdi-livestock-v1 + iri: https://id.registrystack.org/solmara/requirement/nagdi-livestock/v1 + title: {en: NAgDI livestock movement} + description: {en: Confirm livestock movement eligibility through NAgDI's governed Relay lookup.} + evidence_type_lists: + - id: nagdi-livestock-list + title: {en: NAgDI livestock evidence} + evidence_types: [livestock-movement-v1] evidence_types: - - id: birth-registration-evidence - iri: https://id.registrystack.org/solmara/evidence-types/birth-registration - title: - en: Birth registration evidence - proves: - - child-benefit-review-requirement - information_concepts: - - ps:crvs/Birth - - id: population-status-evidence - iri: https://id.registrystack.org/solmara/evidence-types/population-status - title: - en: Population status evidence - proves: - - child-benefit-review-requirement - information_concepts: - - ps:Person - - id: household-poverty-evidence - iri: https://id.registrystack.org/solmara/evidence-types/household-poverty - title: - en: Household poverty evidence - proves: - - child-benefit-review-requirement + - id: cra-child-benefit-v1 + iri: https://id.registrystack.org/solmara/evidence-type/cra-child-benefit/v1 + title: {en: CRA child benefit assertion} + proves: [cra-child-benefit-v1] information_concepts: - - ps:Household - - ps:SocioEconomicProfile - - id: beneficiary-enrollment-evidence - iri: https://id.registrystack.org/solmara/evidence-types/beneficiary-enrollment - title: - en: Beneficiary enrollment evidence - proves: - - child-benefit-review-requirement + - https://id.registrystack.org/solmara/concept/birth-is-registered + - https://id.registrystack.org/solmara/concept/child-age-under-5 + - id: cra-death-status-v1 + iri: https://id.registrystack.org/solmara/evidence-type/cra-death-status/v1 + title: {en: CRA death-status assertion} + proves: [cra-pension-death-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/person-is-deceased] + - id: civil-record-linked-v1 + iri: https://id.registrystack.org/solmara/evidence-type/civil-record-linked/v1 + title: {en: CRA civil-record assertion} + proves: [cra-citizen-record-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/civil-record-linked] + - id: population-active-v1 + iri: https://id.registrystack.org/solmara/evidence-type/population-active/v1 + title: {en: NIA active-population assertion} + proves: [nia-child-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/population-record-active] + - id: citizen-population-active-v1 + iri: https://id.registrystack.org/solmara/evidence-type/citizen-population-active/v1 + title: {en: NIA active-citizen assertion} + proves: [nia-citizen-status-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/citizen-population-record-active] + - id: poverty-priority-v1 + iri: https://id.registrystack.org/solmara/evidence-type/poverty-priority/v1 + title: {en: SRO poverty-priority assertion} + proves: [sro-child-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/household-below-poverty-threshold] + - id: not-enrolled-v1 + iri: https://id.registrystack.org/solmara/evidence-type/not-enrolled/v1 + title: {en: MoSD not-enrolled assertion} + proves: [mosd-child-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/not-already-enrolled] + - id: pension-payment-active-v1 + iri: https://id.registrystack.org/solmara/evidence-type/pension-payment-active/v1 + title: {en: SIPF active-payment assertion} + proves: [sipf-pension-payment-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/pension-payment-active] + - id: survivor-benefit-v1 + iri: https://id.registrystack.org/solmara/evidence-type/survivor-benefit/v1 + title: {en: SIPF survivor-benefit assertion} + proves: [sipf-survivor-benefit-v1] + information_concepts: [https://id.registrystack.org/solmara/concept/survivor-is-eligible] + - id: climate-smart-voucher-v1 + iri: https://id.registrystack.org/solmara/evidence-type/climate-smart-voucher/v1 + title: {en: NAgDI climate-smart voucher assertion} + proves: [nagdi-voucher-v1] information_concepts: - - ps:sp/Enrollment - - id: child-benefit-authority-predicate-collection-evidence - iri: https://id.registrystack.org/solmara/evidence-types/child-benefit-authority-predicate-collection - title: - en: Child benefit authority predicate collection - description: - en: A transient collection of source-owned child benefit predicates with no copied source rows or eligibility decision. - proves: - - child-benefit-review-requirement + - https://id.registrystack.org/solmara/concept/farmer-registered + - https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose + - https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher + - id: livestock-movement-v1 + iri: https://id.registrystack.org/solmara/evidence-type/livestock-movement/v1 + title: {en: NAgDI livestock movement assertion} + proves: [nagdi-livestock-v1] information_concepts: - - ps:Person - - id: death-registration-evidence - iri: https://id.registrystack.org/solmara/evidence-types/death-registration - title: - en: Death registration evidence - proves: - - pension-survivor-review-requirement - information_concepts: - - ps:crvs/Death - - id: pension-case-evidence - iri: https://id.registrystack.org/solmara/evidence-types/pension-case - title: - en: Pension case evidence - proves: - - pension-survivor-review-requirement - - id: farmer-voucher-evidence - iri: https://id.registrystack.org/solmara/evidence-types/farmer-voucher - title: - en: Farmer voucher evidence - proves: - - nagdi-voucher-requirement - information_concepts: - - ps:Farm - - id: livestock-movement-evidence - iri: https://id.registrystack.org/solmara/evidence-types/livestock-movement - title: - en: Livestock movement evidence - proves: - - livestock-movement-requirement + - https://id.registrystack.org/solmara/concept/registered-herd + - https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species + - https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit public_services: - id: child-benefit-review iri: https://id.registrystack.org/solmara/services/child-benefit-review - title: - en: Child benefit eligibility review - description: - en: MoSD review of child benefit eligibility using minimized registry evidence. + title: {en: Child benefit eligibility review} + description: {en: The programme composes four independently signed authority assertions into five reviewed concepts.} competent_authority: mosd-programme-mis jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - child-benefit-review-requirement - data_services: - - child-benefit-evidence-collector-api - - cra-notary-api - - nia-notary-api - - sro-notary-api - - programme-notary-api + holds_requirements: [cra-child-benefit-v1, nia-child-benefit-v1, sro-child-benefit-v1, mosd-child-benefit-v1] + data_services: [cra-evidence-api, nia-evidence-api, sro-evidence-api, mosd-programme-evidence-api] - id: pension-survivor-review iri: https://id.registrystack.org/solmara/services/pension-survivor-review - title: - en: Pension stop and survivor benefit review - description: - en: SIPF review for pension payment stop and survivor benefit eligibility. + title: {en: Pension stop and survivor benefit review} + description: {en: The programme composes CRA and SIPF signed assertions without exposing unrelated civil data.} competent_authority: sipf jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - pension-survivor-review-requirement - data_services: - - cra-notary-api - - sipf-notary-api - - id: nagdi-voucher-review - iri: https://id.registrystack.org/solmara/services/nagdi-voucher-review - title: - en: Farmer climate-smart voucher review - description: - en: NAgDI review of farmer eligibility for climate-smart input support. - competent_authority: nagdi + holds_requirements: [cra-pension-death-v1, sipf-pension-payment-v1, sipf-survivor-benefit-v1] + data_services: [cra-evidence-api, sipf-evidence-api] + - id: citizen-self-service + iri: https://id.registrystack.org/solmara/services/citizen-self-service + title: {en: Citizen self-service} + description: {en: The application composes separately signed CRA and NIA citizen assertions.} + competent_authority: nia jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - nagdi-voucher-requirement - data_services: - - nagdi-notary-api - - id: livestock-movement-control - iri: https://id.registrystack.org/solmara/services/livestock-movement-control - title: - en: Livestock movement control - description: - en: NAgDI review of livestock movement permit eligibility and quarantine controls. + holds_requirements: [cra-citizen-record-v1, nia-citizen-status-v1] + data_services: [cra-evidence-api, nia-evidence-api] + - id: agriculture-review + iri: https://id.registrystack.org/solmara/services/agriculture-review + title: {en: Agriculture voucher and livestock review} + description: {en: NAgDI applications consume independently signed voucher and livestock assertions.} competent_authority: nagdi jurisdiction: https://id.registrystack.org/solmara/jurisdiction/XS - holds_requirements: - - livestock-movement-requirement - data_services: - - nagdi-notary-api + holds_requirements: [nagdi-voucher-v1, nagdi-livestock-v1] + data_services: [nagdi-evidence-api] data_services: - - id: child-benefit-evidence-collector-api - iri: https://child-benefit-federator.solmara.registrystack.org/v1/evaluations - title: - en: Child benefit authority predicate collection API - description: - en: Returns application/json collections assembled from source-Notary predicates without composing an eligibility decision. - endpoint_url: https://child-benefit-federator.solmara.registrystack.org/v1/evaluations - endpoint_description: https://child-benefit-federator.solmara.registrystack.org/v1/claims - conforms_to: https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1 - - id: cra-notary-api - iri: https://cra-notary.solmara.registrystack.org/v1/evaluations - title: - en: CRA evidence API - endpoint_url: https://cra-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://cra-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: nia-notary-api - iri: https://nia-notary.solmara.registrystack.org/v1/evaluations - title: - en: NIA evidence API - endpoint_url: https://nia-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://nia-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: sro-notary-api - iri: https://sro-notary.solmara.registrystack.org/v1/evaluations - title: - en: SRO evidence API - endpoint_url: https://sro-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://sro-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: programme-notary-api - iri: https://programme-notary.solmara.registrystack.org/v1/evaluations - title: - en: Programme MIS evidence API - endpoint_url: https://programme-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://programme-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: sipf-notary-api - iri: https://sipf-notary.solmara.registrystack.org/v1/evaluations - title: - en: SIPF evidence API - endpoint_url: https://sipf-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://sipf-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 - - id: nagdi-notary-api - iri: https://nagdi-notary.solmara.registrystack.org/v1/evaluations - title: - en: NAgDI evidence API - endpoint_url: https://nagdi-notary.solmara.registrystack.org/v1/evaluations - endpoint_description: https://nagdi-notary.solmara.registrystack.org/openapi.json - conforms_to: https://id.registrystack.org/spec/registry-notary/evidence/v1 + - id: cra-evidence-api + iri: https://cra-evidence.solmara.registrystack.org/v1/evidence + title: {en: CRA Evidence cell} + endpoint_url: https://cra-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://cra-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: nia-evidence-api + iri: https://nia-evidence.solmara.registrystack.org/v1/evidence + title: {en: NIA Evidence cell} + endpoint_url: https://nia-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://nia-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: sro-evidence-api + iri: https://sro-evidence.solmara.registrystack.org/v1/evidence + title: {en: SRO Evidence cell} + endpoint_url: https://sro-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://sro-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: mosd-programme-evidence-api + iri: https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence + title: {en: MoSD Programme Evidence cell} + endpoint_url: https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://mosd-programme-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: sipf-evidence-api + iri: https://sipf-evidence.solmara.registrystack.org/v1/evidence + title: {en: SIPF Evidence cell} + endpoint_url: https://sipf-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://sipf-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 + - id: nagdi-evidence-api + iri: https://nagdi-evidence.solmara.registrystack.org/v1/evidence + title: {en: NAgDI Evidence cell} + endpoint_url: https://nagdi-evidence.solmara.registrystack.org/v1/evidence + endpoint_description: https://nagdi-evidence.solmara.registrystack.org/v1/evidence-definitions + conforms_to: https://id.registrystack.org/spec/registry-evidence/v1 datasets: - id: cra-civil - title: - en: Civil registration + title: {en: CRA civil registration publication} + description: {en: CRA-owned immutable birth extract and governed civil-person Relay source.} access_rights: restricted entities: - name: civil_person - title: - en: Civil person - identifiers: - - name: uin - kind: uin - fields: - - name: uin - type: string - required: false - concepts: - - ps:Person.identifier - - name: birth_brn - type: string - required: false - concepts: - - ps:crvs/Birth.registrationNumber - - name: death_drn - type: string - required: false - concepts: - - ps:crvs/Death.registrationNumber - - name: deceased - type: boolean - required: true - evidence_offerings: - - id: cra-birth-registration-offering - iri: https://id.registrystack.org/solmara/evidence-offerings/cra-birth-registration - title: - en: CRA birth registration evidence offering - evidence_type: birth-registration-evidence - issuing_authority: - id: cra - iri: did:web:id.registrystack.org:solmara:authority:cra - name: Civil Registration Authority - country: XS - entity: civil_person - lookup_keys: - - uin - procedure_contexts: - - child-benefit-review - access: - kind: evidence-verification-api - conforms_to: https://spec.openapis.org/oas/v3.1.0 - endpoint_url: https://cra-notary.solmara.registrystack.org/v1/evaluations - discovery_url: https://cra-notary.solmara.registrystack.org/.well-known/evidence-service - ruleset: birth-is-registered - policy: - purpose: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - - id: cra-death-registration-offering - iri: https://id.registrystack.org/solmara/evidence-offerings/cra-death-registration - title: - en: CRA death registration evidence offering - evidence_type: death-registration-evidence - issuing_authority: - id: cra - iri: did:web:id.registrystack.org:solmara:authority:cra - name: Civil Registration Authority - country: XS - entity: civil_person - lookup_keys: - - uin - procedure_contexts: - - pension-survivor-review - access: - kind: evidence-verification-api - conforms_to: https://spec.openapis.org/oas/v3.1.0 - endpoint_url: https://cra-notary.solmara.registrystack.org/v1/evaluations - discovery_url: https://cra-notary.solmara.registrystack.org/.well-known/evidence-service - ruleset: person-is-deceased - policy: - purpose: - - https://id.registrystack.org/solmara/purpose/pension-payment-review + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: nia-population - title: - en: Population register + title: {en: NIA population publication} + description: {en: NIA-owned immutable population extract and governed eSignet Relay source.} access_rights: restricted entities: - - name: person + - name: population_person + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: sro-social - title: - en: Social registry + title: {en: SRO poverty extract} + description: {en: SRO-owned immutable poverty publication.} access_rights: restricted entities: - - name: household + - name: poverty_record + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: mosd-programme - title: - en: Integrated beneficiary registry + title: {en: MoSD programme enrolment publication} + description: {en: MoSD-owned mutable SQLite source consulted through Relay V2.} access_rights: restricted entities: - - name: enrollment + - name: beneficiary_enrolment + identifiers: [{name: uin, kind: uin}] + fields: [{name: uin, type: string, required: true}] - id: sipf-pensions - title: - en: Pensions and social insurance + title: {en: SIPF pension and survivor publication} + description: {en: SIPF-owned mutable SQLite source consulted through Relay V2.} access_rights: restricted entities: - - name: pension_case + - name: pension_payment + identifiers: [{name: pensioner_uin, kind: uin}] + fields: [{name: pensioner_uin, type: string, required: true}] + - name: survivor_case + identifiers: [{name: spouse_uin, kind: uin}] + fields: [{name: spouse_uin, type: string, required: true}] - id: nagdi-agriculture - title: - en: Farmer and livestock registries + title: {en: NAgDI agriculture publication} + description: {en: NAgDI-owned mutable SQLite source consulted through Relay V2.} access_rights: restricted entities: - name: farmer_voucher + identifiers: [{name: farmer_id, kind: local}] + fields: [{name: farmer_id, type: string, required: true}] - name: livestock_movement -evaluation_profiles: - - id: birth-is-registered - ruleset: birth-is-registered-v1 - claim_id: birth-is-registered - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: population-record-active - ruleset: population-record-active-v1 - claim_id: population-record-active - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: child-age-under-5 - ruleset: child-age-under-5-v1 - claim_id: child-age-under-5 - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: household-below-poverty-threshold - ruleset: household-below-poverty-threshold-v1 - claim_id: household-below-poverty-threshold - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: not-already-enrolled - ruleset: not-already-enrolled-v1 - claim_id: not-already-enrolled - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: survivor-benefit-eligibility - ruleset: survivor-benefit-eligibility-v1 - claim_id: survivor-is-eligible - subject_id_type: uin - max_source_observed_age_seconds: 86400 - - id: climate-smart-voucher-eligibility - ruleset: climate-smart-voucher-eligibility-v1 - claim_id: eligible-for-climate-smart-input-voucher - subject_id_type: farmer_id - max_source_observed_age_seconds: 86400 - - id: livestock-movement-permit - ruleset: livestock-movement-permit-v1 - claim_id: eligible-for-livestock-movement-permit - subject_id_type: farmer_id - max_source_observed_age_seconds: 86400 + identifiers: [{name: farmer_id, kind: local}] + fields: [{name: farmer_id, type: string, required: true}] diff --git a/ministries/interior-population/fixtures/001-schema.sql b/ministries/interior-population/fixtures/001-schema.sql index d21f367..0e518d5 100644 --- a/ministries/interior-population/fixtures/001-schema.sql +++ b/ministries/interior-population/fixtures/001-schema.sql @@ -1 +1 @@ -create table population_person (uin text primary key, person_id text, legacy_nid text, given_name text, family_name text, birth_date date, sex text, district_code text, address_area text, settlement_type text, identity_status text, pending_merge_with_uin text, match_basis text, alive boolean, birth_brn text, updated_at timestamptz, observed_at timestamptz, source_system text); +create table if not exists population_person (uin text primary key, person_id text, legacy_nid text, given_name text, family_name text, birth_date date, sex text, district_code text, address_area text, settlement_type text, identity_status text, pending_merge_with_uin text, match_basis text, alive boolean, birth_brn text, updated_at timestamptz, observed_at timestamptz, source_system text); diff --git a/ministries/interior-population/fixtures/002-load.sql b/ministries/interior-population/fixtures/002-load.sql index 3e72b86..384f13a 100644 --- a/ministries/interior-population/fixtures/002-load.sql +++ b/ministries/interior-population/fixtures/002-load.sql @@ -1 +1,25 @@ -copy population_person from '/docker-entrypoint-initdb.d/population_person.csv' with (format csv, header true); +create temporary table population_person_fixture + (like population_person including defaults); + +copy population_person_fixture from '/docker-entrypoint-initdb.d/population_person.csv' with (format csv, header true); + +insert into population_person +select * from population_person_fixture +on conflict (uin) do update set + person_id = excluded.person_id, + legacy_nid = excluded.legacy_nid, + given_name = excluded.given_name, + family_name = excluded.family_name, + birth_date = excluded.birth_date, + sex = excluded.sex, + district_code = excluded.district_code, + address_area = excluded.address_area, + settlement_type = excluded.settlement_type, + identity_status = excluded.identity_status, + pending_merge_with_uin = excluded.pending_merge_with_uin, + match_basis = excluded.match_basis, + alive = excluded.alive, + birth_brn = excluded.birth_brn, + updated_at = excluded.updated_at, + observed_at = excluded.observed_at, + source_system = excluded.source_system; diff --git a/ministries/labour-pensions/fixtures/003-schema.sql b/ministries/labour-pensions/fixtures/003-schema.sql index 9a2f55e..6290be8 100644 --- a/ministries/labour-pensions/fixtures/003-schema.sql +++ b/ministries/labour-pensions/fixtures/003-schema.sql @@ -1,4 +1,4 @@ -create table pension_case ( +create table if not exists pension_case ( pension_case_id text primary key, pensioner_uin text not null, spouse_uin text, @@ -12,15 +12,15 @@ create table pension_case ( source_system text not null ); -create index pension_case_pensioner_uin_idx on pension_case (pensioner_uin); -create index pension_case_spouse_uin_idx on pension_case (spouse_uin); +create index if not exists pension_case_pensioner_uin_idx on pension_case (pensioner_uin); +create index if not exists pension_case_spouse_uin_idx on pension_case (spouse_uin); -create table sipf_pension_payment ( +create table if not exists sipf_pension_payment ( pensioner_uin text primary key, payment_status text not null ); -create table sipf_survivor_benefit ( +create table if not exists sipf_survivor_benefit ( spouse_uin text primary key, survivor_eligible boolean not null ); diff --git a/ministries/labour-pensions/fixtures/004-load.sql b/ministries/labour-pensions/fixtures/004-load.sql index 5c291df..1bc3483 100644 --- a/ministries/labour-pensions/fixtures/004-load.sql +++ b/ministries/labour-pensions/fixtures/004-load.sql @@ -1,7 +1,28 @@ -copy pension_case from '/docker-entrypoint-initdb.d/pension_case.csv' with (format csv, header true); +create temporary table pension_case_fixture + (like pension_case including defaults); + +copy pension_case_fixture from '/docker-entrypoint-initdb.d/pension_case.csv' with (format csv, header true); + +insert into pension_case +select * from pension_case_fixture +on conflict (pension_case_id) do update set + pensioner_uin = excluded.pensioner_uin, + spouse_uin = excluded.spouse_uin, + marriage_mrn = excluded.marriage_mrn, + pension_status = excluded.pension_status, + payment_status = excluded.payment_status, + survivor_eligible = excluded.survivor_eligible, + last_payment_date = excluded.last_payment_date, + account_life_status = excluded.account_life_status, + observed_at = excluded.observed_at, + source_system = excluded.source_system; insert into sipf_pension_payment (pensioner_uin, payment_status) -select pensioner_uin, payment_status from pension_case; +select pensioner_uin, payment_status from pension_case_fixture +on conflict (pensioner_uin) do update set + payment_status = excluded.payment_status; insert into sipf_survivor_benefit (spouse_uin, survivor_eligible) -select spouse_uin, survivor_eligible from pension_case where spouse_uin is not null; +select spouse_uin, survivor_eligible from pension_case_fixture where spouse_uin is not null +on conflict (spouse_uin) do update set + survivor_eligible = excluded.survivor_eligible; diff --git a/ministries/labour-pensions/fixtures/pension_case.csv b/ministries/labour-pensions/fixtures/pension_case.csv index 0390c95..5c669ff 100644 --- a/ministries/labour-pensions/fixtures/pension_case.csv +++ b/ministries/labour-pensions/fixtures/pension_case.csv @@ -2,4 +2,4 @@ pension_case_id,pensioner_uin,spouse_uin,marriage_mrn,pension_status,payment_sta SIPF-AWD-000001,2300109568,2300118698,MRN-1970-0401-00001,in_payment,active,true,2026-07-01,deceased,2026-07-04T09:00:00Z,SIPF-RELAY-PROJECTION SIPF-AWD-000002,2300118698,,,in_payment,active,false,,alive,2026-07-04T09:00:00Z,SIPF-RELAY-PROJECTION SIPF-AWD-000003,2300127827,,,in_payment,active,false,,alive,2026-07-04T09:00:00Z,SIPF-RELAY-PROJECTION -SIPF-AWD-000004,2300136959,,,pending_proof_of_life,active,false,,alive,2026-07-04T09:00:00Z,SIPF-RELAY-PROJECTION +SIPF-AWD-000004,2300136959,2300146081,MRN-2010-0302-00002,pending_proof_of_life,active,false,,alive,2026-07-04T09:00:00Z,SIPF-RELAY-PROJECTION diff --git a/ministries/labour-pensions/fixtures/sipf_survivor_link.csv b/ministries/labour-pensions/fixtures/sipf_survivor_link.csv index da0004e..36591ed 100644 --- a/ministries/labour-pensions/fixtures/sipf_survivor_link.csv +++ b/ministries/labour-pensions/fixtures/sipf_survivor_link.csv @@ -1,2 +1,3 @@ survivor_link_id,deceased_uin,survivor_uin,relationship,proof_mrn,survivor_award_no,link_status,observed_at,source_system SIPF-SL-000001,2300109568,2300118698,spouse,MRN-1970-0401-00001,,verified,2026-07-04T09:00:00Z,SIPF-CORE +SIPF-SL-000002,2300136959,2300146081,former_spouse,MRN-2010-0302-00002,,dissolved,2026-07-04T09:00:00Z,SIPF-CORE diff --git a/perf/README.md b/perf/README.md deleted file mode 100644 index f28261c..0000000 --- a/perf/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Solmara Lab Performance Harness - -This k6 harness exercises four live evaluation paths through the Solmara demo -stack: the Child Benefit Federator and the CRA, SIPF, and NAgDI authority-owned -Notaries. It assumes the lab has generated `.env` secrets and the local Docker -Compose topology is running. - -## Start the Lab - -```bash -just setup -just generate -just up -``` - -Wait for the standard smoke prerequisites to pass before collecting a baseline: - -```bash -just smoke -``` - -## Run with Local k6 - -```bash -set -a -. .env -set +a -mkdir -p output/perf/results output/perf/reports - -k6 run perf/k6/notary_relay_backed.js -``` - -The default profile is `smoke`. It intentionally uses a small VU count and a -short think time to validate routes without creating load. It is not a capacity -measurement. - -The generated `.env` supplies the required bearer tokens: - -- `CHILD_BENEFIT_FEDERATOR_TOKEN` -- `CRA_PENSION_CLIENT_TOKEN` -- `SIPF_PENSION_CLIENT_TOKEN` -- `NAGDI_NOTARY_TOKEN` - -The scenario uses these target URLs and local defaults: - -| Target | Environment variable | Default | -| --- | --- | --- | -| Child Benefit Federator | `CHILD_BENEFIT_FEDERATOR_URL` | `http://127.0.0.1:4321` | -| CRA Notary | `CRA_NOTARY_URL` | `http://127.0.0.1:4325` | -| SIPF Notary | `SIPF_NOTARY_URL` | `http://127.0.0.1:4322` | -| NAgDI Notary | `NAGDI_NOTARY_URL` | `http://127.0.0.1:4323` | - -Run a capacity baseline with an explicit arrival rate: - -```bash -REGISTRY_LAB_PROFILE=capacity \ -REGISTRY_LAB_DURATION=2m \ -REGISTRY_LAB_RATE=200 \ -REGISTRY_LAB_PRE_ALLOCATED_VUS=64 \ -REGISTRY_LAB_MAX_VUS=400 \ -k6 run perf/k6/notary_relay_backed.js -``` - -Run a breakpoint ramp: - -```bash -REGISTRY_LAB_PROFILE=breakpoint \ -REGISTRY_LAB_STAGES=1m:100,1m:200,1m:400,30s:0 \ -REGISTRY_LAB_PRE_ALLOCATED_VUS=64 \ -REGISTRY_LAB_MAX_VUS=400 \ -k6 run perf/k6/notary_relay_backed.js -``` - -## Run with Docker k6 - -Docker Desktop on macOS does not support `--network host` the same way Linux -does. Use `host.docker.internal` for loopback services: - -```bash -docker run --rm \ - --env-file .env \ - -e CHILD_BENEFIT_FEDERATOR_URL=http://host.docker.internal:4321 \ - -e CRA_NOTARY_URL=http://host.docker.internal:4325 \ - -e SIPF_NOTARY_URL=http://host.docker.internal:4322 \ - -e NAGDI_NOTARY_URL=http://host.docker.internal:4323 \ - -v "$PWD:/workspace" \ - -w /workspace \ - grafana/k6:0.57.0 run perf/k6/notary_relay_backed.js -``` - -## Profiles - -The scenario is profile-aware via `REGISTRY_LAB_PROFILE`: - -- `smoke`: `constant-vus`, defaults to `REGISTRY_LAB_VUS=4`, - `REGISTRY_LAB_DURATION=30s`, and `REGISTRY_LAB_THINK_TIME_SECONDS=0.1`. - This catches broken routes, auth drift, and basic latency regressions. -- `capacity`: `constant-arrival-rate`, defaults to the script's baseline target - rate and `REGISTRY_LAB_THINK_TIME_SECONDS=0`. Use this for comparable - requests-per-second baselines. -- `breakpoint`: `ramping-arrival-rate`, defaults to the script's ramp stages - and `REGISTRY_LAB_THINK_TIME_SECONDS=0`. Use this to find the first target - rate where latency or error thresholds fail. - -Common overrides: - -- `REGISTRY_LAB_PROFILE=smoke|capacity|breakpoint` -- `REGISTRY_LAB_DURATION=30s` -- `REGISTRY_LAB_VUS=4` -- `REGISTRY_LAB_RATE=200` -- `REGISTRY_LAB_PRE_ALLOCATED_VUS=64` -- `REGISTRY_LAB_MAX_VUS=400` -- `REGISTRY_LAB_START_RATE=0` -- `REGISTRY_LAB_THINK_TIME_SECONDS=0` -- `REGISTRY_LAB_STAGES=1m:100,1m:200,1m:400,30s:0` - -Keep hosted or shared environments opt-in; the default URLs are local loopback -ports. - -`notary_relay_backed` defaults to `200 req/s` for capacity runs and ramps from -`100` to `200` to `400 req/s` for breakpoint runs. - -## Reports - -The script writes: - -- `output/perf/results/.json` -- `output/perf/reports/.txt` - -The text summaries print the active profile, think time, rate/count gauges, -latency distributions (`avg`, `med`, `p90`, `p95`, `p99`, `max`), and -status-code counters. Status-specific counters are emitted as first-class -metrics because k6 does not include tag cardinality in the compact text summary. diff --git a/perf/k6/lib/common.js b/perf/k6/lib/common.js deleted file mode 100644 index 60e11cd..0000000 --- a/perf/k6/lib/common.js +++ /dev/null @@ -1,336 +0,0 @@ -import { Counter, Rate } from 'k6/metrics'; -import { sleep } from 'k6'; - -export const PURPOSE = 'https://id.registrystack.org/solmara/purpose/citizen-self-service'; -export const CHILD_BENEFIT_PURPOSE = 'https://id.registrystack.org/solmara/purpose/child-benefit-review'; -export const PENSION_PAYMENT_PURPOSE = 'https://id.registrystack.org/solmara/purpose/pension-payment-review'; -export const SURVIVOR_BENEFIT_PURPOSE = 'https://id.registrystack.org/solmara/purpose/survivor-benefit-determination'; -export const VOUCHER_PURPOSE = 'https://id.registrystack.org/solmara/purpose/voucher-eligibility-review'; -export const LIVESTOCK_PURPOSE = 'https://id.registrystack.org/solmara/purpose/livestock-movement-control'; -export const CLAIM_RESULT = 'application/vnd.registry-notary.claim-result+json'; -export const SD_JWT = 'application/dc+sd-jwt'; - -export const unexpectedStatus = new Counter('registry_lab_unexpected_status_total'); -export const checkFailureRate = new Rate('registry_lab_check_failure_rate'); -export const httpStatusTotal = new Counter('registry_lab_http_status_total'); -export const httpStatus200 = new Counter('registry_lab_http_status_200_total'); -export const httpStatus201 = new Counter('registry_lab_http_status_201_total'); -export const httpStatus204 = new Counter('registry_lab_http_status_204_total'); -export const httpStatus400 = new Counter('registry_lab_http_status_400_total'); -export const httpStatus401 = new Counter('registry_lab_http_status_401_total'); -export const httpStatus403 = new Counter('registry_lab_http_status_403_total'); -export const httpStatus404 = new Counter('registry_lab_http_status_404_total'); -export const httpStatus409 = new Counter('registry_lab_http_status_409_total'); -export const httpStatus422 = new Counter('registry_lab_http_status_422_total'); -export const httpStatus429 = new Counter('registry_lab_http_status_429_total'); -export const httpStatus500 = new Counter('registry_lab_http_status_500_total'); -export const httpStatus502 = new Counter('registry_lab_http_status_502_total'); -export const httpStatus503 = new Counter('registry_lab_http_status_503_total'); -export const httpStatus504 = new Counter('registry_lab_http_status_504_total'); -export const httpStatusOther = new Counter('registry_lab_http_status_other_total'); - -export function env(name, fallback = '') { - const value = __ENV[name]; - if (value === undefined || value === null || value === '') { - return fallback; - } - return value; -} - -export function requiredEnv(name) { - const value = env(name); - if (value === '') { - throw new Error(`missing required environment variable ${name}`); - } - return value; -} - -export function duration() { - return env('REGISTRY_LAB_DURATION', '30s'); -} - -export function profile() { - return env('REGISTRY_LAB_PROFILE', 'smoke'); -} - -export function vus(fallback = 4) { - return Number(env('REGISTRY_LAB_VUS', String(fallback))); -} - -export function rate(fallback = 8) { - return Number(env('REGISTRY_LAB_RATE', String(fallback))); -} - -export function preAllocatedVus(fallback = 8) { - return Number(env('REGISTRY_LAB_PRE_ALLOCATED_VUS', String(fallback))); -} - -export function maxVus(fallback = 32) { - return Number(env('REGISTRY_LAB_MAX_VUS', String(fallback))); -} - -export function thinkTimeSeconds() { - const fallback = profile() === 'smoke' ? '0.1' : '0'; - return Number(env('REGISTRY_LAB_THINK_TIME_SECONDS', fallback)); -} - -export function commonOptions(thresholds = {}) { - return { - discardResponseBodies: false, - summaryTrendStats: ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'], - thresholds: { - checks: ['rate>=0.99'], - registry_lab_check_failure_rate: ['rate<0.01'], - registry_lab_unexpected_status_total: ['count==0'], - ...thresholds, - }, - }; -} - -export function sleepIfConfigured() { - const seconds = thinkTimeSeconds(); - if (seconds > 0) { - sleep(seconds); - } -} - -export function loopScenario(extra = {}) { - const { vusDefault, rateDefault, preAllocatedVusDefault, maxVusDefault, stages, ...scenario } = extra; - return { - executor: 'constant-vus', - vus: vus(vusDefault || 4), - duration: duration(), - gracefulStop: '10s', - ...scenario, - }; -} - -export function arrivalScenario(extra = {}) { - const { rateDefault, preAllocatedVusDefault, maxVusDefault, stages, ...scenario } = extra; - return { - executor: 'constant-arrival-rate', - rate: rate(rateDefault || 8), - timeUnit: '1s', - duration: duration(), - preAllocatedVUs: preAllocatedVus(preAllocatedVusDefault || 8), - maxVUs: maxVus(maxVusDefault || 32), - gracefulStop: '10s', - ...scenario, - }; -} - -export function rampingArrivalScenario(extra = {}) { - const { rateDefault, preAllocatedVusDefault, maxVusDefault, stages: defaultStages, ...scenario } = extra; - const stages = parseStages( - env('REGISTRY_LAB_STAGES'), - defaultStages || [ - { duration: '1m', target: rate(rateDefault || 8) }, - { duration: '1m', target: rate(rateDefault || 8) * 2 }, - { duration: '1m', target: rate(rateDefault || 8) * 4 }, - { duration: '30s', target: 0 }, - ], - ); - return { - executor: 'ramping-arrival-rate', - startRate: Number(env('REGISTRY_LAB_START_RATE', '0')), - timeUnit: '1s', - stages, - preAllocatedVUs: preAllocatedVus(preAllocatedVusDefault || 8), - maxVUs: maxVus(maxVusDefault || 32), - gracefulStop: '10s', - ...scenario, - }; -} - -export function profiledScenario(extra = {}) { - switch (profile()) { - case 'capacity': - return arrivalScenario(extra); - case 'breakpoint': - return rampingArrivalScenario(extra); - case 'smoke': - return loopScenario(extra); - default: - throw new Error(`unsupported REGISTRY_LAB_PROFILE ${profile()}`); - } -} - -function parseStages(value, fallback) { - if (!value) { - return fallback; - } - return value.split(',').map((stage) => { - const [durationValue, targetValue] = stage.split(':'); - if (!durationValue || !targetValue) { - throw new Error(`invalid REGISTRY_LAB_STAGES entry ${stage}; expected duration:target`); - } - return { - duration: durationValue, - target: Number(targetValue), - }; - }); -} - -export function bearerHeaders(token, purpose = PURPOSE, accept = 'application/json') { - return { - Authorization: `Bearer ${token}`, - Accept: accept, - 'Data-Purpose': purpose, - 'X-Request-Id': requestId(), - }; -} - -export function jsonHeaders(token, purpose = PURPOSE, accept = CLAIM_RESULT) { - return { - ...bearerHeaders(token, purpose, accept), - 'Content-Type': 'application/json', - }; -} - -export function requestId() { - return `solmara-lab-perf-${__VU}-${__ITER}-${Date.now()}`; -} - -export function target(subjectId, scheme = 'solmara_uin') { - return { - type: 'Person', - identifiers: [{ scheme, value: subjectId }], - }; -} - -export function evaluationPayload(subjectId, claim, disclosure = 'predicate', format = CLAIM_RESULT, scheme = 'solmara_uin') { - return JSON.stringify({ - target: target(subjectId, scheme), - claims: [claim], - disclosure, - format, - }); -} - -export function parseJson(response) { - try { - return response.json(); - } catch (_) { - return {}; - } -} - -export function recordStatus(ok, response, expected) { - recordHttpStatus(response); - checkFailureRate.add(!ok); - if (!ok) { - unexpectedStatus.add(1, { - status: String(response.status), - expected: Array.isArray(expected) ? expected.join(',') : String(expected), - }); - } -} - -export function recordHttpStatus(response) { - const status = Number(response.status); - httpStatusTotal.add(1, { status: String(status) }); - switch (status) { - case 200: - httpStatus200.add(1); - break; - case 201: - httpStatus201.add(1); - break; - case 204: - httpStatus204.add(1); - break; - case 400: - httpStatus400.add(1); - break; - case 401: - httpStatus401.add(1); - break; - case 403: - httpStatus403.add(1); - break; - case 404: - httpStatus404.add(1); - break; - case 409: - httpStatus409.add(1); - break; - case 422: - httpStatus422.add(1); - break; - case 429: - httpStatus429.add(1); - break; - case 500: - httpStatus500.add(1); - break; - case 502: - httpStatus502.add(1); - break; - case 503: - httpStatus503.add(1); - break; - case 504: - httpStatus504.add(1); - break; - default: - httpStatusOther.add(1); - break; - } -} - -function metricLine(name, metric) { - if (!metric || !metric.values) { - return `${name}: {}`; - } - const values = metric.values; - const fields = []; - for (const [label, key] of [ - ['count', 'count'], - ['rate', 'rate'], - ['value', 'value'], - ['avg', 'avg'], - ['min', 'min'], - ['med', 'med'], - ['p90', 'p(90)'], - ['p95', 'p(95)'], - ['p99', 'p(99)'], - ['max', 'max'], - ]) { - if (values[key] !== undefined) { - fields.push(`${label}=${formatMetricValue(values[key])}`); - } - } - if (fields.length === 0) { - return `${name}: ${JSON.stringify(values)}`; - } - return `${name}: ${fields.join(' ')}`; -} - -function formatMetricValue(value) { - if (typeof value !== 'number') { - return String(value); - } - if (Number.isInteger(value)) { - return String(value); - } - return value.toFixed(4); -} - -export function summaryFor(name) { - return (data) => { - const lines = [ - `${name} summary`, - `profile: ${profile()}`, - `think_time_seconds: ${thinkTimeSeconds().toFixed(3)}`, - ]; - const metricNames = Object.keys(data.metrics || {}).sort(); - for (const metricName of metricNames) { - lines.push(metricLine(metricName, data.metrics[metricName])); - } - return { - stdout: `${lines.join('\n')}\n`, - [`output/perf/results/${name}.json`]: JSON.stringify(data, null, 2), - [`output/perf/reports/${name}.txt`]: `${lines.join('\n')}\n`, - }; - }; -} diff --git a/perf/k6/notary_relay_backed.js b/perf/k6/notary_relay_backed.js deleted file mode 100644 index edd194a..0000000 --- a/perf/k6/notary_relay_backed.js +++ /dev/null @@ -1,110 +0,0 @@ -import http from 'k6/http'; -import { check } from 'k6'; -import { - CLAIM_RESULT, - CHILD_BENEFIT_PURPOSE, - PENSION_PAYMENT_PURPOSE, - VOUCHER_PURPOSE, - commonOptions, - env, - evaluationPayload, - jsonHeaders, - parseJson, - profiledScenario, - recordStatus, - requiredEnv, - sleepIfConfigured, - summaryFor, -} from './lib/common.js'; - -const childBenefitFederatorUrl = env('CHILD_BENEFIT_FEDERATOR_URL', 'http://127.0.0.1:4321'); -const craNotaryUrl = env('CRA_NOTARY_URL', 'http://127.0.0.1:4325'); -const sipfNotaryUrl = env('SIPF_NOTARY_URL', 'http://127.0.0.1:4322'); -const nagdiNotaryUrl = env('NAGDI_NOTARY_URL', 'http://127.0.0.1:4323'); -const childBenefitToken = requiredEnv('CHILD_BENEFIT_FEDERATOR_TOKEN'); -const craPensionToken = requiredEnv('CRA_PENSION_CLIENT_TOKEN'); -const sipfPensionToken = requiredEnv('SIPF_PENSION_CLIENT_TOKEN'); -const nagdiToken = requiredEnv('NAGDI_NOTARY_TOKEN'); - -const uinSubjects = ['2300010248', '2300091305', '2300036523', '2300073046']; -const farmerSubjects = ['FR-1001', 'FR-1002', 'FR-1003', 'FR-1004']; - -export const options = { - ...commonOptions({ - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(95)<1500'], - }), - scenarios: { - notary_relay_backed: profiledScenario({ - rateDefault: 200, - preAllocatedVusDefault: 64, - maxVusDefault: 400, - stages: [ - { duration: '1m', target: 100 }, - { duration: '1m', target: 200 }, - { duration: '1m', target: 400 }, - { duration: '30s', target: 0 }, - ], - }), - }, -}; - -export default function () { - const cases = [ - { - name: 'child_benefit_review', - url: `${childBenefitFederatorUrl}/v1/evaluations`, - token: childBenefitToken, - subject: uinSubjects[(__VU + __ITER) % uinSubjects.length], - scheme: 'solmara_uin', - purpose: CHILD_BENEFIT_PURPOSE, - claim: 'birth-is-registered', - format: 'application/json', - }, - { - name: 'cra_death_registration_review', - url: `${craNotaryUrl}/v1/evaluations`, - token: craPensionToken, - subject: uinSubjects[(__VU + __ITER) % uinSubjects.length], - scheme: 'solmara_uin', - purpose: PENSION_PAYMENT_PURPOSE, - claim: 'person-is-deceased', - format: CLAIM_RESULT, - }, - { - name: 'sipf_pension_payment_review', - url: `${sipfNotaryUrl}/v1/evaluations`, - token: sipfPensionToken, - subject: uinSubjects[(__VU + __ITER) % uinSubjects.length], - scheme: 'solmara_uin', - purpose: PENSION_PAYMENT_PURPOSE, - claim: 'pension-payment-active', - format: CLAIM_RESULT, - }, - { - name: 'voucher_eligibility_review', - url: `${nagdiNotaryUrl}/v1/evaluations`, - token: nagdiToken, - subject: farmerSubjects[(__VU + __ITER) % farmerSubjects.length], - scheme: 'farmer_id', - purpose: VOUCHER_PURPOSE, - claim: 'eligible-for-climate-smart-input-voucher', - format: CLAIM_RESULT, - }, - ]; - const item = cases[(__VU + __ITER) % cases.length]; - const response = http.post( - item.url, - evaluationPayload(item.subject, item.claim, 'predicate', item.format, item.scheme), - { headers: jsonHeaders(item.token, item.purpose, item.format) }, - ); - const body = parseJson(response); - const ok = check(response, { - [`${item.name} returned 200`]: (r) => r.status === 200, - [`${item.name} returned results`]: () => Array.isArray(body.results) && body.results.length > 0, - }); - recordStatus(ok, response, 200); - sleepIfConfigured(); -} - -export const handleSummary = summaryFor('notary_relay_backed'); diff --git a/portal/AGENTS.md b/portal/AGENTS.md index ed39523..e386c3c 100644 --- a/portal/AGENTS.md +++ b/portal/AGENTS.md @@ -1,6 +1,6 @@ # portal - Glass Government Citizen Services Portal -This is the Phase 0 mock of the Glass Government citizen services portal for the Republic of Solmara (a fictional nation). It demonstrates the "evidence field" UX pattern: structured, authority-verified fields that show the citizen exactly what data was checked, which authority answered, and what was NOT disclosed. All authority calls are behind the `EvidenceProvider` seam (see `src/lib/providers/EvidenceProvider.ts`), which starts with a `MockEvidenceProvider` and will be wired to live Registry Notary endpoints in Phase 1 without a rewrite. +This is the Glass Government citizen services portal for the Republic of Solmara (a fictional nation). It demonstrates the "evidence field" UX pattern: structured, authority-verified fields that show the citizen exactly what was checked, which authority answered, and what was not disclosed. All authority calls stay behind the `EvidenceProvider` seam, with mock and live providers sharing the same renderer contract. The live provider consumes only the programme application's safe projection of independently verified authority assertions. ## Source-of-truth documentation diff --git a/portal/Dockerfile b/portal/Dockerfile index 6ffb014..321c742 100644 --- a/portal/Dockerfile +++ b/portal/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS build +FROM node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build WORKDIR /app RUN corepack enable @@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm build -FROM node:22-alpine +FROM node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 WORKDIR /app ENV NODE_ENV=production diff --git a/portal/e2e/portal.test.ts b/portal/e2e/portal.test.ts index c5ff1cb..5f2a073 100644 --- a/portal/e2e/portal.test.ts +++ b/portal/e2e/portal.test.ts @@ -61,20 +61,20 @@ test('child benefit delegated two-hop: the civil read resolves only after consen await expect(page.getByText('Child age under 5: yes')).toBeVisible({ timeout: 10_000 }); }); -// The footer audit log is an expandable drawer: collapsed to a few rows by default, +// The footer proof log is an expandable drawer: collapsed to a few rows by default, // click to expand and scroll the full proof history (older entries are not lost). -test('the proof audit log drawer expands to reveal full history', async ({ page }) => { +test('the proof log drawer expands to reveal full history', async ({ page }) => { await signIn(page); await page.getByTestId('card-farmer-voucher').click(); await expect(page.getByText('Registered farmer: yes')).toBeVisible({ timeout: 10_000 }); - const toggle = page.getByTestId('audit-log-toggle'); + const toggle = page.getByTestId('proof-log-toggle'); await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); // The scroll region exists and can hold overflow (full history is reachable). - await expect(page.locator('#proof-audit-log')).toBeVisible(); + await expect(page.locator('#proof-log')).toBeVisible(); }); // DoD: every EvidenceField state is reachable on the mock via the state gallery. diff --git a/portal/src/lib/fields/EvidenceField.test.ts b/portal/src/lib/fields/EvidenceField.test.ts index 25a37d0..35ae0a5 100644 --- a/portal/src/lib/fields/EvidenceField.test.ts +++ b/portal/src/lib/fields/EvidenceField.test.ts @@ -17,7 +17,7 @@ const verifyField: Field = { label: 'Farmer registration', kind: 'verify', claim: 'farmer-registered', - notary: 'agri' + authority: 'agri' }; const fetchField: Field = { @@ -25,14 +25,14 @@ const fetchField: Field = { label: 'Date of birth', kind: 'fetch', claim: 'child-age-under-5', - notary: 'civil' + authority: 'civil' }; const decisionField: Field = { id: 'combined-eligibility', label: 'Combined eligibility', kind: 'decision', - notary: 'social', + authority: 'social', manual: true }; diff --git a/portal/src/lib/fields/authorities.ts b/portal/src/lib/fields/authorities.ts index b5c003c..950b903 100644 --- a/portal/src/lib/fields/authorities.ts +++ b/portal/src/lib/fields/authorities.ts @@ -1,65 +1,96 @@ -import type { NotaryId } from '$lib/types'; +import type { AuthorityId, EvidencePresentation, EvidenceSource } from '$lib/types'; export type SolmaraAuthority = { - id: NotaryId; + id: AuthorityId; label: string; serviceId: string; + issuer: string; + origin: string; }; // One Solmara authority source for portal labels, mock provenance, and live // trace copy. Service endpoints come from environment config; transport security // is a deployment concern and is not inferred from an evidence response. -export const SOLMARA_AUTHORITIES: Record = { +export const SOLMARA_AUTHORITIES: Record = { civil: { id: 'civil', label: 'Civil Registration Authority', - serviceId: 'cra-notary' + serviceId: 'cra-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:cra', + origin: 'https://cra-evidence.solmara.registrystack.org' }, social: { id: 'social', label: 'Social Insurance and Pensions Fund', - serviceId: 'sipf-notary' + serviceId: 'sipf-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:sipf', + origin: 'https://sipf-evidence.solmara.registrystack.org' }, agri: { id: 'agri', label: 'National Agricultural Data Institute', - serviceId: 'nagdi-notary' + serviceId: 'nagdi-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:nagdi', + origin: 'https://nagdi-evidence.solmara.registrystack.org' }, certs: { id: 'certs', label: 'Civil Registration Authority', - serviceId: 'cra-notary' + serviceId: 'cra-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:cra', + origin: 'https://cra-evidence.solmara.registrystack.org' }, childCivil: { id: 'childCivil', label: 'Civil Registration Authority', - serviceId: 'cra-notary' + serviceId: 'cra-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:cra', + origin: 'https://cra-evidence.solmara.registrystack.org' }, population: { id: 'population', label: 'National Identity Agency', - serviceId: 'nia-notary' + serviceId: 'nia-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:nia', + origin: 'https://nia-evidence.solmara.registrystack.org' }, socialRegistry: { id: 'socialRegistry', label: 'Social Registry Office', - serviceId: 'sro-notary' + serviceId: 'sro-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:sro', + origin: 'https://sro-evidence.solmara.registrystack.org' }, programme: { id: 'programme', label: 'MoSD Programme MIS', - serviceId: 'programme-notary' + serviceId: 'mosd-programme-evidence', + issuer: 'did:web:id.registrystack.org:solmara:authority:mosd-programme-mis', + origin: 'https://mosd-programme-evidence.solmara.registrystack.org' } }; -export const AUTHORITY_NAMES: Record = Object.fromEntries( +export const AUTHORITY_NAMES: Record = Object.fromEntries( Object.entries(SOLMARA_AUTHORITIES).map(([id, authority]) => [id, authority.label]) -) as Record; +) as Record; -// A safe default so a wait still names *someone* if a result omits its notary. +// A safe default so a wait still names someone if a result omits its authority. const FALLBACK_AUTHORITY = 'the authority'; -export function authorityName(notary: NotaryId | undefined): string { - if (notary === undefined) return FALLBACK_AUTHORITY; - return AUTHORITY_NAMES[notary]; +export function authorityName(authority: AuthorityId | undefined): string { + if (authority === undefined) return FALLBACK_AUTHORITY; + return AUTHORITY_NAMES[authority]; +} + +export function evidencePresentation( + authority: AuthorityId, + source: EvidenceSource +): EvidencePresentation { + const definition = SOLMARA_AUTHORITIES[authority]; + return { + authority: definition.label, + issuer: definition.issuer, + serviceId: definition.serviceId, + source + }; } diff --git a/portal/src/lib/fields/index.ts b/portal/src/lib/fields/index.ts index dd92522..5f3699b 100644 --- a/portal/src/lib/fields/index.ts +++ b/portal/src/lib/fields/index.ts @@ -10,6 +10,6 @@ export { presentationFor, stampsOnEntry } from './states'; export type { Channel, StatusIcon as StatusIconName, StatePresentation } from './states'; // Authority and reason-code lookups, the single source of truth for how a -// NotaryId reads to a citizen and how a reason code maps to a human sentence. +// authority id reads to a citizen and how a reason code maps to a human sentence. export { authorityName, AUTHORITY_NAMES } from './authorities'; export { reasonSentence, REASON_CODES } from './reasonCodes'; diff --git a/portal/src/lib/forms/DenialBeat.svelte b/portal/src/lib/forms/DenialBeat.svelte index 579dd66..1c095c6 100644 --- a/portal/src/lib/forms/DenialBeat.svelte +++ b/portal/src/lib/forms/DenialBeat.svelte @@ -1,6 +1,6 @@
{/if} -
+ + {/each} + + {#if identityTrace} +
+
{identityTrace.id}Identity
+

{identityTrace.headline}

+

{identityTrace.answered}

+

Not disclosed: {identityTrace.notDisclosed}

+
+ {/if} + + {#if traces.length === 0} +

No proof entries yet. Open a service to begin.

{/if} diff --git a/portal/src/lib/proof/ProofInspector.test.ts b/portal/src/lib/proof/ProofInspector.test.ts index bebc1c0..4bdb6a2 100644 --- a/portal/src/lib/proof/ProofInspector.test.ts +++ b/portal/src/lib/proof/ProofInspector.test.ts @@ -1,266 +1,66 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; import ProofInspector from './ProofInspector.svelte'; -import { CANNED_TRACES } from './canned-traces.js'; import type { ProofTrace } from '$lib/types'; -// Single trace with all depths populated -const verifiedTrace: ProofTrace = { - id: 'event-2', - seq: 2, +const trace: ProofTrace = { + id: 'proof-1', + seq: 1, fieldId: 'farmer-registered', authority: 'agri', - headline: 'Confirmed by NAgDI: Amina did not have to prove this herself', - answered: 'Agriculture answered: farmer-registered = true', - notDisclosed: 'Only the yes/no, no farm details or parcel coordinates', + headline: 'Confirmed by NAgDI', + answered: 'National Agricultural Data Institute answered: farmer-registered = true', + notDisclosed: 'source rows and selector values', status: 'ok', ts: '2026-06-21T12:04:09.000Z', - request: { - method: 'POST', - url: 'https://nagdi-notary.solmara.example/v1/evaluations', - body: { - claim: 'farmer-registered', - purpose: 'https://id.registrystack.org/solmara/purpose/voucher-eligibility-review', - relationship: 'self' - } - }, - response: { - status: 200, - body: { - registered: true, - source_authority: 'Agriculture', - as_of: '2026-05-01' - } - }, + purpose: 'voucher-eligibility-review', + resultState: 'verified', + responseStatus: 200, + presentations: [{ + authority: 'National Agricultural Data Institute', + issuer: 'did:web:id.registrystack.org:solmara:authority:nagdi', + serviceId: 'nagdi-evidence', + source: 'Relay lookup' + }], proof: { - signedBy: 'No credential issued; National Agricultural Data Institute returned a claim evaluation', - algorithm: 'Registry Notary claim-result response; no credential signature asserted', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Not credential-bound; the portal selected the purpose and subject', - credential: 'Claim result only; no credential issued', - auditId: 'Not available in this canned gallery trace' - } -}; - -const denialTrace: ProofTrace = { - id: 'event-4', - seq: 4, - fieldId: 'person-is-deceased', - authority: 'civil', - headline: 'Denied by Civil Registry: subject mismatch, no data read', - answered: 'Civil answered: person-is-deceased = denied (subject_mismatch)', - notDisclosed: 'No data was read; the query was rejected before any registry access', - status: 'denied', - ts: '2026-06-21T12:04:15.000Z', - request: { - method: 'POST', - url: 'https://cra-notary.solmara.example/v1/evaluations', - body: { - claim: 'person-is-deceased', - purpose: 'https://id.registrystack.org/solmara/purpose/pension-payment-review', - relationship: 'self' - } - }, - response: { - status: 403, - body: { - error: 'subject_mismatch', - source_authority: 'Civil Registry', - message: 'Token subject does not match requested target' - } - } -}; - -const applicationEvidenceTrace: ProofTrace = { - ...verifiedTrace, - id: 'event-application-evidence', - authority: 'population', - proof: { - signedBy: 'National Identity Agency source-owned Notary', - algorithm: 'Ordinary JSON response; no application signature asserted', - issuerKey: 'Not applicable for an application evidence set', - holderBound: 'The application selected the purpose and subject', - credential: 'Minimized source-attributed predicate result', - auditId: 'evidence-set:cbe_test' + signedBy: 'National Agricultural Data Institute', + algorithm: 'Flattened JWS, ES256, verified server-side', + issuerKey: 'Authority JWKS', + holderBound: 'Reviewed request', + credential: 'Signed minimum-disclosure Evidence assertion' } }; describe('ProofInspector', () => { - describe('Depth 1 - always visible without expansion', () => { - it('renders the "Not disclosed:" line at depth 1 without any expansion', () => { - render(ProofInspector, { props: { traces: [verifiedTrace] } }); - - // The "Not disclosed:" label must be visible without expanding any accordion - const labels = screen.getAllByText('Not disclosed:'); - expect(labels.length).toBeGreaterThan(0); - - // The not-disclosed text itself is always present in the DOM - expect( - screen.getByText('Only the yes/no, no farm details or parcel coordinates') - ).toBeInTheDocument(); - }); - - it('renders the headline at depth 1', () => { - render(ProofInspector, { props: { traces: [verifiedTrace] } }); - expect( - screen.getByText('Confirmed by NAgDI: Amina did not have to prove this herself') - ).toBeInTheDocument(); - }); - - it('renders the answered line at depth 1', () => { - render(ProofInspector, { props: { traces: [verifiedTrace] } }); - expect( - screen.getByText('Agriculture answered: farmer-registered = true') - ).toBeInTheDocument(); - }); - - it('does not present canned evaluations or UserInfo as signed credentials', () => { - const serialized = JSON.stringify(CANNED_TRACES); - expect(serialized).not.toMatch(/SD-JWT|EdDSA\/Ed25519/); - expect(serialized).toContain('no credential signature asserted'); - expect(CANNED_TRACES.find((trace) => trace.fieldId === 'household-below-poverty-threshold')?.authority).toBe( - 'socialRegistry' - ); - }); - - it('renders "Not disclosed:" for all canned traces without expansion', () => { - render(ProofInspector, { props: { traces: CANNED_TRACES } }); - // Every trace has a not-disclosed line; count the labels - const labels = screen.getAllByText('Not disclosed:'); - expect(labels.length).toBe(CANNED_TRACES.length); - }); - - it('renders the denial trace "Not disclosed:" at depth 1', () => { - render(ProofInspector, { props: { traces: [denialTrace] } }); - expect( - screen.getByText('No data was read; the query was rejected before any registry access') - ).toBeInTheDocument(); - }); - }); - - describe('Bearer token redaction', () => { - it('never renders a real bearer token in the DOM', async () => { - const { container } = render(ProofInspector, { props: { traces: CANNED_TRACES } }); - - // Expand depth 2 for the verified trace - const expandBtns = screen.getAllByText(/Request and response/); - for (const btn of expandBtns) { - await fireEvent.click(btn); - } - - const html = container.innerHTML; - - // Must never contain a raw token value. Only redacted placeholders should - // be visible in the rendered request. - expect(html).not.toMatch(/Bearer [A-Za-z0-9+/=._-]{8,}/); - expect(html).not.toMatch(/x-api-key:\s+[A-Za-z0-9+/=._-]{8,}/); + it('renders the resident-facing result without exposing evidence internals', () => { + const { container } = render(ProofInspector, { traces: [trace] }); - // The redacted placeholder MUST be present after expanding - expect(html).toContain('(redacted)'); - }); - - it('renders redacted dots instead of a real token value', async () => { - const { container } = render(ProofInspector, { props: { traces: [verifiedTrace] } }); - - const expandBtn = screen.getByText(/Request and response/); - await fireEvent.click(expandBtn); - - // Should find the redacted dots and the "(redacted)" label - const redactedEl = container.querySelector('.redacted'); - expect(redactedEl).toBeInTheDocument(); - expect(container.innerHTML).toContain('(redacted)'); - }); + expect(screen.getByText('Confirmed by NAgDI')).toBeInTheDocument(); + expect(screen.getByText(/source rows and selector values/)).toBeInTheDocument(); + expect(container.textContent).not.toMatch(/requestNonce|subjects|protected|payload|signature|Bearer|FR-\d+/); }); - describe('Application evidence proof', () => { - it('shows the evidence artifact without presenting a synthetic SD-JWT credential', async () => { - render(ProofInspector, { props: { traces: [applicationEvidenceTrace] } }); + it('expands canonical authority, issuer, service, and exact source metadata only', async () => { + const { container } = render(ProofInspector, { traces: [trace] }); + await fireEvent.click(screen.getByRole('button', { name: /Authority evidence/ })); - await fireEvent.click(screen.getByText(/Request and response/)); - await fireEvent.click(screen.getByText(/Cryptographic proof/)); - - expect(screen.getByText('Minimized source-attributed predicate result')).toBeInTheDocument(); - expect( - screen.getByText('National Identity Agency source-owned Notary') - ).toBeInTheDocument(); - expect(screen.queryByText('Raw SD-JWT')).not.toBeInTheDocument(); - }); + expect(screen.getAllByText('National Agricultural Data Institute')).toHaveLength(2); + expect(screen.getByText('did:web:id.registrystack.org:solmara:authority:nagdi')).toBeInTheDocument(); + expect(screen.getByText('nagdi-evidence')).toBeInTheDocument(); + expect(screen.getByText('Relay lookup')).toBeInTheDocument(); + expect(container.textContent).not.toMatch(/raw wire|audit id|compact JWS|request body/i); }); - describe('Copy-as-curl', () => { - it('curl output contains $NOTARY_TOKEN placeholder, not a real token', async () => { - const writtenTexts: string[] = []; - const mockWriteText = vi.fn((text: string) => { - writtenTexts.push(text); - return Promise.resolve(); - }); - - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: mockWriteText }, - writable: true, - configurable: true - }); - - render(ProofInspector, { props: { traces: [verifiedTrace] } }); - - // Expand depth 2 - const expandBtn = screen.getByText(/Request and response/); - await fireEvent.click(expandBtn); - - // Click the copy-as-curl button - const copyBtn = screen.getByLabelText('Copy request as curl command'); - await fireEvent.click(copyBtn); - - expect(mockWriteText).toHaveBeenCalledOnce(); - - const curlCmd = writtenTexts[0]; - - // Must contain the placeholder token variable - expect(curlCmd).toContain('$NOTARY_TOKEN'); - expect(curlCmd).toContain('x-api-key: $NOTARY_TOKEN'); - - // Must NOT contain any real token material - // Real tokens would be long base64 or JWT-format strings - expect(curlCmd).not.toMatch(/Bearer [A-Za-z0-9+/=._-]{8,}/); - expect(curlCmd).not.toMatch(/x-api-key:\s+[A-Za-z0-9+/=._-]{8,}/); - - // Must include the request method and URL - expect(curlCmd).toContain('POST'); - expect(curlCmd).toContain(verifiedTrace.request.url); - }); - }); - - describe('Connected state', () => { - it('does not show reconnecting pill when connected', () => { - render(ProofInspector, { props: { traces: [], connected: true } }); - expect(screen.queryByText(/Reconnecting to audit feed/i)).not.toBeInTheDocument(); - }); - - it('shows the reconnecting pill when disconnected', () => { - render(ProofInspector, { props: { traces: [], connected: false } }); - expect(screen.getByText(/Reconnecting to audit feed/i)).toBeInTheDocument(); - }); - }); - - describe('In-flight skeleton', () => { - it('renders in-flight traces at the top with heartbeat indicator', () => { - const inFlightTrace: ProofTrace = CANNED_TRACES.find( - (t) => t.status === 'in_flight' - )!; - render(ProofInspector, { props: { traces: [inFlightTrace] } }); - - // The heartbeat dot is rendered with aria-label "In flight" - expect(screen.getByLabelText('In flight')).toBeInTheDocument(); - }); + it('states when authorization prevented any authority call', async () => { + const denied = { ...trace, id: 'denied', status: 'denied' as const, resultState: 'error' as const, presentations: [] }; + render(ProofInspector, { traces: [denied] }); + await fireEvent.click(screen.getByRole('button', { name: /Authority evidence/ })); + expect(screen.getByText('No authority Evidence service was called.')).toBeInTheDocument(); }); - describe('All canned traces render', () => { - it('renders all five canned traces without error', () => { - const { container } = render(ProofInspector, { props: { traces: CANNED_TRACES } }); - // All 5 event ids should appear - for (const trace of CANNED_TRACES) { - expect(container.innerHTML).toContain(trace.id); - } - }); + it('shows reconnecting state without removing existing proofs', () => { + render(ProofInspector, { traces: [trace], connected: false }); + expect(screen.getByRole('status')).toHaveTextContent('Reconnecting to proof feed'); + expect(screen.getByText('Confirmed by NAgDI')).toBeInTheDocument(); }); }); diff --git a/portal/src/lib/proof/ProofTicker.svelte b/portal/src/lib/proof/ProofTicker.svelte index 6917fe7..4fe6b0b 100644 --- a/portal/src/lib/proof/ProofTicker.svelte +++ b/portal/src/lib/proof/ProofTicker.svelte @@ -39,8 +39,8 @@ } function authorityShort(trace: ProofTrace): string { - // The identity-binding trace has no Notary authority (eSignet is the identity - // provider, not one of the four Notaries), so name its issuer explicitly rather + // The identity-binding trace has no Evidence authority (eSignet is the identity + // provider, not one of the source authorities), so name its issuer explicitly rather // than falling through to a bare "Unknown". if (trace.id === IDENTITY_TRACE_ID) { return 'eSignet'; @@ -63,7 +63,7 @@ case 'programme': return 'Programme'; default: - return trace.authority ?? 'Unknown'; + return trace.status === 'denied' ? 'Portal' : trace.authority ?? 'Unattributed'; } } @@ -90,7 +90,7 @@ class="proof-ticker" role="log" aria-live="polite" - aria-label="Proof audit log" + aria-label="Proof log" aria-atomic="false" aria-relevant="additions" > diff --git a/portal/src/lib/proof/ProofTicker.test.ts b/portal/src/lib/proof/ProofTicker.test.ts index 532a095..e801783 100644 --- a/portal/src/lib/proof/ProofTicker.test.ts +++ b/portal/src/lib/proof/ProofTicker.test.ts @@ -17,9 +17,9 @@ describe('ProofTicker', () => { expect(ticker).toHaveAttribute('aria-live', 'polite'); }); - it('is labelled as the proof audit log', () => { + it('is labelled as the proof log', () => { render(ProofTicker, { props: { traces: [] } }); - expect(screen.getByRole('log', { name: /proof audit log/i })).toBeInTheDocument(); + expect(screen.getByRole('log', { name: /proof log/i })).toBeInTheDocument(); }); it('renders an entry for each trace', () => { @@ -59,14 +59,14 @@ describe('ProofTicker', () => { expect(screen.getAllByText('Agri').length).toBeGreaterThan(0); // Social Registry entries expect(screen.getAllByText('Social Registry').length).toBeGreaterThan(0); - // Civil entries - expect(screen.getAllByText('Civil').length).toBeGreaterThan(0); + // Portal authorization entry + expect(screen.getAllByText('Portal').length).toBeGreaterThan(0); }); it('labels the identity-binding trace as eSignet, never Unknown', () => { - // The identity trace carries no Notary `authority` (eSignet is not one of the - // four Notaries), so a naive authority lookup falls through to "Unknown". It - // must instead name its issuer so the very first audit line reads honestly. + // The identity trace carries no authority Evidence source (eSignet is the + // identity provider), so a naive lookup falls through to "Unknown". It + // must instead name its issuer so the first proof line reads honestly. render(ProofTicker, { props: { traces: [buildIdentityTrace('Elena Dela Cruz')] } }); expect(screen.queryByText('Unknown')).toBeNull(); expect(screen.getByText('eSignet')).toBeInTheDocument(); diff --git a/portal/src/lib/proof/canned-traces.ts b/portal/src/lib/proof/canned-traces.ts index 12d1352..be57298 100644 --- a/portal/src/lib/proof/canned-traces.ts +++ b/portal/src/lib/proof/canned-traces.ts @@ -1,181 +1,113 @@ -// Canned ProofTrace[] for the proof gallery demo and unit tests. -// Every ProofStatus and depth shape is represented. import type { ProofTrace } from '$lib/types'; export const CANNED_TRACES: ProofTrace[] = [ - // 1. In-flight (skeleton at top) { id: 'event-1', seq: 1, fieldId: 'farmer-registered', authority: 'agri', - headline: 'Checking NAgDI for farmer-registered status...', - answered: 'Agriculture answered: farmer-registered = (pending)', - notDisclosed: 'Only the yes/no result is checked, no farm details', + headline: 'Checking NAgDI for farmer registration status...', + answered: 'National Agricultural Data Institute response is pending', + notDisclosed: 'Only the reviewed answer is requested, no farm details', status: 'in_flight', ts: '2026-06-21T12:04:05.000Z', - request: { - method: 'POST', - url: 'https://nagdi-notary.solmara.example/v1/evaluations', - body: { - claim: 'farmer-registered', - purpose: 'https://id.registrystack.org/solmara/purpose/voucher-eligibility-review', - relationship: 'self' + purpose: 'voucher-eligibility-review', + resultState: 'in_flight', + presentations: [ + { + authority: 'National Agricultural Data Institute', + issuer: 'did:web:id.registrystack.org:solmara:authority:nagdi', + serviceId: 'nagdi-evidence', + source: 'Relay lookup' } - } + ] }, - - // 2. Verified (farmer registration) { id: 'event-2', seq: 2, fieldId: 'farmer-registered', authority: 'agri', - headline: - 'Confirmed by NAgDI: Amina did not have to prove this herself', - answered: 'Agriculture answered: farmer-registered = true', - notDisclosed: 'Only the yes/no, no farm details or parcel coordinates', + headline: 'Confirmed by NAgDI: the applicant did not have to prove this herself', + answered: 'National Agricultural Data Institute answered: farmer-registered = true', + notDisclosed: 'Only the yes/no answer, no farm details or parcel coordinates', status: 'ok', ts: '2026-06-21T12:04:09.000Z', - request: { - method: 'POST', - url: 'https://nagdi-notary.solmara.example/v1/evaluations', - body: { - claim: 'farmer-registered', - purpose: 'https://id.registrystack.org/solmara/purpose/voucher-eligibility-review', - relationship: 'self' + purpose: 'voucher-eligibility-review', + resultState: 'verified', + responseStatus: 200, + presentations: [ + { + authority: 'National Agricultural Data Institute', + issuer: 'did:web:id.registrystack.org:solmara:authority:nagdi', + serviceId: 'nagdi-evidence', + source: 'Relay lookup' } - }, - response: { - status: 200, - body: { - registered: true, - source_authority: 'Agriculture', - as_of: '2026-05-01' - } - }, + ], proof: { - signedBy: 'No credential issued; National Agricultural Data Institute returned a claim evaluation', - algorithm: 'Registry Notary claim-result response; no credential signature asserted', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Not credential-bound; the portal selected the purpose and subject', - credential: 'Claim result only; no credential issued', - auditId: 'Not available in this canned gallery trace' + signedBy: 'National Agricultural Data Institute issued the returned Evidence', + algorithm: 'Verified Evidence assertion', + issuerKey: 'Authority Evidence JWKS', + holderBound: 'Audience-scoped to the portal request', + credential: 'Minimum-disclosure Evidence assertion' } }, - - // 3. Fetched (household composition) { id: 'event-3', seq: 3, fieldId: 'household-below-poverty-threshold', authority: 'socialRegistry', - headline: - 'Verified by Social Protection: household is below the programme threshold', - answered: 'Social answered: household-below-poverty-threshold = true', + headline: 'The household is below the reviewed programme threshold', + answered: 'Social Registry Office answered: household-below-poverty-threshold = true', notDisclosed: 'Predicate only, not poverty score or household roster', status: 'ok', ts: '2026-06-21T12:04:12.000Z', - request: { - method: 'POST', - url: 'https://sro-notary.solmara.example/v1/evaluations', - body: { - claim: 'household-below-poverty-threshold', - purpose: 'https://id.registrystack.org/solmara/purpose/child-benefit-review', - relationship: 'self' + purpose: 'child-benefit-review', + resultState: 'verified', + responseStatus: 200, + presentations: [ + { + authority: 'Social Registry Office', + issuer: 'did:web:id.registrystack.org:solmara:authority:sro', + serviceId: 'sro-evidence', + source: 'immutable extract' } - }, - response: { - status: 200, - body: { - satisfied: true, - source_authority: 'Social Registry Office', - as_of: '2026-04-15' - } - }, + ], proof: { - signedBy: 'Social Registry Office source-owned Notary', - algorithm: 'Authenticated claim-result evaluation; no credential signature asserted', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Portal-selected purpose and subject', - credential: 'Minimized predicate claim result', - auditId: 'Not available in this canned gallery trace' + signedBy: 'Social Registry Office issued the returned Evidence', + algorithm: 'Verified Evidence assertion', + issuerKey: 'Authority Evidence JWKS', + holderBound: 'Audience-scoped to the portal request', + credential: 'Minimum-disclosure Evidence assertion' } }, - - // 4. Denial (cross-person attempt) { id: 'event-4', seq: 4, fieldId: 'person-is-deceased', - authority: 'civil', - headline: - 'Denied by Civil Registry: subject mismatch, no data read for 2300073046', - answered: 'Civil answered: person-is-deceased = denied (subject_mismatch)', - notDisclosed: - 'No data was read; the query was rejected before any registry access', + headline: 'Denied by the portal: request was not authorized, no data read', + answered: 'Portal authorization gate returned 403 not_authorized', + notDisclosed: 'No source was contacted', status: 'denied', ts: '2026-06-21T12:04:15.000Z', - request: { - method: 'POST', - url: 'https://cra-notary.solmara.example/v1/evaluations', - body: { - claim: 'person-is-deceased', - purpose: 'https://id.registrystack.org/solmara/purpose/pension-payment-review', - relationship: 'self' - } - }, - response: { - status: 403, - body: { - error: 'subject_mismatch', - source_authority: 'Civil Registry', - message: 'Token subject does not match requested target' - } - } + purpose: 'pension-payment-review', + resultState: 'error', + responseStatus: 403, + presentations: [] }, - - // 5. Identity-binding (pinned to bottom as the foundation) { id: 'event-0', seq: 0, fieldId: 'identity', - authority: 'civil', - headline: - 'Identity bound via eSignet: session linked to 2300018263 (Elena Dela Cruz)', - answered: 'Civil answered: identity = bound (eSignet UserInfo)', - notDisclosed: - 'Only name and national ID were shared; no other civil facts disclosed', + headline: 'Identity bound through eSignet', + answered: 'eSignet bound the signed-in portal session', + notDisclosed: 'No additional identity attributes were shared', status: 'ok', ts: '2026-06-21T12:03:58.000Z', - request: { - method: 'POST', - url: 'https://esignet.solmara.example/v1/userinfo', - body: { - claim: 'identity', - purpose: 'session_binding', - relationship: 'self' - } - }, - response: { - status: 200, - body: { - sub: '2300018263', - name: 'Elena Dela Cruz', - source_authority: 'Civil Registry via eSignet', - as_of: '2026-06-21' - } - }, - proof: { - signedBy: 'No credential issued; eSignet UserInfo bound the portal session', - algorithm: 'OIDC UserInfo response; no credential signature asserted', - issuerKey: 'Not applicable for UserInfo', - holderBound: 'Portal session bound to the configured UserInfo subject claim', - credential: 'OIDC session identity, not a verifiable credential', - auditId: 'session-binding:event-0' - } + purpose: 'session-binding', + resultState: 'prefilled', + responseStatus: 200, + presentations: [] } ]; -// The identity-binding entry is always pinned to the bottom. export const IDENTITY_TRACE_ID = 'event-0'; diff --git a/portal/src/lib/providers/authority-plan.ts b/portal/src/lib/providers/authority-plan.ts index 925b71c..4c2d498 100644 --- a/portal/src/lib/providers/authority-plan.ts +++ b/portal/src/lib/providers/authority-plan.ts @@ -1,5 +1,7 @@ import { PURPOSES } from '$lib/forms/descriptors'; import type { ScenarioResult } from '$lib/providers/mock/scenarios'; +import type { AuthorityId } from '$lib/types'; +import { SOLMARA_AUTHORITIES } from '$lib/fields/authorities'; export type AuthorityClient = | 'craPension' @@ -10,17 +12,19 @@ export type AuthorityClient = export type AuthorityPlan = { client: AuthorityClient; + authorityId: AuthorityId; authority: string; serviceId: string; claimId: string; purpose: string; + source: 'immutable extract' | 'Relay lookup'; scheme?: string; }; /** * Resolve the exact authority-owned claims used by a portal field. This is the * shared plan for live and mock providers, so the mock cannot present a - * portal-composed decision as if one Notary produced it. + * portal-composed decision as if one Evidence service produced it. */ export function authorityPlan( scenarioKey: string, @@ -28,24 +32,30 @@ export function authorityPlan( ): AuthorityPlan[] { const craPension: AuthorityPlan = { client: 'craPension', + authorityId: 'civil', authority: 'Civil Registration Authority', - serviceId: 'cra-notary', + serviceId: SOLMARA_AUTHORITIES.civil.serviceId, claimId: 'person-is-deceased', - purpose: PURPOSES.pensionPaymentReview + purpose: PURPOSES.pensionPaymentReview, + source: 'Relay lookup' }; const sipfPayment: AuthorityPlan = { client: 'sipfPension', + authorityId: 'social', authority: 'Social Insurance and Pensions Fund', - serviceId: 'sipf-notary', + serviceId: SOLMARA_AUTHORITIES.social.serviceId, claimId: 'pension-payment-active', - purpose: PURPOSES.pensionPaymentReview + purpose: PURPOSES.pensionPaymentReview, + source: 'Relay lookup' }; const sipfSurvivor: AuthorityPlan = { client: 'sipfPension', + authorityId: 'social', authority: 'Social Insurance and Pensions Fund', - serviceId: 'sipf-notary', + serviceId: SOLMARA_AUTHORITIES.social.serviceId, claimId: 'survivor-is-eligible', - purpose: PURPOSES.survivorBenefitDetermination + purpose: PURPOSES.survivorBenefitDetermination, + source: 'Relay lookup' }; if (scenarioKey === 'disability-determination') return [craPension, sipfPayment]; @@ -60,17 +70,21 @@ export function authorityPlan( return [ { client: 'craCitizen', + authorityId: 'civil', authority: 'Civil Registration Authority', - serviceId: 'cra-notary', + serviceId: SOLMARA_AUTHORITIES.civil.serviceId, claimId: 'civil-record-linked', - purpose: PURPOSES.citizenSelfService + purpose: PURPOSES.citizenSelfService, + source: 'Relay lookup' }, { client: 'niaCitizen', + authorityId: 'population', authority: 'National Identity Agency', - serviceId: 'nia-notary', + serviceId: SOLMARA_AUTHORITIES.population.serviceId, claimId: 'citizen-population-record-active', - purpose: PURPOSES.citizenSelfService + purpose: PURPOSES.citizenSelfService, + source: 'immutable extract' } ]; } @@ -78,15 +92,17 @@ export function authorityPlan( return [ { client: 'nagdi', + authorityId: 'agri', authority: 'National Agricultural Data Institute', - serviceId: 'nagdi-notary', + serviceId: SOLMARA_AUTHORITIES.agri.serviceId, claimId: scenario.claimId, purpose: scenario.purpose, + source: 'Relay lookup', scheme: 'farmer_id' } ]; } - if (scenario.notary === 'civil') return [craPension]; + if (scenario.authority === 'civil') return [craPension]; if (scenario.service === 'pension') return [sipfSurvivor]; throw new Error(`No authority route for scenario "${scenarioKey}"`); } diff --git a/portal/src/lib/providers/feeds.svelte.test.ts b/portal/src/lib/providers/feeds.svelte.test.ts index e1ecd14..a05fd7b 100644 --- a/portal/src/lib/providers/feeds.svelte.test.ts +++ b/portal/src/lib/providers/feeds.svelte.test.ts @@ -13,15 +13,8 @@ function trace(id: string): ProofTrace { notDisclosed: 'Not disclosed: any other civil record detail', status: 'ok', ts: '2026-06-22T12:00:00.000Z', - request: { - method: 'POST', - url: 'https://civil-notary.gov.solmara.example/v1/evaluations', - body: { claim: 'person-is-deceased' } - }, - response: { - status: 200, - body: { result: true } - } + resultState: 'verified', + presentations: [] }; } diff --git a/portal/src/lib/providers/live/index.test.ts b/portal/src/lib/providers/live/index.test.ts index fd49e3f..daa081f 100644 --- a/portal/src/lib/providers/live/index.test.ts +++ b/portal/src/lib/providers/live/index.test.ts @@ -1,793 +1,109 @@ import { describe, expect, it, vi } from 'vitest'; -import { LiveEvidenceProvider } from '.'; -import { PURPOSES } from '$lib/forms/descriptors'; -import { CLAIM_RESULT_FORMAT } from '$lib/providers/mock/wire'; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' } - }); -} - -function serviceIdFor(claimId: string): string { - if (claimId === 'citizen-population-record-active') return 'nia-notary'; - if (claimId === 'pension-payment-active' || claimId === 'survivor-is-eligible') { - return 'sipf-notary'; - } - if (claimId.startsWith('farmer-')) return 'nagdi-notary'; - return 'cra-notary'; -} - -function evaluationResult(claimId: string, satisfied: boolean): Record { - const evaluationId = `eval-${claimId}`; - const claimVersion = '1'; - return { - claim_id: claimId, - claim_version: claimVersion, - subject_type: 'person', - target_ref: { handle: 'rnref:v1:test-target' }, - value: satisfied, - satisfied, - disclosure: 'predicate', - evaluation_id: evaluationId, - format: CLAIM_RESULT_FORMAT, - issued_at: '2026-07-15T00:00:00Z', - expires_at: null, - provenance: { - schema_version: 'registry-notary-claim-provenance/v2', - generated_by: { - type: 'claim_evaluation', - service_id: serviceIdFor(claimId), - evaluation_id: evaluationId, - claim_id: claimId, - claim_version: claimVersion - }, - used: { relay_consultation_count: 1 }, - derived_from: [] - } - }; -} - -function evaluationResponse(claimId: string, satisfied: boolean): Response { - return jsonResponse({ - results: [evaluationResult(claimId, satisfied)] - }); -} - -function fetchInit(fetcher: ReturnType, callIndex: number): RequestInit { - const init: unknown = fetcher.mock.calls[callIndex]?.[1]; - if (typeof init !== 'object' || init === null) throw new Error('missing fetch init'); - // Vitest stores the RequestInit object as the second recorded fetch argument. - return init as RequestInit; -} - -describe('LiveEvidenceProvider', () => { - it('calls the CRA Notary directly with its pension-purpose token and server-selected subject', async () => { - const fetcher = vi.fn().mockResolvedValue(evaluationResponse('person-is-deceased', true)); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300018263', selectedSubject: '2300109568' } - ); - - expect(fetcher).toHaveBeenCalledTimes(1); - expect(fetcher.mock.calls[0][0]).toBe( - 'https://cra-notary.solmara.registrystack.org/v1/evaluations' - ); - const init = fetchInit(fetcher, 0); - expect(init.headers).toEqual({ - 'x-api-key': 'cra-pension-token', - Accept: CLAIM_RESULT_FORMAT, - 'Data-Purpose': PURPOSES.pensionPaymentReview, - 'Content-Type': 'application/json' - }); - expect(JSON.parse(String(init.body))).toEqual({ - claims: ['person-is-deceased'], - purpose: PURPOSES.pensionPaymentReview, - disclosure: 'predicate', - format: CLAIM_RESULT_FORMAT, - relationship: { type: 'self' }, - target: { - type: 'Person', - identifiers: [{ scheme: 'solmara_uin', value: '2300109568' }] - } - }); - expect(evaluation.result.display).toBe('Death registered: yes'); - expect(JSON.stringify(fetcher.mock.calls)).not.toContain('relay'); - }); - - it('fails closed when a successful authority response omits the predicate decision', async () => { - const fetcher = vi.fn().mockResolvedValue( - jsonResponse({ - results: [ - { - claim_id: 'person-is-deceased', - claim_version: '1', - disclosure: 'predicate', - evaluation_id: 'eval-malformed', - issued_at: '2026-07-15T00:00:00Z' +import { LiveEvidenceProvider } from './index'; + +const ctx = { subject: '2300018263', delegatedTarget: '2300010248' }; + +function envelope(results: Array<{ claim_id: string; satisfied: boolean; value?: unknown }>, extra: Record = {}) { + return new Response( + JSON.stringify({ + result: { + response_source: { + status: 200, + body: { + results: results.map((item) => ({ ...item, value: item.value ?? item.satisfied })), + signed_evidence: [{ protected: 'e30', payload: 'e30', signature: 'c2ln' }] } - ] - }) - ); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.state).toBe('error'); - expect(evaluation.proof.status).toBe('error'); - expect(evaluation.result.asOf).toBeUndefined(); - }); - - for (const requiredField of [ - 'claim_version', - 'evaluation_id', - 'issued_at', - 'format', - 'provenance' - ]) { - it(`rejects a direct Notary result missing ${requiredField} without filling it locally`, async () => { - const result = evaluationResult('person-is-deceased', true); - delete result[requiredField]; - const fetcher = vi.fn().mockResolvedValue(jsonResponse({ results: [result] })); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.state).toBe('error'); - expect(evaluation.proof.status).toBe('error'); - expect(evaluation.raw.response.body).toEqual({ results: [] }); - expect(evaluation.result.asOf).toBeUndefined(); - }); - } - - it('rejects direct provenance that does not bind to the expected authority and result', async () => { - const result = evaluationResult('person-is-deceased', true); - // The fixture builder always installs this provenance object before the test mutates it. - const provenance = result.provenance as { - generated_by: { service_id: string; evaluation_id: string }; - }; - provenance.generated_by.service_id = 'nia-notary'; - provenance.generated_by.evaluation_id = 'eval-other'; - const fetcher = vi.fn().mockResolvedValue(jsonResponse({ results: [result] })); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.state).toBe('error'); - expect(evaluation.proof.status).toBe('error'); - expect(evaluation.raw.response.body).toEqual({ results: [] }); - }); - - it('does not present an expired direct claim result as verified', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-15T12:00:00Z')); - try { - const result = evaluationResult('person-is-deceased', true); - result.issued_at = '2026-07-10T00:00:00Z'; - result.expires_at = '2026-07-14T00:00:00Z'; - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token' }, - vi.fn().mockResolvedValue(jsonResponse({ results: [result] })) - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result).toMatchObject({ - state: 'stale', - display: 'person-is-deceased: expired evidence, refresh required', - asOf: '2026-07-10T00:00:00Z' - }); - expect(evaluation.proof.status).toBe('error'); - } finally { - vi.useRealTimers(); - } - }); - - it('derives the pension-stop decision in the portal from independent CRA and SIPF claims', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(evaluationResponse('person-is-deceased', true)) - .mockResolvedValueOnce(evaluationResponse('pension-payment-active', true)); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token', - SIPF_NOTARY_URL: 'https://sipf-notary.solmara.registrystack.org', - SIPF_PENSION_CLIENT_TOKEN: 'sipf-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'disability-determination', label: 'Stop pension?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(fetcher).toHaveBeenCalledTimes(2); - expect(fetcher.mock.calls.map((call) => call[0])).toEqual([ - 'https://cra-notary.solmara.registrystack.org/v1/evaluations', - 'https://sipf-notary.solmara.registrystack.org/v1/evaluations' - ]); - const requests = fetcher.mock.calls.map((_call, index) => - JSON.parse(String(fetchInit(fetcher, index).body)) - ); - expect(requests.map((request) => request.claims)).toEqual([ - ['person-is-deceased'], - ['pension-payment-active'] - ]); - expect(JSON.stringify(requests)).not.toContain('pension-payment-should-stop'); - expect(evaluation.result.display).toBe('Pension payment should stop: yes'); - expect(evaluation.result.authority).toBeUndefined(); - expect(evaluation.proof.authority).toBeUndefined(); - expect(evaluation.raw.request.method).toBe('MULTI'); - expect(evaluation.raw.response.body).toMatchObject({ - orchestration: { service_id: 'citizen-portal', decision: 'application_composed' }, - derived_decisions: { 'pension-payment-should-stop': true }, - source_trace: [ - { service_id: 'cra-notary', claims: ['person-is-deceased'] }, - { service_id: 'sipf-notary', claims: ['pension-payment-active'] } - ] - }); - }); - - it('uses the oldest source timestamp for a composed decision', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-15T12:00:00Z')); - try { - const civil = evaluationResult('person-is-deceased', true); - civil.issued_at = '2026-07-14T00:00:00Z'; - civil.expires_at = '2026-08-14T00:00:00Z'; - const payment = evaluationResult('pension-payment-active', true); - payment.issued_at = '2026-07-10T00:00:00Z'; - payment.expires_at = '2026-08-10T00:00:00Z'; - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse({ results: [civil] })) - .mockResolvedValueOnce(jsonResponse({ results: [payment] })); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token', - SIPF_NOTARY_URL: 'https://sipf-notary.solmara.registrystack.org', - SIPF_PENSION_CLIENT_TOKEN: 'sipf-pension-token' + presentation: { + authority: 'National Agricultural Data Institute', + issuer: 'did:web:id.registrystack.org:solmara:authority:nagdi', + provider: 'https://nagdi-evidence.solmara.registrystack.org', + source: 'Relay lookup' }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'disability-determination', label: 'Stop pension?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.asOf).toBe('2026-07-10T00:00:00Z'); - expect(evaluation.result.state).toBe('verified'); - } finally { - vi.useRealTimers(); - } - }); - - it('does not compose a positive decision when any source result is expired', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-15T12:00:00Z')); - try { - const civil = evaluationResult('person-is-deceased', true); - civil.issued_at = '2026-07-10T00:00:00Z'; - civil.expires_at = '2026-07-14T00:00:00Z'; - const payment = evaluationResult('pension-payment-active', true); - payment.issued_at = '2026-07-14T00:00:00Z'; - payment.expires_at = '2026-08-14T00:00:00Z'; - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token', - SIPF_NOTARY_URL: 'https://sipf-notary.solmara.registrystack.org', - SIPF_PENSION_CLIENT_TOKEN: 'sipf-pension-token' - }, - vi - .fn() - .mockResolvedValueOnce(jsonResponse({ results: [civil] })) - .mockResolvedValueOnce(jsonResponse({ results: [payment] })) - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'disability-determination', label: 'Stop pension?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.state).toBe('stale'); - expect(evaluation.proof.status).toBe('error'); - expect(evaluation.raw.response.body).toMatchObject({ - derived_decisions: { 'pension-payment-should-stop': null } - }); - } finally { - vi.useRealTimers(); - } - }); - - it('shows the direct SIPF payment predicate without inventing a household conflict result', async () => { - const fetcher = vi.fn().mockResolvedValue(evaluationResponse('pension-payment-active', true)); - const provider = new LiveEvidenceProvider( - { - SIPF_NOTARY_URL: 'https://sipf-notary.solmara.registrystack.org', - SIPF_PENSION_CLIENT_TOKEN: 'sipf-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'pension-payment-active', label: 'Pension payment active?', kind: 'verify' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.display).toBe('Pension payment active: yes'); - expect(JSON.stringify(evaluation)).not.toMatch(/conflict|household-size/); - }); - - it('derives only a boolean survivor decision and never invents a support band', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(evaluationResponse('person-is-deceased', true)) - .mockResolvedValueOnce(evaluationResponse('pension-payment-active', true)) - .mockResolvedValueOnce(evaluationResponse('survivor-is-eligible', true)); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token', - SIPF_NOTARY_URL: 'https://sipf-notary.solmara.registrystack.org', - SIPF_PENSION_CLIENT_TOKEN: 'sipf-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'combined-support-eligibility', label: 'Eligibility decision', kind: 'decision' }, - { subject: '2300109568' } - ); - - expect(evaluation.result.display).toBe('Survivor benefit eligible: yes'); - expect(evaluation.raw.response.body).toMatchObject({ - derived_decisions: { 'survivor-benefit-eligible': true } - }); - expect(JSON.stringify(evaluation)).not.toMatch(/support.band|band B/i); - }); - - it('routes survivor evidence to SIPF under the survivor-benefit purpose', async () => { - const fetcher = vi.fn().mockResolvedValue(evaluationResponse('survivor-is-eligible', true)); - const provider = new LiveEvidenceProvider( - { - SIPF_NOTARY_URL: 'https://sipf-notary.solmara.registrystack.org', - SIPF_PENSION_CLIENT_TOKEN: 'sipf-pension-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'functioning-assessment', label: 'Survivor eligible?', kind: 'verify' }, - { subject: '2300118698' } - ); - - expect(fetcher.mock.calls[0][0]).toBe( - 'https://sipf-notary.solmara.registrystack.org/v1/evaluations' - ); - expect(fetchInit(fetcher, 0).headers).toMatchObject({ - 'x-api-key': 'sipf-pension-token', - 'Data-Purpose': PURPOSES.survivorBenefitDetermination - }); - expect(evaluation.proof.crypto).toMatchObject({ - issuerKey: 'Not applicable for claim-result evaluation', - credential: 'Claim results only; no credential issued by the portal' - }); - }); - - it('keeps CRA and NIA citizen evidence separate and names NIA as credential owner', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(evaluationResponse('civil-record-linked', true)) - .mockResolvedValueOnce(evaluationResponse('citizen-population-record-active', true)); - const provider = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_CITIZEN_CLIENT_TOKEN: 'cra-citizen-token', - NIA_NOTARY_URL: 'https://nia-notary.solmara.registrystack.org', - NIA_CITIZEN_CLIENT_TOKEN: 'nia-citizen-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'citizen-record-status', label: 'Citizen record status', kind: 'verify' }, - { subject: '2300018263' } - ); - - expect(fetcher.mock.calls.map((call) => call[0])).toEqual([ - 'https://cra-notary.solmara.registrystack.org/v1/evaluations', - 'https://nia-notary.solmara.registrystack.org/v1/evaluations' - ]); - expect(fetcher.mock.calls.map((_call, index) => fetchInit(fetcher, index).headers)).toEqual([ - { - 'x-api-key': 'cra-citizen-token', - Accept: CLAIM_RESULT_FORMAT, - 'Data-Purpose': PURPOSES.citizenSelfService, - 'Content-Type': 'application/json' - }, - { - 'x-api-key': 'nia-citizen-token', - Accept: CLAIM_RESULT_FORMAT, - 'Data-Purpose': PURPOSES.citizenSelfService, - 'Content-Type': 'application/json' + ...extra } - ]); - expect(evaluation.result.display).toBe('Civil and population records active: yes'); - expect(evaluation.proof.crypto).toMatchObject({ - issuerKey: 'Not applicable for claim-result evaluation', - credential: 'Application decision only; no credential issued by the portal' - }); - expect(JSON.stringify(evaluation)).not.toMatch(/CSR-BIRTH|certificate_id|issued_on/); - }); - - it('denies delegated reads before configuration lookup or any upstream call', async () => { - const fetcher = vi.fn(); - const provider = new LiveEvidenceProvider({}, fetcher); - - const evaluation = await provider.evaluateDetailed( - { id: 'date-of-birth', label: 'Child age under 5', kind: 'verify' }, - { subject: '2300018263', delegatedTarget: '2300010248' } - ); - - expect(fetcher).not.toHaveBeenCalled(); - expect(evaluation.raw.request.url).toBe( - 'solmara://citizen-portal/blocked-before-authority-call' - ); - expect(evaluation.raw.response.status).toBe(403); - expect(evaluation.result.reasonCode).toBe('relationship_not_proven'); - }); - - it('uses the child application ordinary JSON evidence contract after the guardian gate', async () => { - const fetcher = vi.fn().mockResolvedValue( - jsonResponse({ - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: 'cbe_live_1', - orchestration: { - service_id: 'child-benefit-federator', - decision: 'not_composed' - }, - purpose: PURPOSES.childBenefitReview, - target: { type: 'Person', identifier_schemes: ['solmara_uin'] }, - results: [ - { - claim_id: 'child-age-under-5', - claim_version: '1', - disclosure: 'predicate', - format: CLAIM_RESULT_FORMAT, - issued_at: '2026-07-15T00:00:00Z', - expires_at: '2026-08-14T00:00:00Z', - satisfied: true, - authority: 'Civil Registration Authority', - notary_service_id: 'cra-notary' - } - ], - source_trace: [ - { - authority: 'Civil Registration Authority', - service_id: 'cra-notary', - claims: ['child-age-under-5'] - } - ] - }) - ); - const provider = new LiveEvidenceProvider( - { - CHILD_BENEFIT_FEDERATOR_URL: - 'https://child-benefit-federator.solmara.registrystack.org', - CHILD_BENEFIT_FEDERATOR_TOKEN: 'child-token' - }, - fetcher - ); + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); +} +describe('LiveEvidenceProvider', () => { + it('routes reviewed fields through the scenario runner without overriding its purpose', async () => { + const fetcher = vi.fn(async () => envelope([{ claim_id: 'farmer-registered', satisfied: true }])) as unknown as typeof fetch; + const provider = new LiveEvidenceProvider({ SCENARIO_RUNNER_URL: 'http://scenario-runner:8080' }, fetcher); const evaluation = await provider.evaluateDetailed( - { id: 'date-of-birth', label: 'Child age under 5', kind: 'verify' }, - { subject: '2300018263', delegatedTarget: '2300010248' }, - { guardianLinkVerified: true } + { id: 'registered-farmer', label: 'Registered farmer?', kind: 'verify' }, + ctx ); expect(fetcher).toHaveBeenCalledTimes(1); - expect(fetcher.mock.calls[0][0]).toBe( - 'https://child-benefit-federator.solmara.registrystack.org/v1/evaluations' - ); - const init = fetchInit(fetcher, 0); - expect(init.headers).toEqual({ - 'x-api-key': 'child-token', - Accept: 'application/json', - 'Data-Purpose': PURPOSES.childBenefitReview, - 'Content-Type': 'application/json' - }); - expect(JSON.parse(String(init.body))).toEqual({ - target: { - type: 'Person', - identifiers: [{ scheme: 'solmara_uin', value: '2300010248' }] - }, - claims: ['child-age-under-5'], - disclosure: 'predicate', - format: 'application/json', - variables: { as_of_date: '2026-01-15' } - }); - expect(evaluation.raw.response.body).toMatchObject({ - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: 'cbe_live_1', - orchestration: { decision: 'not_composed' }, - source_trace: [{ service_id: 'cra-notary' }] - }); - expect(evaluation.proof.crypto).toMatchObject({ - algorithm: 'Ordinary JSON response; no application signature asserted', - credential: 'Minimized source-attributed predicate result', - auditId: 'evidence-set:cbe_live_1' - }); - expect(JSON.stringify(evaluation.proof.crypto)).not.toMatch(/federated|federation/); - expect(JSON.stringify(evaluation.proof.crypto)).not.toContain('SD-JWT'); + const [url, init] = vi.mocked(fetcher).mock.calls[0]; + expect(String(url)).toContain('/v1/scenarios/farmer-climate-smart-voucher/steps/positive/run'); + expect(JSON.parse(String(init?.body))).toEqual({}); + expect(evaluation.result.state).toBe('verified'); + expect(evaluation.proof.crypto.algorithm).toBe('Verified Evidence assertion'); + expect(JSON.stringify(evaluation)).not.toContain('x-api-key'); }); - it('rejects a child predicate that lacks matching source attribution', async () => { - const fetcher = vi.fn().mockResolvedValue( - jsonResponse({ - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: 'cbe_unattributed', - orchestration: { - service_id: 'child-benefit-federator', - decision: 'not_composed' - }, - purpose: PURPOSES.childBenefitReview, - target: { type: 'Person', identifier_schemes: ['solmara_uin'] }, - results: [ - { - claim_id: 'child-age-under-5', - claim_version: '1', - disclosure: 'predicate', - format: CLAIM_RESULT_FORMAT, - issued_at: '2026-07-15T00:00:00Z', - expires_at: null, - satisfied: true, - authority: 'Civil Registration Authority', - notary_service_id: 'cra-notary' - } - ], - source_trace: [] - }) - ); - const provider = new LiveEvidenceProvider( - { - CHILD_BENEFIT_FEDERATOR_URL: - 'https://child-benefit-federator.solmara.registrystack.org', - CHILD_BENEFIT_FEDERATOR_TOKEN: 'child-token' - }, - fetcher - ); - + it('blocks a dependent read until the relationship is proven', async () => { + const fetcher = vi.fn() as unknown as typeof fetch; + const provider = new LiveEvidenceProvider({ SCENARIO_RUNNER_URL: 'http://scenario-runner:8080' }, fetcher); const evaluation = await provider.evaluateDetailed( { id: 'date-of-birth', label: 'Child age under 5', kind: 'verify' }, - { subject: '2300018263', delegatedTarget: '2300010248' }, - { guardianLinkVerified: true } - ); - - expect(evaluation.result.state).toBe('error'); - expect(evaluation.proof.status).toBe('error'); - expect(evaluation.result.display).toBe('child-age-under-5: not returned'); - expect(evaluation.result.asOf).toBeUndefined(); - }); - - it('does not present expired child application evidence as verified', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-15T12:00:00Z')); - try { - const fetcher = vi.fn().mockResolvedValue( - jsonResponse({ - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: 'cbe_expired', - orchestration: { - service_id: 'child-benefit-federator', - decision: 'not_composed' - }, - purpose: PURPOSES.childBenefitReview, - target: { type: 'Person', identifier_schemes: ['solmara_uin'] }, - results: [ - { - claim_id: 'child-age-under-5', - claim_version: '1', - disclosure: 'predicate', - format: CLAIM_RESULT_FORMAT, - issued_at: '2026-07-10T00:00:00Z', - expires_at: '2026-07-14T00:00:00Z', - satisfied: true, - authority: 'Civil Registration Authority', - notary_service_id: 'cra-notary' - } - ], - source_trace: [ - { - authority: 'Civil Registration Authority', - service_id: 'cra-notary', - claims: ['child-age-under-5'] - } - ] - }) - ); - const provider = new LiveEvidenceProvider( - { - CHILD_BENEFIT_FEDERATOR_URL: - 'https://child-benefit-federator.solmara.registrystack.org', - CHILD_BENEFIT_FEDERATOR_TOKEN: 'child-token' - }, - fetcher - ); - - const evaluation = await provider.evaluateDetailed( - { id: 'date-of-birth', label: 'Child age under 5', kind: 'verify' }, - { subject: '2300018263', delegatedTarget: '2300010248' }, - { guardianLinkVerified: true } - ); - - expect(evaluation.result.state).toBe('stale'); - expect(evaluation.result.display).toBe('child-age-under-5: expired evidence, refresh required'); - expect(evaluation.proof.status).toBe('error'); - } finally { - vi.useRealTimers(); - } - }); - - it('rejects internally consistent child attribution from the wrong authority', async () => { - const fetcher = vi.fn().mockResolvedValue( - jsonResponse({ - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: 'cbe_wrong_authority', - orchestration: { - service_id: 'child-benefit-federator', - decision: 'not_composed' - }, - purpose: PURPOSES.childBenefitReview, - target: { type: 'Person', identifier_schemes: ['solmara_uin'] }, - results: [ - { - claim_id: 'child-age-under-5', - claim_version: '1', - disclosure: 'predicate', - format: CLAIM_RESULT_FORMAT, - issued_at: '2026-07-15T00:00:00Z', - expires_at: null, - satisfied: true, - authority: 'National Identity Agency', - notary_service_id: 'nia-notary' - } - ], - source_trace: [ - { - authority: 'National Identity Agency', - service_id: 'nia-notary', - claims: ['child-age-under-5'] - } - ] - }) - ); - const provider = new LiveEvidenceProvider( - { - CHILD_BENEFIT_FEDERATOR_URL: - 'https://child-benefit-federator.solmara.registrystack.org', - CHILD_BENEFIT_FEDERATOR_TOKEN: 'child-token' - }, - fetcher + ctx ); + expect(fetcher).not.toHaveBeenCalled(); + expect(evaluation.proof.status).toBe('denied'); + expect(evaluation.raw.request.url).toBe('solmara://citizen-portal/blocked-before-evidence'); + }); + + it('selects one concept from a signed child-benefit assertion set', async () => { + const fetcher = vi.fn(async () => envelope([ + { claim_id: 'birth-is-registered', satisfied: true }, + { claim_id: 'child-age-under-5', satisfied: true }, + { claim_id: 'population-record-active', satisfied: true }, + { claim_id: 'household-below-poverty-threshold', satisfied: true }, + { claim_id: 'not-already-enrolled', satisfied: true } + ])) as unknown as typeof fetch; + const provider = new LiveEvidenceProvider({ SCENARIO_RUNNER_URL: 'http://scenario-runner:8080' }, fetcher); const evaluation = await provider.evaluateDetailed( { id: 'date-of-birth', label: 'Child age under 5', kind: 'verify' }, - { subject: '2300018263', delegatedTarget: '2300010248' }, + ctx, { guardianLinkVerified: true } ); - expect(evaluation.result.state).toBe('error'); - expect(evaluation.proof.status).toBe('error'); - expect(evaluation.result.display).toBe('child-age-under-5: not returned'); - expect(evaluation.result.asOf).toBeUndefined(); + expect(evaluation.result.state).toBe('verified'); + expect(evaluation.proof.answered).toContain('child-age-under-5 = true'); }); - it('returns the server-owned cross-person denial without an upstream call', async () => { - const fetcher = vi.fn(); - const provider = new LiveEvidenceProvider({}, fetcher); - + it('combines separate pension and survivor Evidence steps in the portal application', async () => { + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/stop-payment/')) { + return envelope([ + { claim_id: 'person-is-deceased', satisfied: true }, + { claim_id: 'pension-payment-active', satisfied: true } + ]); + } + return envelope([{ claim_id: 'survivor-is-eligible', satisfied: true }]); + }) as unknown as typeof fetch; + const provider = new LiveEvidenceProvider({ SCENARIO_RUNNER_URL: 'http://scenario-runner:8080' }, fetcher); const evaluation = await provider.evaluateDetailed( - { id: 'denial', label: 'Cross-person denial', kind: 'verify' }, - { subject: '2300018263' }, - { scenarioKey: 'denial' } + { id: 'combined-support-eligibility', label: 'Eligibility', kind: 'decision' }, + ctx ); - expect(fetcher).not.toHaveBeenCalled(); - expect(evaluation.result.reasonCode).toBe('subject_mismatch'); + expect(fetcher).toHaveBeenCalledTimes(2); expect(evaluation.result.authority).toBeUndefined(); - expect(evaluation.proof.authority).toBeUndefined(); - expect(evaluation.proof.headline).toBe( - 'Portal denied the cross-person request before any authority call' - ); - expect(evaluation.proof.answered).toContain('before any authority call'); - expect(evaluation.raw.response.status).toBe(403); + expect(evaluation.result.state).toBe('verified'); + expect(evaluation.raw.request.method).toBe('MULTI'); }); - it('requires the authority endpoint and purpose-specific token only when evaluated', async () => { - const missingUrl = new LiveEvidenceProvider( - { CRA_PENSION_CLIENT_TOKEN: 'cra-pension-token' }, - vi.fn() - ); - await expect( - missingUrl.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300018263' } - ) - ).rejects.toThrow('CRA_NOTARY_URL is required'); - - const missingToken = new LiveEvidenceProvider( - { CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org' }, - vi.fn() - ); - await expect( - missingToken.evaluateDetailed( - { id: 'person-is-alive', label: 'Alive?', kind: 'verify' }, - { subject: '2300018263' } - ) - ).rejects.toThrow('CRA_PENSION_CLIENT_TOKEN is required'); - - const partialFetcher = vi.fn(); - const incompleteCitizen = new LiveEvidenceProvider( - { - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_CITIZEN_CLIENT_TOKEN: 'cra-citizen-token', - NIA_NOTARY_URL: 'https://nia-notary.solmara.registrystack.org' - }, - partialFetcher - ); + it('requires only the scenario runner URL when a live call is made', async () => { + const provider = new LiveEvidenceProvider({}, vi.fn() as unknown as typeof fetch); await expect( - incompleteCitizen.evaluateDetailed( - { id: 'citizen-record-status', label: 'Citizen record status', kind: 'verify' }, - { subject: '2300018263' } - ) - ).rejects.toThrow('NIA_CITIZEN_CLIENT_TOKEN is required'); - expect(partialFetcher).not.toHaveBeenCalled(); + provider.evaluateDetailed({ id: 'registered-farmer', label: 'Registered farmer?', kind: 'verify' }, ctx) + ).rejects.toThrow('SCENARIO_RUNNER_URL is required'); }); }); diff --git a/portal/src/lib/providers/live/index.ts b/portal/src/lib/providers/live/index.ts index cecfc43..c5326bb 100644 --- a/portal/src/lib/providers/live/index.ts +++ b/portal/src/lib/providers/live/index.ts @@ -1,343 +1,182 @@ import type { EvaluateContext, DetailedEvidenceProvider } from '$lib/providers/EvidenceProvider'; -import type { ClaimResult, Field, ProofTrace } from '$lib/types'; -import { - SCENARIOS, - PERSONA, - type EvaluateOptions, - type MockEvaluation -} from '$lib/providers/mock'; -import { AUTHORITY_LABEL, NOTARY_SERVICE_ID } from '$lib/providers/mock/scenarios'; -import { - authorityPlan, - isApplicationOwnedPlan, - type AuthorityClient, - type AuthorityPlan -} from '$lib/providers/authority-plan'; -import { - authorityLabel, - buildChildBenefitRequest, - buildEvaluationRequest, - buildRawRequest, - CLAIM_RESULT_FORMAT, - makeEvaluationId, - type ClaimResultView, - type RawApplicationRequest, - type RawApplicationResponse, - type RawChildBenefitResponse, - type RawEvaluateRequest, - type RawEvaluationResponse, - type RawProviderResponse -} from '$lib/providers/mock/wire'; +import type { ClaimResult, EvidencePresentation, EvidenceSource, Field, ProofTrace } from '$lib/types'; +import { SCENARIOS, type EvaluateOptions, type MockEvaluation } from '$lib/providers/mock'; +import { evidencePresentation, SOLMARA_AUTHORITIES } from '$lib/fields/authorities'; +import { authorityPlan, isApplicationOwnedPlan } from '$lib/providers/authority-plan'; export type LiveProviderEnv = { - CHILD_BENEFIT_FEDERATOR_URL?: string; - CHILD_BENEFIT_FEDERATOR_TOKEN?: string; - CRA_NOTARY_URL?: string; - CRA_PENSION_CLIENT_TOKEN?: string; - CRA_CITIZEN_CLIENT_TOKEN?: string; - NIA_NOTARY_URL?: string; - NIA_CITIZEN_CLIENT_TOKEN?: string; - SIPF_NOTARY_URL?: string; - SIPF_PENSION_CLIENT_TOKEN?: string; - NAGDI_NOTARY_URL?: string; - NAGDI_NOTARY_TOKEN?: string; + SCENARIO_RUNNER_URL?: string; }; type Fetcher = typeof fetch; -type LiveClient = 'childBenefit' | AuthorityClient; -type LiveServiceRef = { url?: string; token?: string; urlEnv: string; tokenEnv: string }; -type DenialBody = { error: string; error_description: string }; -type AuthorityEvaluation = { - plan: AuthorityPlan; - url: string; - request: RawEvaluateRequest; - status: number; - response: RawEvaluationResponse; -}; -type ConfiguredService = { url: string; token: string }; -type EvaluationOutcome = { - hasResult: boolean; - satisfied: boolean | null; - value: unknown; - stale: boolean; - issuedAt?: string; - derivedDecisions?: Record; +type Dict = Record; +type RunnerResult = { + friendly?: { status?: string; title?: string; message?: string }; + request_source?: Dict; + request_sources?: Dict[]; + response_source?: { status?: number | null; body?: unknown; error?: string }; + source_trace?: Dict[]; + derived_decisions?: Record; + results?: Dict[]; + presentation?: unknown; + presentations?: unknown[]; }; +type RunnerCall = { scenarioId: string; stepId: string }; +/** + * The live portal delegates scenario execution to the server-side scenario + * runner. That runner owns Mint authentication and calls authority Evidence; + * browser-controlled input can select only a reviewed portal field. + */ export class LiveEvidenceProvider implements DetailedEvidenceProvider { #seq = 0; - #services: Record; + #runnerUrl: string | undefined; #fetch: Fetcher; constructor(env: LiveProviderEnv, fetcher: Fetcher = fetch) { - this.#services = { - childBenefit: { - url: env.CHILD_BENEFIT_FEDERATOR_URL, - token: env.CHILD_BENEFIT_FEDERATOR_TOKEN, - urlEnv: 'CHILD_BENEFIT_FEDERATOR_URL', - tokenEnv: 'CHILD_BENEFIT_FEDERATOR_TOKEN' - }, - craPension: { - url: env.CRA_NOTARY_URL, - token: env.CRA_PENSION_CLIENT_TOKEN, - urlEnv: 'CRA_NOTARY_URL', - tokenEnv: 'CRA_PENSION_CLIENT_TOKEN' - }, - craCitizen: { - url: env.CRA_NOTARY_URL, - token: env.CRA_CITIZEN_CLIENT_TOKEN, - urlEnv: 'CRA_NOTARY_URL', - tokenEnv: 'CRA_CITIZEN_CLIENT_TOKEN' - }, - niaCitizen: { - url: env.NIA_NOTARY_URL, - token: env.NIA_CITIZEN_CLIENT_TOKEN, - urlEnv: 'NIA_NOTARY_URL', - tokenEnv: 'NIA_CITIZEN_CLIENT_TOKEN' - }, - sipfPension: { - url: env.SIPF_NOTARY_URL, - token: env.SIPF_PENSION_CLIENT_TOKEN, - urlEnv: 'SIPF_NOTARY_URL', - tokenEnv: 'SIPF_PENSION_CLIENT_TOKEN' - }, - nagdi: { - url: env.NAGDI_NOTARY_URL, - token: env.NAGDI_NOTARY_TOKEN, - urlEnv: 'NAGDI_NOTARY_URL', - tokenEnv: 'NAGDI_NOTARY_TOKEN' - } - }; + this.#runnerUrl = env.SCENARIO_RUNNER_URL; this.#fetch = fetcher; } async evaluate(field: Field, ctx: EvaluateContext, opts?: EvaluateOptions): Promise { - const evaluation = await this.evaluateDetailed(field, ctx, opts); - return evaluation.result; + return (await this.evaluateDetailed(field, ctx, opts)).result; } async evaluateDetailed( field: Field, - ctx: EvaluateContext, + _ctx: EvaluateContext, opts?: EvaluateOptions ): Promise { const scenarioKey = opts?.scenarioKey ?? field.id; const scenario = SCENARIOS[scenarioKey]; - if (!scenario) { - throw new Error(`LiveEvidenceProvider: no scenario mapping for field "${field.id}"`); - } + if (!scenario) throw new Error(`LiveEvidenceProvider: no scenario mapping for field "${field.id}"`); - if (scenarioKey === 'denial') { - return this.#denied(scenarioKey, scenario, ctx, 'subject_mismatch'); - } + if (scenarioKey === 'denial') return this.#blocked(field, scenario, 'not_authorized'); if (scenario.delegated && opts?.guardianLinkVerified !== true) { - return this.#denied(scenarioKey, scenario, ctx, 'relationship_not_proven'); + return this.#blocked(field, scenario, 'relationship_not_proven'); } + const calls = runnerCalls(scenarioKey, scenario.service); + const responses = await Promise.all(calls.map((call) => this.#run(call))); + const status = responses.find((response) => !isSuccess(response.response_source?.status))?.response_source?.status ?? 200; + const results = responses.flatMap((response) => responseResults(response)); + const presentations = presentationsFor(scenarioKey, scenario, responses); + const outcome = outcomeFor(scenarioKey, scenario.claimId, results, responses); + const proofStatus = status === 403 ? 'denied' : !isSuccess(status) || !outcome.found ? 'error' : outcome.satisfied === false ? 'false' : 'ok'; const seq = ++this.#seq; - const subject = resolveSubject(scenarioKey, scenario, ctx); - if (scenario.service === 'childBenefit') { - return this.#evaluateChild(scenario, subject, seq); - } - - const plan = authorityPlan(scenarioKey, scenario); - const configured = plan.map((authority) => ({ - authority, - service: this.#serviceFor(authority.client) - })); - const evaluations = await Promise.all( - configured.map(({ authority, service }) => - this.#evaluateAuthority(authority, subject, service) - ) - ); - const normalizedResults = evaluations.flatMap((evaluation) => evaluation.response.results); - const outcome = evaluationOutcome(scenarioKey, scenario, normalizedResults); - const failed = evaluations.find( - (evaluation) => evaluation.status < 200 || evaluation.status >= 300 - ); - const responseStatus = failed?.status ?? 200; - const status = proofStatus(responseStatus, outcome.satisfied, outcome.hasResult); - const raw = liveRawTrace(evaluations, outcome.derivedDecisions); - const applicationOwned = isApplicationOwnedPlan(plan); - - return { - result: portalResult(scenario, status, outcome, seq), - raw, - proof: { - headline: scenario.headline, - answered: answeredByAuthorities(evaluations), - notDisclosed: scenario.notDisclosed, - status, - authority: applicationOwned ? undefined : scenario.notary, - crypto: liveCrypto(scenarioKey, evaluations) - }, - timing: { latencyMs: 0, staggerOrder: scenario.staggerOrder, slow: false } + const applicationOwned = scenario.applicationOwned === true; + const responseBody = { + results, + source_trace: responses.flatMap((response) => response.source_trace ?? []), + ...(outcome.derivedDecisions ? { derived_decisions: outcome.derivedDecisions } : {}) }; - } - - async #evaluateChild( - scenario: (typeof SCENARIOS)[string], - subject: string, - seq: number - ): Promise { - const service = this.#serviceFor('childBenefit'); - const request = buildChildBenefitRequest(scenario, subject); - const url = joinedUrl(service.url, '/v1/evaluations'); - const response = await this.#fetchJson(url, { - method: 'POST', - headers: { - ...notaryHeaders(service.token, scenario.purpose, 'application/json'), - 'Content-Type': 'application/json' - }, - body: JSON.stringify(request) - }); - const childBody = - isChildBenefitResponse(response.body) && response.body.purpose === scenario.purpose - ? response.body - : undefined; - const claim = childBody?.results.find((result) => result.claim_id === scenario.claimId); - const attributed = - childBody !== undefined && claim !== undefined && hasChildAttribution(childBody, scenario); - const stale = attributed && isExpired(claim.expires_at); - const outcome: EvaluationOutcome = { - hasResult: attributed, - satisfied: attributed && !stale ? claim.satisfied : null, - value: attributed && !stale ? claim.satisfied : undefined, - stale, - issuedAt: attributed ? claim.issued_at : undefined + const requestBody = { + purpose: scenario.purpose, + scenario_steps: calls.map((call) => `${call.scenarioId}/${call.stepId}`), + composition: calls.length > 1 ? 'portal-application' : 'single-reviewed-step' }; - const status = proofStatus(response.status, outcome.satisfied, outcome.hasResult); - const evidenceSetId = childBody?.evidence_set_id; - const responseBody: RawProviderResponse = childBody ?? { results: [] }; + const display = proofStatus === 'ok' && outcome.satisfied === true + ? scenario.display + : `${scenario.claimId}: ${outcome.found ? String(outcome.value) : 'not returned'}`; return { - result: portalResult(scenario, status, outcome, seq), + result: { + state: proofStatus === 'ok' ? scenario.state : proofStatus === 'false' ? 'false' : 'error', + display, + ...(!applicationOwned ? { authority: scenario.authority } : {}), + traceId: `event ${seq}` + }, raw: { - request: { method: 'POST', url, body: request }, - response: { status: response.status, body: responseBody } + request: { + method: calls.length > 1 ? 'MULTI' : 'POST', + url: `${requiredRunnerUrl(this.#runnerUrl)}/v1/scenarios`, + body: requestBody + }, + response: { status: typeof status === 'number' ? status : 503, body: responseBody } }, proof: { headline: scenario.headline, - answered: childAnswered(attributed ? childBody : undefined, scenario.claimId), + answered: outcome.found + ? `${presentations[0]?.authority ?? 'Authority Evidence'} answered: ${scenario.claimId} = ${String(outcome.value)}` + : 'The authority Evidence service returned no usable value for this field', notDisclosed: scenario.notDisclosed, - status, - authority: scenario.notary, - crypto: { - signedBy: `No application signature; ${authorityLabel(scenario)} source result was collected by child-benefit-federator`, - algorithm: 'Ordinary JSON response; no application signature asserted', - issuerKey: 'Not applicable for an application evidence set', - holderBound: 'Not credential-bound; the BFF selected the purpose and subject', - credential: 'Minimized source-attributed predicate result', - auditId: `evidence-set:${evidenceSetId ?? 'unavailable'}` - } + status: proofStatus, + authority: applicationOwned ? undefined : scenario.authority, + purpose: scenario.purpose, + presentations, + crypto: evidenceProof(presentations, proofStatus) }, timing: { latencyMs: 0, staggerOrder: scenario.staggerOrder, slow: false } }; } - async #evaluateAuthority( - plan: AuthorityPlan, - subject: string, - service: ConfiguredService - ): Promise { - const request = buildEvaluationRequest( - plan.claimId, - subject, - plan.purpose, - plan.scheme ?? 'solmara_uin' - ); - const url = joinedUrl(service.url, '/v1/evaluations'); - const response = await this.#fetchJson(url, { + async #run(call: RunnerCall): Promise { + const url = `${requiredRunnerUrl(this.#runnerUrl)}/v1/scenarios/${call.scenarioId}/steps/${call.stepId}/run`; + const response = await this.#fetch(url, { method: 'POST', - headers: { - ...notaryHeaders(service.token, plan.purpose, CLAIM_RESULT_FORMAT), - 'Content-Type': 'application/json' - }, - body: JSON.stringify(request) + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({}) }); - return { - plan, - url, - request, - status: response.status, - response: parseEvaluationResponse(response.body, plan) - }; - } - - async #fetchJson( - url: string, - init: RequestInit - ): Promise<{ status: number; body: Record }> { - const response = await this.#fetch(url, init); - const body: unknown = await response.json().catch(() => ({})); - return { status: response.status, body: recordValue(body) ?? {} }; - } - - #serviceFor(client: LiveClient): { url: string; token: string } { - const ref = this.#services[client]; - return { - url: requiredUrl(ref.url, ref.urlEnv), - token: requiredValue(ref.token, ref.tokenEnv) - }; + const envelope = asDict(await response.json().catch(() => ({}))); + // RunnerResult is a bounded structural view over the already parsed object. + const result = asDict(envelope.result) as RunnerResult; + if (!response.ok && result.response_source === undefined) { + return { + response_source: { + status: response.status, + body: { + type: 'https://registrystack.org/problems/evidence/service_unavailable', + title: 'Authority Evidence is unavailable', + status: response.status + } + } + }; + } + return result; } - #denied( - scenarioKey: string, + #blocked( + field: Field, scenario: (typeof SCENARIOS)[string], - ctx: EvaluateContext, code: string ): MockEvaluation { const seq = ++this.#seq; - const evaluationId = makeEvaluationId(seq); - const subject = - scenarioKey === 'denial' - ? PERSONA.karim - : ctx.delegatedTarget ?? ctx.selectedSubject ?? ctx.subject; - const rawRequest = - scenario.service === 'childBenefit' - ? buildChildBenefitRequest(scenario, subject) - : buildRawRequest(scenario, subject, { - actorIdHash: hashActor(ctx.subject), - delegationRef: 'rnref:v1:REL-1001-MOTHER' - }); - const body: DenialBody = { - error: code, - error_description: scenario.denial?.message ?? 'requester is not authorized for this target' + const body = { + type: `urn:solmara:portal:problem:${code}`, + title: 'Portal authorization denied the request', + status: 403, + detail: 'The portal stopped this request before source access.', + operation: `denial:event-${seq}` }; return { result: { state: 'error', - display: - code === 'relationship_not_proven' - ? 'Denied: guardian link not proven, no dependent record was read' - : scenario.display, + display: code === 'relationship_not_proven' ? 'Denied: guardian link not proven' : scenario.display, reasonCode: code, traceId: `event ${seq}` }, raw: { request: { method: 'POST', - url: 'solmara://citizen-portal/blocked-before-authority-call', - body: rawRequest + url: 'solmara://citizen-portal/blocked-before-evidence', + body: { field: field.id, purpose: scenario.purpose, disclosure: scenario.disclosure } }, response: { status: 403, body } }, proof: { - headline: - code === 'relationship_not_proven' - ? `Portal denied the request before calling ${authorityLabel(scenario)}: the guardian link was not proven, so no dependent record was read` - : 'Portal denied the cross-person request before any authority call', - answered: `Portal authorization gate stopped the request before any authority call: 403 ${code}`, + headline: 'Portal authorization stopped the request before authority Evidence was called', + answered: `Portal authorization gate returned 403 ${code}`, notDisclosed: scenario.notDisclosed, status: 'denied', authority: undefined, + purpose: scenario.purpose, + presentations: [], crypto: { - signedBy: 'Portal authorization gate; no authority Notary called', - algorithm: 'No signature; request stopped before source access', + signedBy: 'Portal authorization gate; authority Evidence was not called', + algorithm: 'No evidence assertion was produced', issuerKey: 'Not applicable', - holderBound: 'Portal session actor and server-selected subject', - credential: 'No credential or evidence result returned', - auditId: `denial:${evaluationId}` + holderBound: 'Portal session and server-selected subject', + credential: 'No credential or evidence assertion returned' } }, timing: { latencyMs: 0, staggerOrder: scenario.staggerOrder, slow: false } @@ -345,515 +184,147 @@ export class LiveEvidenceProvider implements DetailedEvidenceProvider { } } -function evaluationOutcome( - scenarioKey: string, - scenario: (typeof SCENARIOS)[string], - results: ClaimResultView[] -): EvaluationOutcome { - if (scenarioKey === 'disability-determination') { - return derivedOutcome( - scenario.claimId, - results, - ['person-is-deceased', 'pension-payment-active'] - ); - } +function runnerCalls(scenarioKey: string, service: string): RunnerCall[] { + if (service === 'childBenefit') return [{ scenarioId: 'birth-to-child-benefit', stepId: 'positive' }]; + if (service === 'nagdi') return [{ scenarioId: 'farmer-climate-smart-voucher', stepId: 'positive' }]; + if (service === 'citizen') return [{ scenarioId: 'citizen-self-service', stepId: 'positive' }]; if (scenarioKey === 'combined-support-eligibility') { - return derivedOutcome( - scenario.claimId, - results, - ['person-is-deceased', 'pension-payment-active', 'survivor-is-eligible'] - ); + return [ + { scenarioId: 'death-to-pension-survivor', stepId: 'stop-payment' }, + { scenarioId: 'death-to-pension-survivor', stepId: 'survivor-benefit' } + ]; } - if (scenarioKey === 'citizen-record-status') { - return derivedOutcome( - 'citizen-self-service-ready', - results, - ['civil-record-linked', 'citizen-population-record-active'] - ); + if (scenarioKey === 'functioning-assessment') { + return [{ scenarioId: 'death-to-pension-survivor', stepId: 'survivor-benefit' }]; } - const claimId = - scenarioKey === 'person-is-alive' || scenarioKey === 'ambiguous' - ? 'person-is-deceased' - : scenarioKey === 'functioning-assessment' || - scenarioKey === 'stale' - ? 'survivor-is-eligible' - : scenario.claimId; - const result = results.find((candidate) => candidate.claim_id === claimId); - const stale = result !== undefined && isExpired(result.expires_at); - return { - hasResult: result !== undefined, - satisfied: stale ? null : (result?.satisfied ?? null), - value: stale ? undefined : result?.value, - stale, - issuedAt: result?.issued_at - }; -} - -function derivedOutcome( - decisionId: string, - results: ClaimResultView[], - claims: string[], - trueValue: unknown = true -): EvaluationOutcome { - const sourceResults = claims.map((claimId) => - results.find((result) => result.claim_id === claimId) - ); - const hasResult = sourceResults.every((result) => result !== undefined); - const stale = hasResult && sourceResults.some((result) => result && isExpired(result.expires_at)); - const satisfied = hasResult && !stale - ? sourceResults.every((result) => result?.satisfied === true) - : null; - return { - hasResult, - satisfied, - value: satisfied === true ? trueValue : satisfied, - stale, - issuedAt: oldestIssuedAt(sourceResults), - derivedDecisions: { [decisionId]: satisfied } - }; -} - -function oldestIssuedAt(results: Array): string | undefined { - return results - .flatMap((result) => (result ? [result.issued_at] : [])) - .sort((left, right) => Date.parse(left) - Date.parse(right))[0]; -} - -function isExpired(expiresAt: string | null, now = Date.now()): boolean { - return expiresAt !== null && Date.parse(expiresAt) <= now; -} - -function liveRawTrace( - evaluations: AuthorityEvaluation[], - derivedDecisions?: Record -): MockEvaluation['raw'] { - const first = evaluations[0]; - if (evaluations.length === 1 && first) { - return { - request: { method: 'POST', url: first.url, body: first.request }, - response: { status: first.status, body: first.response } - }; + if (scenarioKey === 'stale' || scenarioKey === 'ambiguous') { + return [{ scenarioId: 'death-to-pension-survivor', stepId: 'stale-control' }]; } - const purposes = [...new Set(evaluations.map((evaluation) => evaluation.plan.purpose))]; - const request: RawApplicationRequest = { - purpose: - purposes.length === 1 - ? (purposes[0] ?? '') - : 'application-composed-from-source-authorized-purposes', - disclosure: 'decision', - composition: 'application', - requests: evaluations.map((evaluation) => ({ - authority: evaluation.plan.authority, - service_id: evaluation.plan.serviceId, - body: evaluation.request - })) - }; - const response: RawApplicationResponse = { - schema_version: 'solmara-portal-evidence/v1', - orchestration: { - service_id: 'citizen-portal', - decision: 'application_composed' - }, - results: evaluations.flatMap((evaluation) => evaluation.response.results), - source_trace: evaluations.map((evaluation) => ({ - authority: evaluation.plan.authority, - service_id: evaluation.plan.serviceId, - status: evaluation.status, - claims: [evaluation.plan.claimId] - })), - ...(derivedDecisions ? { derived_decisions: derivedDecisions } : {}) - }; - const failed = evaluations.find( - (evaluation) => evaluation.status < 200 || evaluation.status >= 300 - ); - return { - request: { - method: 'MULTI', - url: 'solmara://citizen-portal/application-composition', - body: request - }, - response: { status: failed?.status ?? 200, body: response } - }; + return [{ scenarioId: 'death-to-pension-survivor', stepId: 'stop-payment' }]; } -function portalResult( - scenario: (typeof SCENARIOS)[string], - status: ProofTrace['status'], - outcome: EvaluationOutcome, - seq: number -): ClaimResult { - const applicationOwned = outcome.derivedDecisions !== undefined; - return { - state: - outcome.stale - ? 'stale' - : status === 'false' - ? 'false' - : status === 'denied' || status === 'error' - ? 'error' - : scenario.state, - display: displayResult(scenario, outcome.value, outcome.satisfied, status, outcome.stale), - ...(!applicationOwned ? { authority: scenario.notary } : {}), - ...(outcome.issuedAt ? { asOf: outcome.issuedAt } : {}), - traceId: `event ${seq}` - }; -} - -function liveCrypto( - _scenarioKey: string, - evaluations: AuthorityEvaluation[] -): NonNullable { - const evaluationIds = evaluations.flatMap((evaluation) => - evaluation.response.results.map((result) => result.evaluation_id) - ); - const auditId = - evaluationIds.length > 0 - ? evaluationIds.map((evaluationId) => `evaluation:${evaluationId}`).join('; ') - : 'No valid evaluation identifier returned'; - const answeringAuthorities = [ - ...new Set( - evaluations - .filter((evaluation) => evaluation.response.results.length > 0) - .map((evaluation) => evaluation.plan.authority) - ) - ]; - if (evaluations.length > 1) { - return { - signedBy: - answeringAuthorities.length > 0 - ? `No credential issued; ${answeringAuthorities.join(' and ')} returned separate claim evaluations` - : 'No valid claim result returned; the portal did not compose a decision', - algorithm: 'Independent Registry Notary claim-result responses', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Not credential-bound; the BFF selected the purpose and subject', - credential: 'Application decision only; no credential issued by the portal', - auditId - }; +function responseResults(response: RunnerResult): Dict[] { + if (Array.isArray(response.results)) { + return response.results.map(asDict).filter((item) => Object.keys(item).length > 0); } - return { - signedBy: - answeringAuthorities.length === 1 - ? `No credential issued; ${answeringAuthorities[0]} returned a claim evaluation` - : 'No valid claim result returned', - algorithm: 'Source-owned Registry Notary claim-result responses', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Not credential-bound; the BFF selected the purpose and subject', - credential: 'Claim results only; no credential issued by the portal', - auditId - }; + const body = asDict(response.response_source?.body); + return Array.isArray(body.results) ? body.results.map(asDict).filter((item) => Object.keys(item).length > 0) : []; } -function resolveSubject( +function outcomeFor( scenarioKey: string, - scenario: (typeof SCENARIOS)[string], - ctx: EvaluateContext -): string { - if (scenarioKey === 'denial') return PERSONA.karim; - if (scenario.delegated || scenarioKey === 'caregiver-link') { - return requiredValue(ctx.delegatedTarget, 'delegatedTarget'); - } - return ctx.selectedSubject ?? ctx.subject; -} - -function requiredUrl(value: string | undefined, name: string): string { - const raw = requiredValue(value, name); - try { - return new URL(raw).toString().replace(/\/$/, ''); - } catch { - throw new Error(`${name} must be an absolute URL`); - } -} - -function requiredValue(value: string | undefined, name: string): string { - if (!value) throw new Error(`${name} is required for PORTAL_PROVIDER=live`); - return value; -} - -function joinedUrl(base: string, path: string): string { - return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; -} - -function notaryHeaders(token: string, purpose: string, accept: string): Record { - return { - 'x-api-key': token, - Accept: accept, - 'Data-Purpose': purpose - }; -} - -function parseEvaluationResponse( - body: Record, - expected: AuthorityPlan -): RawEvaluationResponse { - if (!Array.isArray(body.results) || body.results.length !== 1) return { results: [] }; - const result = parseClaimResult(body.results[0]); - if ( - !result || - result.claim_id !== expected.claimId || - result.provenance.generated_by.service_id !== expected.serviceId - ) { - return { results: [] }; - } - return { results: [result] }; -} - -function parseClaimResult(value: unknown): ClaimResultView | undefined { - const item = recordValue(value); - if (!item) return undefined; - const targetRef = parseTargetRef(item.target_ref); - const provenance = parseProvenance(item.provenance); - const expiresAt = item.expires_at; - if ( - !nonEmptyString(item.evaluation_id) || - !nonEmptyString(item.claim_id) || - !nonEmptyString(item.claim_version) || - !nonEmptyString(item.subject_type) || - !targetRef || - !('value' in item) || - (typeof item.satisfied !== 'boolean' && item.satisfied !== null) || - item.disclosure !== 'predicate' || - item.format !== CLAIM_RESULT_FORMAT || - !dateTimeString(item.issued_at) || - (expiresAt !== null && !dateTimeString(expiresAt)) || - !validLifetime(item.issued_at, expiresAt) || - !provenance - ) { - return undefined; + claimId: string, + results: Dict[], + responses: RunnerResult[] +): { found: boolean; satisfied: boolean | null; value: unknown; derivedDecisions?: Record } { + if (scenarioKey === 'disability-determination') { + const value = responses[0]?.derived_decisions?.['pension-payment-should-stop']; + return { found: typeof value === 'boolean', satisfied: value ?? null, value, derivedDecisions: { 'pension-payment-should-stop': value ?? null } }; } - if ( - provenance.generated_by.evaluation_id !== item.evaluation_id || - provenance.generated_by.claim_id !== item.claim_id || - provenance.generated_by.claim_version !== item.claim_version - ) { - return undefined; + if (scenarioKey === 'combined-support-eligibility') { + const required = ['person-is-deceased', 'pension-payment-active', 'survivor-is-eligible']; + const values = required.map((id) => results.find((item) => item.claim_id === id)?.satisfied); + const found = values.every((value) => typeof value === 'boolean'); + const value = found ? values.every((item) => item === true) : null; + return { found, satisfied: value, value, derivedDecisions: { [claimId]: value } }; } - return { - evaluation_id: item.evaluation_id, - claim_id: item.claim_id, - claim_version: item.claim_version, - subject_type: item.subject_type, - target_ref: targetRef, - value: item.value, - satisfied: item.satisfied, - disclosure: item.disclosure, - format: item.format, - issued_at: item.issued_at, - expires_at: expiresAt, - provenance - }; -} - -function parseTargetRef(value: unknown): ClaimResultView['target_ref'] | undefined { - const target = recordValue(value); - if (!target || !nonEmptyString(target.handle)) return undefined; - if ( - ('identifier_schemes' in target && - (!Array.isArray(target.identifier_schemes) || - !target.identifier_schemes.every(nonEmptyString))) || - ('profile' in target && !nonEmptyString(target.profile)) || - ('type' in target && !nonEmptyString(target.type)) - ) { - return undefined; + if (scenarioKey === 'citizen-record-status') { + const required = ['civil-record-linked', 'citizen-population-record-active']; + const values = required.map((id) => results.find((item) => item.claim_id === id)?.satisfied); + const found = values.every((value) => typeof value === 'boolean'); + const value = found ? values.every((item) => item === true) : null; + return { found, satisfied: value, value, derivedDecisions: { 'citizen-self-service-ready': value } }; } - return { - handle: target.handle, - ...(Array.isArray(target.identifier_schemes) - ? { identifier_schemes: target.identifier_schemes } - : {}), - ...(typeof target.profile === 'string' ? { profile: target.profile } : {}), - ...(typeof target.type === 'string' ? { type: target.type } : {}) - }; + const effectiveClaim = scenarioKey === 'person-is-alive' || scenarioKey === 'ambiguous' + ? 'person-is-deceased' + : scenarioKey === 'stale' + ? 'survivor-is-eligible' + : claimId; + const item = results.find((candidate) => candidate.claim_id === effectiveClaim); + const value = item?.value ?? item?.satisfied; + return { found: item !== undefined, satisfied: typeof item?.satisfied === 'boolean' ? item.satisfied : null, value }; } -function parseProvenance(value: unknown): ClaimResultView['provenance'] | undefined { - const provenance = recordValue(value); - const generatedBy = recordValue(provenance?.generated_by); - const used = recordValue(provenance?.used); - if ( - provenance?.schema_version !== 'registry-notary-claim-provenance/v2' || - !generatedBy || - generatedBy.type !== 'claim_evaluation' || - !nonEmptyString(generatedBy.service_id) || - !nonEmptyString(generatedBy.evaluation_id) || - !nonEmptyString(generatedBy.claim_id) || - !nonEmptyString(generatedBy.claim_version) || - !validOptionalString(generatedBy, 'policy_hash') || - !validOptionalString(generatedBy, 'policy_id') || - !validOptionalString(generatedBy, 'policy_version') || - !used || - !Number.isInteger(used.relay_consultation_count) || - Number(used.relay_consultation_count) < 0 || - !Array.isArray(provenance.derived_from) || - !provenance.derived_from.every((entry) => recordValue(entry) !== undefined) - ) { - return undefined; - } +function evidenceProof( + presentations: EvidencePresentation[], + status: ProofTrace['status'] +): NonNullable { + const authorities = [...new Set(presentations.map((item) => item.authority))]; + const returned = status === 'ok' || status === 'false'; return { - schema_version: 'registry-notary-claim-provenance/v2', - generated_by: { - type: 'claim_evaluation', - service_id: generatedBy.service_id, - evaluation_id: generatedBy.evaluation_id, - claim_id: generatedBy.claim_id, - claim_version: generatedBy.claim_version, - ...(typeof generatedBy.policy_hash === 'string' - ? { policy_hash: generatedBy.policy_hash } - : {}), - ...(typeof generatedBy.policy_id === 'string' ? { policy_id: generatedBy.policy_id } : {}), - ...(typeof generatedBy.policy_version === 'string' - ? { policy_version: generatedBy.policy_version } - : {}) - }, - used: { relay_consultation_count: Number(used.relay_consultation_count) }, - derived_from: provenance.derived_from + signedBy: returned && authorities.length ? `${authorities.join(' and ')} issued the returned Evidence` : 'No Evidence assertion was returned', + algorithm: returned ? 'Verified Evidence assertion' : 'Not available', + issuerKey: returned ? 'Authority Evidence JWKS' : 'Not applicable', + holderBound: 'Audience-scoped to the portal request', + credential: returned ? 'Minimum-disclosure Evidence assertion' : 'No Evidence assertion returned' }; } -function isChildBenefitResponse(body: Record): body is RawChildBenefitResponse { - const orchestration = recordValue(body.orchestration); - const target = recordValue(body.target); - return ( - body.schema_version === 'solmara-child-benefit-evidence/v1' && - nonEmptyString(body.evidence_set_id) && - orchestration?.service_id === 'child-benefit-federator' && - orchestration.decision === 'not_composed' && - typeof body.purpose === 'string' && - target !== undefined && - !('identifiers' in target) && - Array.isArray(target.identifier_schemes) && - target.identifier_schemes.every((scheme) => typeof scheme === 'string') && - Array.isArray(body.results) && - body.results.every((item) => { - const result = recordValue(item); - return ( - result !== undefined && - nonEmptyString(result.claim_id) && - nonEmptyString(result.claim_version) && - typeof result.satisfied === 'boolean' && - result.disclosure === 'predicate' && - result.format === CLAIM_RESULT_FORMAT && - dateTimeString(result.issued_at) && - (dateTimeString(result.expires_at) || result.expires_at === null) && - validLifetime(result.issued_at, result.expires_at) && - nonEmptyString(result.authority) && - nonEmptyString(result.notary_service_id) - ); - }) && - Array.isArray(body.source_trace) - ); -} - -function hasChildAttribution( - body: RawChildBenefitResponse, - scenario: (typeof SCENARIOS)[string] -): boolean { - const claimId = scenario.claimId; - const expectedServiceId = NOTARY_SERVICE_ID[scenario.notary]; - const expectedAuthority = AUTHORITY_LABEL[scenario.notary]; - const result = body.results.find((candidate) => candidate.claim_id === claimId); - if (!result) return false; - if ( - result.notary_service_id !== expectedServiceId || - result.authority !== expectedAuthority - ) { - return false; - } - return body.source_trace.some((item) => { - const trace = recordValue(item); - return ( - trace?.service_id === expectedServiceId && - trace.authority === expectedAuthority && - Array.isArray(trace.claims) && - trace.claims.includes(claimId) - ); +function presentationsFor( + scenarioKey: string, + scenario: (typeof SCENARIOS)[string], + responses: RunnerResult[] +): EvidencePresentation[] { + const supplied = responses.flatMap((response) => { + const direct = response.presentations ?? (response.presentation ? [response.presentation] : []); + const fromTrace = response.source_trace ?? []; + const fromResults = responseResults(response).map((result) => result.presentation); + return [...direct, ...fromTrace, ...fromResults].map(parsePresentation).filter(isPresentation); }); -} + if (supplied.length > 0) return uniquePresentations(supplied); -function recordValue(value: unknown): Record | undefined { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; - // TypeScript cannot infer the string-keyed record after the runtime object checks. - return value as Record; -} - -function nonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; + const plan = scenario.service === 'childBenefit' ? [] : authorityPlan(scenarioKey, scenario); + if (isApplicationOwnedPlan(plan)) { + return plan.map((entry) => evidencePresentation(entry.authorityId, entry.source)); + } + return [evidencePresentation(scenario.authority, sourceForScenario(scenario))]; } -function dateTimeString(value: unknown): value is string { - return ( - nonEmptyString(value) && - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && - !Number.isNaN(Date.parse(value)) +function parsePresentation(value: unknown): EvidencePresentation | null { + const item = asDict(value); + const source = item.source; + const match = Object.values(SOLMARA_AUTHORITIES).find( + (authority) => authority.label === item.authority && authority.issuer === item.issuer ); + if (!match || (source !== 'immutable extract' && source !== 'Relay lookup')) return null; + return evidencePresentation(match.id, source); } -function validOptionalString(record: Record, key: string): boolean { - return !(key in record) || nonEmptyString(record[key]); -} - -function validLifetime(issuedAt: unknown, expiresAt: unknown): boolean { - if (!dateTimeString(issuedAt)) return false; - if (expiresAt === null) return true; - return dateTimeString(expiresAt) && Date.parse(expiresAt) >= Date.parse(issuedAt); +function isPresentation(value: EvidencePresentation | null): value is EvidencePresentation { + return value !== null; } -function proofStatus( - status: number, - satisfied: unknown, - hasResult: boolean -): ProofTrace['status'] { - if (status === 403) return 'denied'; - if (status < 200 || status >= 300 || !hasResult) return 'error'; - if (satisfied === false) return 'false'; - return satisfied === true ? 'ok' : 'error'; +function uniquePresentations(items: EvidencePresentation[]): EvidencePresentation[] { + const seen = new Set(); + return items.filter((item) => { + const key = `${item.serviceId}:${item.source}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } -function displayResult( - scenario: (typeof SCENARIOS)[string], - value: unknown, - satisfied: unknown, - status: ProofTrace['status'], - stale: boolean -): string { - if (stale) { - return scenario.state === 'stale' - ? scenario.display - : `${scenario.claimId}: expired evidence, refresh required`; +function sourceForScenario(scenario: (typeof SCENARIOS)[string]): EvidenceSource { + if (scenario.service === 'childBenefit' && ['childCivil', 'population', 'socialRegistry'].includes(scenario.authority)) { + return 'immutable extract'; } - if (status === 'ok' && satisfied === true) return scenario.display; - return `${scenario.claimId}: ${displayValue(value, satisfied)}`; + return 'Relay lookup'; } -function displayValue(value: unknown, satisfied: unknown): string { - if (typeof satisfied === 'boolean') return String(satisfied); - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return String(value); +function requiredRunnerUrl(value: string | undefined): string { + if (!value) throw new Error('SCENARIO_RUNNER_URL is required for PORTAL_PROVIDER=live'); + try { + return new URL(value).toString().replace(/\/$/, ''); + } catch { + throw new Error('SCENARIO_RUNNER_URL must be an absolute URL'); } - if (value === null || value === undefined) return 'not returned'; - return JSON.stringify(value); -} - -function answeredByAuthorities(evaluations: AuthorityEvaluation[]): string { - return evaluations - .map((evaluation) => { - const result = evaluation.response.results.find( - (candidate) => candidate.claim_id === evaluation.plan.claimId - ); - return `${evaluation.plan.authority} answered: ${evaluation.plan.claimId} = ${displayValue(result?.value, result?.satisfied)}`; - }) - .join('; '); } -function childAnswered(body: RawChildBenefitResponse | undefined, claimId: string): string { - const result = body?.results.find((candidate) => candidate.claim_id === claimId); - if (!result) return `Child benefit application returned no valid ${claimId} result`; - return `${result.authority} answered: ${claimId} = ${result.satisfied}`; +function asDict(value: unknown): Dict { + // The runtime guard establishes the dictionary shape used by safe readers. + return value && typeof value === 'object' && !Array.isArray(value) ? value as Dict : {}; } -function hashActor(subject: string): string { - let hash = 0; - for (let i = 0; i < subject.length; i += 1) { - hash = (hash * 31 + subject.charCodeAt(i)) & 0xffffffff; - } - return `hmac-sha256:${(hash >>> 0).toString(16).padStart(8, '0')}`; +function isSuccess(status: number | null | undefined): boolean { + return typeof status === 'number' && status >= 200 && status < 300; } diff --git a/portal/src/lib/providers/mock/index.test.ts b/portal/src/lib/providers/mock/index.test.ts index 8a4a8a0..a0b1475 100644 --- a/portal/src/lib/providers/mock/index.test.ts +++ b/portal/src/lib/providers/mock/index.test.ts @@ -3,11 +3,12 @@ import type { EvaluateContext } from '$lib/providers/EvidenceProvider'; import type { Field } from '$lib/types'; import { MockEvidenceProvider, PERSONA } from './index'; import { - CHILD_BENEFIT_FORMAT, + decodeEvidencePayload, + EVIDENCE_RESPONSE_FORMAT, type RawApplicationRequest, type RawApplicationResponse, - type RawChildBenefitRequest, - type RawEvaluateRequest + type RawEvidenceRequest, + type RawEvidenceResponse } from './wire'; const provider = new MockEvidenceProvider(); @@ -18,277 +19,287 @@ function field(id: string): Field { } describe('MockEvidenceProvider.evaluate', () => { - it('returns a GREEN verified state for a true predicate', async () => { - const res = await provider.evaluate(field('registered-farmer'), ctx); - expect(res.state).toBe('verified'); - expect(res.authority).toBe('agri'); - expect(res.traceId).toMatch(/^event \d+$/); + it('returns a verified authority result for a true predicate', async () => { + const result = await provider.evaluate(field('registered-farmer'), ctx); + expect(result).toMatchObject({ state: 'verified', authority: 'agri' }); + expect(result.traceId).toMatch(/^event \d+$/); }); - it('returns a verified pension stop predicate', async () => { - const res = await provider.evaluate(field('disability-determination'), ctx); - expect(res.state).toBe('verified'); - expect(res.display).toContain('Pension payment should stop'); - expect(res.authority).toBeUndefined(); + it('keeps a portal-composed decision separate from authority assertions', async () => { + const evaluation = await provider.evaluateDetailed(field('combined-support-eligibility'), ctx); + expect(evaluation.result.state).toBe('verified'); + expect(evaluation.result.authority).toBeUndefined(); + expect(evaluation.proof.authority).toBeUndefined(); + expect(evaluation.raw.request.method).toBe('MULTI'); + + const request = evaluation.raw.request.body as RawApplicationRequest; + const response = evaluation.raw.response.body as RawApplicationResponse; + expect(request.requests.map((source) => source.service_id)).toEqual([ + 'cra-evidence', + 'sipf-evidence', + 'sipf-evidence' + ]); + expect(request.requests.map((source) => source.body.requirement)).toEqual([ + 'urn:solmara:requirement:person-is-deceased:v1', + 'urn:solmara:requirement:pension-payment-active:v1', + 'urn:solmara:requirement:survivor-is-eligible:v1' + ]); + expect(response.signed_evidence).toHaveLength(3); + expect(response.derived_decisions).toEqual({ 'survivor-benefit-eligible': true }); + }); + + it('returns the verified pension-stop decision composed from its two authority requirements', async () => { + const evaluation = await provider.evaluateDetailed(field('disability-determination'), ctx); + expect(evaluation.result.state).toBe('verified'); + expect(evaluation.result.display).toContain('Pension payment should stop'); + expect(evaluation.result.authority).toBeUndefined(); + + const request = evaluation.raw.request.body as RawApplicationRequest; + expect(request.requests.map((source) => source.body.requirement)).toEqual([ + 'urn:solmara:requirement:person-is-deceased:v1', + 'urn:solmara:requirement:pension-payment-active:v1' + ]); }); - it('returns a delegated child age predicate', async () => { - // date-of-birth is the legacy field key for the age predicate, so the - // guardian link must be proven for the value to be returned. - const ev = await provider.evaluateDetailed(field('date-of-birth'), ctx, { + it('returns the delegated child-age predicate as minimized signed Evidence', async () => { + const evaluation = await provider.evaluateDetailed(field('date-of-birth'), ctx, { guardianLinkVerified: true }); - expect(ev.result.state).toBe('verified'); - // The child application returns minimized predicates without a duplicate value field. - const view = ev.raw.response.body as { results: { satisfied: boolean }[] }; - expect(view.results[0].satisfied).toBe(true); + expect(evaluation.result).toMatchObject({ state: 'verified', authority: 'childCivil' }); + const assertion = decodeEvidencePayload(evaluation.raw.response.body as RawEvidenceResponse); + expect(assertion.supportedValues).toEqual([ + { providesValueFor: 'urn:solmara:concept:child-age-under-5', value: true } + ]); }); - it('returns the missing population source predicate', async () => { - const ev = await provider.evaluateDetailed(field('population-record-active'), ctx, { + it('returns the population predicate from NIA Evidence with canonical attribution', async () => { + const evaluation = await provider.evaluateDetailed(field('population-record-active'), ctx, { guardianLinkVerified: true }); - expect(ev.result).toMatchObject({ + expect(evaluation.result).toMatchObject({ state: 'verified', display: 'Population record active: yes', authority: 'population' }); - // The child application response is a predicate-only evidence set. - const view = ev.raw.response.body as { - results: { claim_id: string; satisfied: boolean }[]; - }; - expect(view.results[0]).toMatchObject({ - claim_id: 'population-record-active', - satisfied: true + const assertion = decodeEvidencePayload(evaluation.raw.response.body as RawEvidenceResponse); + expect(assertion).toMatchObject({ + supportsRequirement: 'urn:solmara:requirement:population-record-active:v1', + issuedBy: 'did:web:id.registrystack.org:solmara:authority:nia', + providedBy: 'https://nia-evidence.solmara.registrystack.org/' }); }); - it('returns a boolean citizen-record decision without inventing certificate facts', async () => { - const ev = await provider.evaluateDetailed(field('citizen-record-status'), ctx); - expect(ev.result.state).toBe('verified'); - expect(ev.result.authority).toBeUndefined(); - expect(ev.proof.authority).toBeUndefined(); - const view = ev.raw.response.body as RawApplicationResponse; - expect(view.results.map((result) => result.provenance.generated_by.service_id)).toEqual([ - 'cra-notary', - 'nia-notary' - ]); - expect(view.derived_decisions).toEqual({ 'citizen-self-service-ready': true }); - expect(JSON.stringify(ev)).not.toMatch(/certificate_id|issued_on|CSR-BIRTH/); - }); + it('keeps citizen-record readiness portal-owned without inventing certificate facts', async () => { + const evaluation = await provider.evaluateDetailed(field('citizen-record-status'), ctx); + expect(evaluation.result.state).toBe('verified'); + expect(evaluation.result.authority).toBeUndefined(); + expect(evaluation.proof.authority).toBeUndefined(); - it('mirrors live multi-authority requests instead of fabricating a Notary-owned decision', async () => { - const ev = await provider.evaluateDetailed(field('combined-support-eligibility'), ctx); - expect(ev.result.state).toBe('verified'); - expect(ev.result.authority).toBeUndefined(); - expect(ev.proof.authority).toBeUndefined(); - expect(ev.raw.request.method).toBe('MULTI'); - const request = ev.raw.request.body as RawApplicationRequest; - const view = ev.raw.response.body as RawApplicationResponse; - expect(request.requests.map((source) => source.service_id)).toEqual([ - 'cra-notary', - 'sipf-notary', - 'sipf-notary' - ]); - expect(request.requests.map((source) => source.body.claims[0])).toEqual([ - 'person-is-deceased', - 'pension-payment-active', - 'survivor-is-eligible' - ]); - expect(view.results.map((result) => result.claim_id)).toEqual([ - 'person-is-deceased', - 'pension-payment-active', - 'survivor-is-eligible' + const response = evaluation.raw.response.body as RawApplicationResponse; + expect(response.signed_evidence.map((source) => source.authority)).toEqual([ + 'Civil Registration Authority', + 'National Identity Agency' ]); - expect(view.derived_decisions).toEqual({ 'survivor-benefit-eligible': true }); - expect(JSON.stringify(ev)).not.toContain('support_band'); + expect(response.derived_decisions).toEqual({ 'citizen-self-service-ready': true }); + expect(JSON.stringify(evaluation)).not.toMatch(/certificate_id|issued_on|CSR-BIRTH/); }); }); -describe('denial beat (cross-person stranger)', () => { - it('produces a 403 subject_mismatch with NO source read', async () => { - const ev = await provider.evaluateDetailed(field('denial'), ctx); - expect(ev.result.state).toBe('error'); - expect(ev.result.reasonCode).toBe('subject_mismatch'); - // 403 shape, no 200 results body - expect(ev.raw.response.status).toBe(403); - expect(ev.raw.response.body).not.toHaveProperty('results'); - expect(ev.raw.response.body).toHaveProperty('error', 'subject_mismatch'); - // the proof status is a denial; the rail will bounce - expect(ev.proof.status).toBe('denied'); - expect(ev.result.authority).toBeUndefined(); - expect(ev.proof.authority).toBeUndefined(); - expect(ev.proof.headline).toBe( - 'Portal denied the cross-person request before any authority call' - ); - expect(ev.raw.request.url).toBe('solmara://citizen-portal/blocked-before-authority-call'); - expect(ev.proof.answered).toContain('before any authority call'); - expect(ev.proof.crypto).toMatchObject({ - signedBy: 'Portal authorization gate; no authority Notary called', - credential: 'No credential or evidence result returned' +describe('portal authorization gates', () => { + it('denies a cross-person request before authority Evidence is called', async () => { + const evaluation = await provider.evaluateDetailed(field('denial'), ctx); + expect(evaluation.raw.response).toMatchObject({ status: 403 }); + expect(evaluation.raw.response.body).toMatchObject({ + type: 'urn:solmara:portal:problem:not_authorized', + status: 403 + }); + expect(evaluation.raw.request.url).toBe('solmara://citizen-portal/blocked-before-authority-call'); + expect(evaluation.proof).toMatchObject({ + status: 'denied', + authority: undefined, + crypto: { + signedBy: 'Portal authorization gate; authority Evidence was not called', + credential: 'No credential or Evidence assertion returned' + } }); - // the request targeted the stranger, never the session subject - // Denial scenarios use a single authority request, never an application batch. - const target = (ev.raw.request.body as RawEvaluateRequest).target; - expect(target.identifiers[0].value).toBe(PERSONA.karim); }); - it('does not perform a source read (no source_count > 0 on a denial)', async () => { - const ev = await provider.evaluateDetailed(field('denial'), ctx); - // there is no 200 results body at all, so no source was read - expect(ev.raw.response.body).not.toHaveProperty('results'); - }); + it('denies a dependent read until the guardian link is proven', async () => { + const denied = await provider.evaluateDetailed(field('date-of-birth'), ctx); + expect(denied.raw.response.status).toBe(403); + expect(denied.raw.response.body).not.toHaveProperty('protected'); - it('preserves the configured disclosure on direct authority responses', async () => { - const ev = await provider.evaluateDetailed(field('voucher-eligibility'), ctx); - const view = ev.raw.response.body as { results: { disclosure: string }[] }; - expect(view.results[0].disclosure).toBe('decision'); + const allowed = await provider.evaluateDetailed(field('date-of-birth'), ctx, { + guardianLinkVerified: true + }); + expect(allowed.raw.response.status).toBe(200); + const request = allowed.raw.request.body as RawEvidenceRequest; + expect(request.requirement).toBe('urn:solmara:requirement:child-age-under-5:v1'); + expect(request.subjects[0].selector.values.solmara_uin).toBe(PERSONA.mateo); }); -}); -describe('delegated two-hop gate', () => { - it('denies a civil read before the guardian link is proven (no dependent read)', async () => { - const ev = await provider.evaluateDetailed(field('date-of-birth'), ctx, { + it('denies a dependent read when the guardian link is explicitly false', async () => { + const evaluation = await provider.evaluateDetailed(field('date-of-birth'), ctx, { guardianLinkVerified: false }); - expect(ev.result.state).toBe('error'); - expect(ev.raw.response.status).toBe(403); - expect(ev.raw.response.body).not.toHaveProperty('results'); - expect(ev.proof.status).toBe('denied'); - expect(ev.proof.crypto.signedBy).toBe('Portal authorization gate; no authority Notary called'); + expect(evaluation.result).toMatchObject({ + state: 'error', + reasonCode: 'relationship_not_proven' + }); + expect(evaluation.raw.response.status).toBe(403); + expect(evaluation.proof.crypto.signedBy).toBe( + 'Portal authorization gate; authority Evidence was not called' + ); }); - it('denies a delegated civil read when the guardian flag is omitted (deny by default)', async () => { - const ev = await provider.evaluateDetailed(field('date-of-birth'), ctx); - expect(ev.result.state).toBe('error'); - expect(ev.raw.response.status).toBe(403); - expect(ev.raw.response.body).not.toHaveProperty('results'); - expect(ev.proof.status).toBe('denied'); - }); - - it('authorizes the civil read once the guardian link is proven', async () => { - const ev = await provider.evaluateDetailed(field('date-of-birth'), ctx, { - guardianLinkVerified: true - }); - expect(ev.result.state).toBe('verified'); - expect(ev.raw.response.status).toBe(200); - // The portal gate authorizes the read before the clean federator request is built. - // Child scenarios use the application request variant of the provider union. - const body = ev.raw.request.body as RawChildBenefitRequest; - expect(body.on_behalf_of).toBeUndefined(); - expect(body.target.identifiers[0].value).toBe(PERSONA.mateo); - expect(body.variables).toEqual({ as_of_date: '2026-01-15' }); + it('retains the attempted selector only server-side on a cross-person denial', async () => { + const evaluation = await provider.evaluateDetailed(field('denial'), ctx); + const request = evaluation.raw.request.body as RawEvidenceRequest; + expect(evaluation.result.reasonCode).toBe('not_authorized'); + expect(request.subjects[0].selector.values.solmara_uin).toBe(PERSONA.karim); + expect(evaluation.raw.response.body).not.toHaveProperty('protected'); + expect(evaluation.raw.response.body).not.toHaveProperty('signed_evidence'); }); }); -describe('resilience states', () => { - it('flags a slow call (>= 6s budget) before it resolves verified', async () => { - const ev = await provider.evaluateDetailed(field('slow'), ctx); - expect(ev.timing.slow).toBe(true); - expect(ev.result.state).toBe('verified'); - }); +describe('authority Evidence wire contract', () => { + it('uses EvidenceRequest v1 keys and the /v1/evidence route', async () => { + const evaluation = await provider.evaluateDetailed(field('registered-farmer'), ctx); + const request = evaluation.raw.request.body as RawEvidenceRequest; - it('lands an upstream failure in ERROR with no source read', async () => { - const ev = await provider.evaluateDetailed(field('error'), ctx); - expect(ev.result.state).toBe('error'); - expect(ev.raw.response.status).toBe(503); - expect(ev.raw.response.body).not.toHaveProperty('results'); - expect(ev.proof.crypto).toMatchObject({ - signedBy: 'No claim result; Social Registry Office was unavailable', - credential: 'No credential or evidence result returned' + expect(evaluation.raw.request.url).toBe('https://nagdi-evidence.solmara.registrystack.org/v1/evidence'); + expect(Object.keys(request)).toEqual([ + 'requestNonce', + 'requirement', + 'purpose', + 'subjects' + ]); + expect(request.requestNonce).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(request.requirement).toBe('urn:solmara:requirement:farmer-registered:v1'); + expect(request.purpose).toBe('voucher-eligibility-review'); + expect(request.subjects[0]).toMatchObject({ + role: 'subject', + selector: { profile: 'solmara-farmer-v1' } }); }); - it('marks a fetched-but-old value as stale', async () => { - const ev = await provider.evaluateDetailed(field('stale'), ctx); - expect(ev.result.state).toBe('stale'); - const view = ev.raw.response.body as { results: { issued_at: string; expires_at: string }[] }; - expect(Date.parse(view.results[0].issued_at)).toBeLessThan(Date.parse(view.results[0].expires_at)); - expect(Date.parse(view.results[0].expires_at)).toBeLessThan(Date.now()); - }); + it('returns a flattened JWS carrying an Evidence assertion payload', async () => { + const evaluation = await provider.evaluateDetailed(field('registered-farmer'), ctx); + const response = evaluation.raw.response.body as RawEvidenceResponse; + expect(Object.keys(response)).toEqual(['protected', 'payload', 'signature']); + expect(response.signature).toMatch(/^[A-Za-z0-9_-]{86}$/); - it('never collapses an ambiguous match to false', async () => { - const ev = await provider.evaluateDetailed(field('ambiguous'), ctx); - expect(ev.result.state).toBe('ambiguous'); + const assertion = decodeEvidencePayload(response); + expect(assertion).toMatchObject({ + schema: 'registry.assertion-evidence/v1', + assuranceProfile: 'evidence-grade', + subjectBinding: 'audience-scoped', + type: 'Evidence', + supportsRequirement: 'urn:solmara:requirement:farmer-registered:v1', + purpose: 'voucher-eligibility-review' + }); + expect(assertion.supportedValues).toEqual([ + { providesValueFor: 'urn:solmara:concept:farmer-registered', value: true } + ]); + expect(EVIDENCE_RESPONSE_FORMAT).toBe('application/jose+json'); + expect(evaluation.proof.crypto).toMatchObject({ + algorithm: 'Flattened JWS, ES256', + issuerKey: '/.well-known/evidence/jwks.json', + credential: 'Signed minimum-disclosure Evidence assertion' + }); }); -}); -describe('structural match to the Notary OpenAPI', () => { - // The depth-2 bodies must carry the same key set as EvaluateRequest / - // EvaluationResponse -> ClaimResultView. We assert the required key sets here so - // a drift from the OpenAPI fails loudly. - it('request body matches EvaluateRequest key set', async () => { - const ev = await provider.evaluateDetailed(field('registered-farmer'), ctx); - // The agriculture scenario uses a single Registry Notary request. - const body = ev.raw.request.body as Record; - expect(Object.keys(body)).toEqual([ - 'claims', - 'purpose', - 'disclosure', - 'format', - 'relationship', - 'target' + it('encodes a reviewed decision object as an Evidence structured value', async () => { + const evaluation = await provider.evaluateDetailed(field('voucher-eligibility'), ctx); + const assertion = decodeEvidencePayload(evaluation.raw.response.body as RawEvidenceResponse); + expect(assertion.supportedValues).toEqual([ + { + providesValueFor: 'urn:solmara:concept:eligible-for-climate-smart-input-voucher', + value: { + form: 'reviewed-structured-value', + schema: 'urn:solmara:value-schema:eligible-for-climate-smart-input-voucher:v1', + fields: { eligible: true, voucher_tier: 'standard' } + } + } ]); - expect(body.claims).toEqual(['farmer-registered']); + expect(JSON.stringify(assertion)).not.toContain('parcel'); }); - it('200 response result matches the ClaimResultView required key set', async () => { - const ev = await provider.evaluateDetailed(field('registered-farmer'), ctx); - // The agriculture scenario uses the mock ClaimResultView response variant. - const view = (ev.raw.response.body as { results: Record[] }).results[0]; - // every required key from the OpenAPI ClaimResultView is present - for (const key of [ - 'evaluation_id', - 'claim_id', - 'claim_version', - 'subject_type', - 'target_ref', - 'value', - 'satisfied', - 'disclosure', - 'format', - 'issued_at', - 'expires_at', - 'provenance' - ]) { - expect(view).toHaveProperty(key); - } - // Provenance is asserted structurally after the required top-level key check. - const prov = view.provenance as Record; - expect(prov).toHaveProperty('schema_version', 'registry-notary-claim-provenance/v2'); - expect(prov).toHaveProperty('generated_by'); - expect(prov).toHaveProperty('used'); - expect(prov).toHaveProperty('derived_from'); + it('keeps authority Evidence service identity aligned with each legal issuer', async () => { + const evaluation = await provider.evaluateDetailed(field('combined-support-eligibility'), ctx); + const response = evaluation.raw.response.body as RawApplicationResponse; + expect(response.signed_evidence.map((source) => source.service_id)).toEqual([ + 'cra-evidence', + 'sipf-evidence', + 'sipf-evidence' + ]); + expect( + response.signed_evidence.map((source) => decodeEvidencePayload(source.assertion).issuedBy) + ).toEqual([ + 'did:web:id.registrystack.org:solmara:authority:cra', + 'did:web:id.registrystack.org:solmara:authority:sipf', + 'did:web:id.registrystack.org:solmara:authority:sipf' + ]); + expect( + response.signed_evidence.map((source) => decodeEvidencePayload(source.assertion).providedBy) + ).toEqual([ + 'https://cra-evidence.solmara.registrystack.org/', + 'https://sipf-evidence.solmara.registrystack.org/', + 'https://sipf-evidence.solmara.registrystack.org/' + ]); }); - it('uses the ordinary child application evidence request and source-attributed proof contract', async () => { - const ev = await provider.evaluateDetailed(field('date-of-birth'), ctx, { + it('uses the CRA Evidence endpoint and signed proof contract for child predicates', async () => { + const evaluation = await provider.evaluateDetailed(field('date-of-birth'), ctx, { guardianLinkVerified: true }); - expect(ev.raw.request).toEqual({ - method: 'POST', - url: 'https://child-benefit-federator.solmara.registrystack.org/v1/evaluations', - body: { - target: { - type: 'Person', - identifiers: [{ scheme: 'solmara_uin', value: PERSONA.mateo }] - }, - claims: ['child-age-under-5'], - disclosure: 'predicate', - format: CHILD_BENEFIT_FORMAT, - variables: { as_of_date: '2026-01-15' } - } - }); - expect(ev.proof.crypto).toMatchObject({ - signedBy: - 'Civil Registration Authority source result collected by child-benefit-federator', - algorithm: 'Ordinary JSON response; no application signature asserted', - issuerKey: 'Not applicable for an application evidence set', - holderBound: 'Purpose- and subject-bound child-benefit evidence request', - credential: 'Minimized source-attributed predicate result' + expect(evaluation.raw.request.url).toBe('https://cra-evidence.solmara.registrystack.org/v1/evidence'); + expect(evaluation.proof.crypto).toMatchObject({ + signedBy: 'Civil Registration Authority Evidence', + algorithm: 'Flattened JWS, ES256', + issuerKey: '/.well-known/evidence/jwks.json', + credential: 'Signed minimum-disclosure Evidence assertion' }); - expect(JSON.stringify(ev.proof.crypto)).not.toMatch( - /federated|federation|SD-JWT|notary:citizen/ + }); +}); + +describe('resilience states', () => { + it('flags a slow call without changing its eventual verified result', async () => { + const evaluation = await provider.evaluateDetailed(field('slow'), ctx); + expect(evaluation.timing.slow).toBe(true); + expect(evaluation.result.state).toBe('verified'); + }); + + it('scopes an unavailable Evidence service to the field', async () => { + const evaluation = await provider.evaluateDetailed(field('error'), ctx); + expect(evaluation.result.state).toBe('error'); + expect(evaluation.raw.response.status).toBe(503); + expect(evaluation.proof.crypto.signedBy).toBe( + 'No Evidence assertion; Social Registry Office was unavailable' ); }); + + it('never collapses an ambiguous source match to false', async () => { + const evaluation = await provider.evaluateDetailed(field('ambiguous'), ctx); + expect(evaluation.result.state).toBe('ambiguous'); + }); + + it('preserves stale assertion timestamps for the freshness warning', async () => { + const evaluation = await provider.evaluateDetailed(field('stale'), ctx); + const assertion = decodeEvidencePayload(evaluation.raw.response.body as RawEvidenceResponse); + expect(evaluation.result).toMatchObject({ state: 'stale', asOf: '2025-09-30' }); + expect(Date.parse(assertion.issuedAt)).toBeLessThan(Date.parse(assertion.validUntil)); + expect(Date.parse(assertion.validUntil)).toBeLessThan(Date.now()); + }); + + it('returns the current Evidence problem shape when the authority service is unavailable', async () => { + const evaluation = await provider.evaluateDetailed(field('error'), ctx); + expect(evaluation.result.reasonCode).toBe('service_unavailable'); + expect(evaluation.raw.response.body).toMatchObject({ + type: 'https://registrystack.org/problems/evidence/service_unavailable', + title: 'Authority Evidence is unavailable', + status: 503 + }); + expect(evaluation.raw.response.body).not.toHaveProperty('signature'); + }); }); diff --git a/portal/src/lib/providers/mock/index.ts b/portal/src/lib/providers/mock/index.ts index 744cfd2..5174a95 100644 --- a/portal/src/lib/providers/mock/index.ts +++ b/portal/src/lib/providers/mock/index.ts @@ -3,11 +3,11 @@ // per field/claim, with deterministic latency and a top-to-bottom stagger. // // The depth-2 request/response bodies inside each ProofTrace are built by ./wire -// from the owning service contract. Notary services keep their evaluation shape; -// child benefit uses its ordinary source-attributed JSON application shape. +// from the current authority Evidence request and signed-assertion contracts. import type { EvaluateContext, EvidenceProvider } from '$lib/providers/EvidenceProvider'; -import type { ClaimResult, Field, ProofStatus, ProofTrace } from '$lib/types'; +import type { ClaimResult, EvidencePresentation, EvidenceSource, Field, ProofStatus, ProofTrace } from '$lib/types'; +import { evidencePresentation } from '$lib/fields/authorities'; import { PERSONA, SCENARIOS, type ScenarioResult } from './scenarios'; import { authorityPlan, @@ -16,18 +16,16 @@ import { } from '$lib/providers/authority-plan'; import { authorityLabel, - buildClaimResultView, - buildChildBenefitRequest, - buildChildBenefitResponse, - buildEvaluationRequest, + buildEvidenceAssertion, + buildEvidenceRequest, buildRawRequest, buildRawResponse, - makeEvaluationId, - notaryUrl, + evidenceUrl, + makeOperationId, + signEvidence, type RawApplicationRequest, type RawApplicationResponse, - type RawProviderRequest, - type RawProviderResponse + type RawProviderRequest } from './wire'; // Optional knobs for delegated / denial selection the BFF passes via ctx-derived @@ -43,13 +41,13 @@ export type EvaluateOptions = { }; // The richer evaluate result the BFF consumes: the portal-facing ClaimResult plus -// the RAW (un-redacted) wire request/response and depth-1/3 proof material. The -// BFF redacts the raw bodies before teeing them to the feed. +// the raw server-only request/response and safe proof presentation. Raw values +// are never copied into the browser-facing ProofTrace. export type MockEvaluation = { result: ClaimResult; raw: { - request: { method: string; url: string; body: RawProviderRequest }; - response: { status: number; body: RawProviderResponse | DenialBody }; + request: { method: string; url: string; body: unknown }; + response: { status: number; body: unknown }; }; proof: { headline: string; @@ -57,13 +55,21 @@ export type MockEvaluation = { notDisclosed: string; status: ProofTrace['status']; authority: ProofTrace['authority']; + purpose: string; + presentations: EvidencePresentation[]; crypto: NonNullable; }; // deterministic timing for the UI stagger / SLOW threshold choreography. timing: { latencyMs: number; staggerOrder: number; slow: boolean }; }; -type DenialBody = { error: string; error_description: string }; +type DenialBody = { + type: string; + title: string; + status: number; + detail: string; + operation: string; +}; const SLOW_THRESHOLD_MS = 6000; @@ -84,68 +90,52 @@ export function resolveScenarioKey(field: Field, opts?: EvaluateOptions): string function resolveSubject(scenario: ScenarioResult, ctx: EvaluateContext, key: string): string { if (key === 'denial') return PERSONA.karim; if (scenario.subjectPersona) return PERSONA[scenario.subjectPersona]; - if (scenario.notary === 'agri') return PERSONA.aminaFarmer; + if (scenario.authority === 'agri') return PERSONA.aminaFarmer; if (scenario.delegated) return ctx.delegatedTarget ?? PERSONA.mateo; return ctx.subject; } function buildCrypto( scenario: ScenarioResult, - evaluationId: string, + _evaluationId: string, plan: AuthorityPlan[] ): NonNullable { - if (scenario.service === 'childBenefit') { - return { - signedBy: `${authorityLabel(scenario)} source result collected by child-benefit-federator`, - algorithm: 'Ordinary JSON response; no application signature asserted', - issuerKey: 'Not applicable for an application evidence set', - holderBound: 'Purpose- and subject-bound child-benefit evidence request', - credential: 'Minimized source-attributed predicate result', - auditId: `evidence-set:cbe_${evaluationId}` - }; - } if (isApplicationOwnedPlan(plan)) { const authorities = [...new Set(plan.map((authority) => authority.authority))]; return { - signedBy: `No credential issued; ${authorities.join(' and ')} returned separate claim evaluations`, - algorithm: 'Independent Registry Notary claim-result responses', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Not credential-bound; the BFF selected the purpose and subject', - credential: 'Application decision only; no credential issued by the portal', - auditId: plan - .map((_authority, index) => `evaluation:${evaluationId}-${index + 1}`) - .join('; ') + signedBy: `${authorities.join(' and ')} returned separate authority Evidence assertions`, + algorithm: 'Independent flattened JWS assertions, ES256', + issuerKey: 'Each Evidence service publishes /.well-known/evidence/jwks.json', + holderBound: 'Audience-scoped to the portal requester, purpose, nonce, and subject binding', + credential: 'Signed minimum-disclosure assertions; the portal composed the decision', }; } return { - signedBy: `No credential issued; ${authorityLabel(scenario)} returned a claim result`, - algorithm: 'Registry Notary claim-result response', - issuerKey: 'Not applicable for claim-result evaluation', - holderBound: 'Not credential-bound; the BFF selected the purpose and subject', - credential: 'Claim result only; no credential issued', - auditId: `evaluation:${evaluationId}` + signedBy: `${authorityLabel(scenario)} Evidence`, + algorithm: 'Flattened JWS, ES256', + issuerKey: '/.well-known/evidence/jwks.json', + holderBound: 'Audience-scoped to the portal requester, purpose, nonce, and subject binding', + credential: 'Signed minimum-disclosure Evidence assertion' }; } -function blockedCrypto(evaluationId: string): NonNullable { +function blockedCrypto(_evaluationId: string): NonNullable { return { - signedBy: 'Portal authorization gate; no authority Notary called', - algorithm: 'No signature; request stopped before source access', + signedBy: 'Portal authorization gate; authority Evidence was not called', + algorithm: 'No Evidence assertion was produced', issuerKey: 'Not applicable', holderBound: 'Portal session actor and server-selected subject', - credential: 'No credential or evidence result returned', - auditId: `denial:${evaluationId}` + credential: 'No credential or Evidence assertion returned' }; } function unavailableCrypto(scenario: ScenarioResult): NonNullable { return { - signedBy: `No claim result; ${authorityLabel(scenario)} was unavailable`, + signedBy: `No Evidence assertion; ${authorityLabel(scenario)} was unavailable`, algorithm: 'No response proof available', issuerKey: 'Not applicable', holderBound: 'The BFF selected the purpose and subject', - credential: 'No credential or evidence result returned', - auditId: 'No evaluation identifier returned' + credential: 'No credential or Evidence assertion returned' }; } @@ -184,7 +174,7 @@ export class MockEvidenceProvider implements EvidenceProvider { const subject = resolveSubject(scenario, ctx, key); const seq = ++this.#seq; - const evaluationId = makeEvaluationId(seq); + const evaluationId = makeOperationId(seq); const issuedAt = scenario.state === 'stale' ? new Date(`${scenario.asOf}T00:00:00.000Z`) @@ -202,32 +192,22 @@ export class MockEvidenceProvider implements EvidenceProvider { ? buildApplicationExchange(scenario, plan, subject, evaluationId, issuedAt) : undefined; const rawRequest = - applicationExchange?.request ?? buildProviderRequest(scenario, subject, ctx.subject); + applicationExchange?.request ?? buildProviderRequest(scenario, subject); // Denial / error scenarios perform NO source read: there is no 200 body. if (scenario.httpStatus === 403) { - return this.#denied(field, scenario, ctx, key, scenario.denial?.code ?? 'subject_mismatch'); + return this.#denied(field, scenario, ctx, key, scenario.denial?.code ?? 'not_authorized'); } if (scenario.httpStatus === 503) { return this.#errored(field, scenario, ctx, key, rawRequest); } - const rawResponse = - applicationExchange?.response ?? - (scenario.service === 'childBenefit' - ? buildChildBenefitResponse( - scenario, - evaluationId, - issuedAt, - // The service branch above guarantees the child application request shape. - (rawRequest as ReturnType).target - ) - : buildRawResponse(scenario, evaluationId, issuedAt)); + const rawResponse = applicationExchange?.response ?? buildRawResponse(scenario, evaluationId, issuedAt); const result: ClaimResult = { state: scenario.state, display: scenario.display, - ...(!applicationOwned ? { authority: scenario.notary } : {}), + ...(!applicationOwned ? { authority: scenario.authority } : {}), asOf: scenario.asOf, ...(scenario.reasonCode ? { reasonCode: scenario.reasonCode } : {}), traceId: `event ${seq}` @@ -240,7 +220,7 @@ export class MockEvidenceProvider implements EvidenceProvider { method: applicationOwned ? 'MULTI' : 'POST', url: applicationOwned ? 'solmara://citizen-portal/application-composition' - : notaryUrl(scenario), + : evidenceUrl(scenario), body: rawRequest }, response: { status: scenario.httpStatus, body: rawResponse } @@ -250,7 +230,11 @@ export class MockEvidenceProvider implements EvidenceProvider { answered: scenario.answered, notDisclosed: scenario.notDisclosed, status: scenario.status, - authority: applicationOwned ? undefined : scenario.notary, + authority: applicationOwned ? undefined : scenario.authority, + purpose: scenario.purpose, + presentations: applicationOwned + ? plan.map((entry) => evidencePresentation(entry.authorityId, entry.source)) + : [evidencePresentation(scenario.authority, sourceForScenario(scenario))], crypto: buildCrypto(scenario, evaluationId, plan) }, timing: { @@ -270,16 +254,18 @@ export class MockEvidenceProvider implements EvidenceProvider { code: string ): MockEvaluation { const seq = ++this.#seq; - const evaluationId = makeEvaluationId(seq); + const evaluationId = makeOperationId(seq); // We still show the REQUEST the BFF attempted, with the stranger target, so the // inspector shows what was asked, then the 403 with no source read. The target // is redacted before it ever reaches the feed. const subject = scenario.denial ? PERSONA.karim : (scenario.subjectPersona ? PERSONA[scenario.subjectPersona] : PERSONA.mateo); - const rawRequest = buildProviderRequest(scenario, subject, _ctx.subject); + const rawRequest = buildProviderRequest(scenario, subject); const denialBody: DenialBody = { - error: code, - error_description: - scenario.denial?.message ?? 'requester is not authorized for this target' + type: `urn:solmara:portal:problem:${code}`, + title: 'Portal authorization denied the request', + status: 403, + detail: scenario.denial?.message ?? 'requester is not authorized for this target', + operation: evaluationId }; const result: ClaimResult = { state: 'error', @@ -309,6 +295,8 @@ export class MockEvidenceProvider implements EvidenceProvider { notDisclosed: scenario.notDisclosed, status: 'denied', authority: undefined, + purpose: scenario.purpose, + presentations: [], crypto: blockedCrypto(evaluationId) }, timing: { @@ -330,20 +318,23 @@ export class MockEvidenceProvider implements EvidenceProvider { ): MockEvaluation { const seq = this.#seq; // already incremented by the caller const errBody: DenialBody = { - error: 'upstream_unavailable', - error_description: `could not reach ${authorityLabel(scenario)}` + type: 'https://registrystack.org/problems/evidence/service_unavailable', + title: 'Authority Evidence is unavailable', + status: 503, + detail: `could not reach ${authorityLabel(scenario)}`, + operation: `unavailable:${seq}` }; const result: ClaimResult = { state: 'error', display: scenario.display, - authority: scenario.notary, + authority: scenario.authority, reasonCode: scenario.reasonCode, traceId: `event ${seq}` }; return { result, raw: { - request: { method: 'POST', url: notaryUrl(scenario), body: rawRequest }, + request: { method: 'POST', url: evidenceUrl(scenario), body: rawRequest }, response: { status: 503, body: errBody } }, proof: { @@ -351,7 +342,9 @@ export class MockEvidenceProvider implements EvidenceProvider { answered: scenario.answered, notDisclosed: scenario.notDisclosed, status: 'error', - authority: scenario.notary, + authority: scenario.authority, + purpose: scenario.purpose, + presentations: [evidencePresentation(scenario.authority, sourceForScenario(scenario))], crypto: unavailableCrypto(scenario) }, timing: { @@ -376,37 +369,19 @@ function buildApplicationExchange( purposes.length === 1 ? (purposes[0] ?? '') : 'application-composed-from-source-authorized-purposes', - disclosure: 'decision', - composition: 'application', - requests: plan.map((authority) => ({ + composition: 'portal-application', + requests: plan.map((authority, index) => ({ authority: authority.authority, service_id: authority.serviceId, - body: buildEvaluationRequest( + body: buildEvidenceRequest( authority.claimId, subject, authority.purpose, - authority.scheme ?? 'solmara_uin' + authority.scheme ?? 'solmara_uin', + index + 1 ) })) }; - const results = plan.map((authority, index) => - buildClaimResultView( - { - claimId: authority.claimId, - claimVersion: scenario.claimVersion, - serviceId: authority.serviceId, - subjectType: 'person', - satisfied: true, - value: true, - disclosure: 'predicate', - sourceCount: 1, - identifierScheme: authority.scheme ?? 'solmara_uin', - freshnessDays: scenario.freshnessDays - }, - `${evaluationId}-${index + 1}`, - issuedAt - ) - ); return { request, response: { @@ -415,12 +390,27 @@ function buildApplicationExchange( service_id: 'citizen-portal', decision: 'application_composed' }, - results, + signed_evidence: plan.map((authority, index) => ({ + authority: authority.authority, + service_id: authority.serviceId, + assertion: signEvidence( + buildEvidenceAssertion( + scenario, + `${evaluationId}-${index + 1}`, + issuedAt, + authority.claimId, + authority.serviceId, + true, + authority.authorityId + ), + authority.serviceId + ) + })), source_trace: plan.map((authority) => ({ authority: authority.authority, service_id: authority.serviceId, status: 200, - claims: [authority.claimId] + requirements: [`urn:solmara:requirement:${authority.claimId}:v1`] })), derived_decisions: { [scenario.claimId]: scenario.satisfied } } @@ -429,32 +419,25 @@ function buildApplicationExchange( function buildProviderRequest( scenario: ScenarioResult, - subject: string, - actorSubject: string + subject: string ): RawProviderRequest { - if (scenario.service === 'childBenefit') { - return buildChildBenefitRequest(scenario, subject); - } - return buildRawRequest(scenario, subject, { - actorIdHash: scenario.delegated ? hashActor(actorSubject) : undefined, - delegationRef: scenario.delegated ? 'rnref:v1:REL-1001-MOTHER' : undefined - }); -} - -// A keyed-hash placeholder for an actor id. Never the raw principal; matches the -// id_hash wire shape (hmac-sha256:). Deterministic for the mock. -function hashActor(subject: string): string { - let h = 0; - for (let i = 0; i < subject.length; i++) { - h = (h * 31 + subject.charCodeAt(i)) & 0xffffffff; - } - return `hmac-sha256:${(h >>> 0).toString(16).padStart(8, '0')}`; + return buildRawRequest(scenario, subject); } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function sourceForScenario(scenario: ScenarioResult): EvidenceSource { + if ( + scenario.service === 'childBenefit' && + ['childCivil', 'population', 'socialRegistry'].includes(scenario.authority) + ) { + return 'immutable extract'; + } + return 'Relay lookup'; +} + export type { ProofStatus }; // Re-export the canned scenario keys so the BFF / state gallery can enumerate the diff --git a/portal/src/lib/providers/mock/scenarios.test.ts b/portal/src/lib/providers/mock/scenarios.test.ts index f568499..a823a3b 100644 --- a/portal/src/lib/providers/mock/scenarios.test.ts +++ b/portal/src/lib/providers/mock/scenarios.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { AUTHORITY_LABEL, NOTARY_SERVICE_ID, SCENARIOS } from './scenarios'; +import { AUTHORITY_LABEL, EVIDENCE_SERVICE_ID, SCENARIOS } from './scenarios'; // The depth-1 proof feed always names the authority that answered. Every // "answered" line must open with that authority's canonical // label (the same label the field badge, ministry rail, and proof crypto -// "signedBy" already use for that notary id), never an operational short +// "signedBy" already use for that authority id), never an operational short // name or nickname. describe('SCENARIOS answered lines use the canonical authority label', () => { for (const [key, scenario] of Object.entries(SCENARIOS)) { - const label = AUTHORITY_LABEL[scenario.notary]; + const label = AUTHORITY_LABEL[scenario.authority]; it(`"${key}" attributes its answered line to its actual owner`, () => { expect( scenario.answered.startsWith( - scenario.applicationOwned ? 'Portal application answered:' : `${label} answered:` + scenario.status === 'denied' + ? 'Portal authorization gate answered:' + : scenario.applicationOwned + ? 'Portal application answered:' + : `${label} answered:` ) ).toBe(true); }); @@ -30,7 +34,7 @@ describe('pension purpose ownership', () => { }); describe('child-benefit source ownership', () => { - it('uses five predicates from four source-owned Notaries without a composed eligibility scenario', () => { + it('uses five predicates from four authority-owned Evidence services without a composed eligibility scenario', () => { const scenarios = [ SCENARIOS['birth-event-exists'], SCENARIOS['population-record-active'], @@ -46,16 +50,17 @@ describe('child-benefit source ownership', () => { 'household-below-poverty-threshold', 'not-already-enrolled' ]); - expect(new Set(scenarios.map((scenario) => scenario.notary))).toEqual( + expect(new Set(scenarios.map((scenario) => scenario.authority))).toEqual( new Set(['childCivil', 'population', 'socialRegistry', 'programme']) ); expect(SCENARIOS['eligible-for-child-benefit']).toBeUndefined(); - expect(scenarios.map((scenario) => NOTARY_SERVICE_ID[scenario.notary])).toEqual([ - 'cra-notary', - 'nia-notary', - 'cra-notary', - 'sro-notary', - 'programme-notary' + expect(scenarios.map((scenario) => EVIDENCE_SERVICE_ID[scenario.authority])).toEqual([ + 'cra-evidence', + 'nia-evidence', + 'cra-evidence', + 'sro-evidence', + 'mosd-programme-evidence' ]); + expect(new Set(scenarios.map((scenario) => EVIDENCE_SERVICE_ID[scenario.authority])).size).toBe(4); }); }); diff --git a/portal/src/lib/providers/mock/scenarios.ts b/portal/src/lib/providers/mock/scenarios.ts index 9d31f1b..c28054f 100644 --- a/portal/src/lib/providers/mock/scenarios.ts +++ b/portal/src/lib/providers/mock/scenarios.ts @@ -1,19 +1,18 @@ // Canned scenarios driving the Phase 0 mock. Each scenario is keyed by a stable // lookup id (field id, with a few delegated/denial variants) and carries enough // to build both a ClaimResult and a ProofTrace using the owning service's real -// wire contract: Registry Notary evaluations or the child federator bundle. +// wire contract: signed authority Evidence assertions or a portal composition. // // Volatile fields (evaluation_id, issued_at/expires_at, signatures, freshness) // are present but value-variable: they are stamped at evaluate() time, never // byte-matched. Everything else (key set, types, ordering) matches the OpenAPI. -import type { FieldState, NotaryId, ProofStatus } from '$lib/types'; +import type { AuthorityId, FieldState, ProofStatus } from '$lib/types'; import { SOLMARA_AUTHORITIES } from '$lib/fields/authorities'; import { PURPOSES } from '$lib/forms/descriptors'; -// Map a portal NotaryId to the human authority label the Notary returns and the -// proof inspector shows. Single canonical name per authority. -export const AUTHORITY_LABEL: Record = { +// Map a portal authority id to the human label shown by the proof inspector. +export const AUTHORITY_LABEL: Record = { civil: SOLMARA_AUTHORITIES.civil.label, social: SOLMARA_AUTHORITIES.social.label, agri: SOLMARA_AUTHORITIES.agri.label, @@ -24,8 +23,8 @@ export const AUTHORITY_LABEL: Record = { programme: SOLMARA_AUTHORITIES.programme.label }; -// Per-notary service id that appears in provenance.generated_by.service_id. -export const NOTARY_SERVICE_ID: Record = { +// Authority-owned Evidence service shown in request provenance. +export const EVIDENCE_SERVICE_ID: Record = { civil: SOLMARA_AUTHORITIES.civil.serviceId, social: SOLMARA_AUTHORITIES.social.serviceId, agri: SOLMARA_AUTHORITIES.agri.serviceId, @@ -36,27 +35,26 @@ export const NOTARY_SERVICE_ID: Record = { programme: SOLMARA_AUTHORITIES.programme.serviceId }; -// What the Notary sends back as source_authority / the proof "answered" line. -// The `disclosure` mirrors the EvaluateRequest.disclosure on the wire. +// The disclosure mode drives the minimized value represented by the assertion. export type ScenarioDisclosure = 'predicate' | 'value' | 'object' | 'decision'; // The depth-2 response value (the ClaimResultView.value). The runtime may return // any JSON value; we keep it as unknown so booleans, dates, and objects all fit. export type ScenarioResult = { // ---- routing / lookup ---- - notary: NotaryId; + authority: AuthorityId; service: 'childBenefit' | 'pension' | 'nagdi' | 'citizen'; claimId: string; // the wire claim id, e.g. 'farmer-registered' claimVersion: string; subjectPersona?: PersonaKey; applicationOwned?: boolean; - // ---- request shaping (EvaluateRequest) ---- + // ---- Authority Evidence request shaping ---- purpose: string; // declared purpose disclosure: ScenarioDisclosure; // delegated scenarios send on_behalf_of + relationship:guardian, and read a // dependent subject. Non-delegated scenarios are relationship:self. delegated?: boolean; - // ---- response / claim-result shaping (ClaimResultView) ---- + // ---- response / assertion projection ---- value: unknown; // boolean | string (date) | object summary satisfied: boolean | null; // null for plain value/object fetches subjectType: string; // 'person' | 'household' | 'holding' @@ -66,7 +64,7 @@ export type ScenarioResult = { state: FieldState; // resulting FieldState display: string; // the value/predicate sentence shown in the field reasonCode?: string; // e.g. 'VR-RED-02' - reasonCodes?: { code: string; authority: NotaryId; text: string }[]; // decisions + reasonCodes?: { code: string; authority: AuthorityId; text: string }[]; // decisions // ---- proof depth-1 copy ---- headline: string; // consequence-first answered: string; // "{Authority} answered: {claim} = {value}" @@ -74,7 +72,7 @@ export type ScenarioResult = { status: ProofStatus; // ---- denial / error shaping ---- httpStatus: number; // 200 normally; 403 denial; 503 error - denial?: { code: string; message: string }; // for the subject_mismatch beat + denial?: { code: string; message: string }; // for the not_authorized beat // ---- resilience flavor ---- // latencyMs is the deterministic delay; staggerOrder gives the top-to-bottom // stagger so fields land in a believable cascade, never all at once. @@ -105,7 +103,7 @@ export const SCENARIOS: Record = { // farmer-voucher // --------------------------------------------------------------------------- 'registered-farmer': { - notary: 'agri', + authority: 'agri', service: 'nagdi', claimId: 'farmer-registered', claimVersion: '2026-07', @@ -129,7 +127,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'farm-holding': { - notary: 'agri', + authority: 'agri', service: 'nagdi', claimId: 'data-use-authorized-for-purpose', claimVersion: '2026-07', @@ -153,7 +151,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'voucher-eligibility': { - notary: 'agri', + authority: 'agri', service: 'nagdi', claimId: 'eligible-for-climate-smart-input-voucher', claimVersion: '2026-07', @@ -185,7 +183,7 @@ export const SCENARIOS: Record = { // child-benefit (guardian gate, then five source-owned predicates) // --------------------------------------------------------------------------- 'caregiver-link': { - notary: 'childCivil', + authority: 'childCivil', service: 'childBenefit', claimId: 'birth-is-registered', claimVersion: '2026-07', @@ -211,7 +209,7 @@ export const SCENARIOS: Record = { // The source reads below are only authorized after the caregiver-link verify // above succeeds. The provider enforces this gate. 'birth-event-exists': { - notary: 'childCivil', + authority: 'childCivil', service: 'childBenefit', claimId: 'birth-is-registered', claimVersion: '2026-07', @@ -236,7 +234,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'population-record-active': { - notary: 'population', + authority: 'population', service: 'childBenefit', claimId: 'population-record-active', claimVersion: '2026-07', @@ -251,7 +249,7 @@ export const SCENARIOS: Record = { asOf: '2026-06-15', state: 'verified', display: 'Population record active: yes', - headline: 'Confirmed by the National Identity Agency through a source-owned Notary', + headline: 'Confirmed by National Identity Agency Evidence', answered: 'National Identity Agency answered: population-record-active = true', notDisclosed: 'Not disclosed: identity attributes or population register row', status: 'ok', @@ -261,7 +259,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'date-of-birth': { - notary: 'childCivil', + authority: 'childCivil', service: 'childBenefit', claimId: 'child-age-under-5', claimVersion: '2026-07', @@ -286,7 +284,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'household-composition': { - notary: 'socialRegistry', + authority: 'socialRegistry', service: 'childBenefit', claimId: 'household-below-poverty-threshold', claimVersion: '2026-07', @@ -300,7 +298,7 @@ export const SCENARIOS: Record = { asOf: '2026-05-09', state: 'verified', display: 'Household below threshold: yes', - headline: 'Confirmed by the Social Registry Office through its source-owned Notary', + headline: 'Confirmed by Social Registry Office Evidence', answered: 'Social Registry Office answered: household-below-poverty-threshold = true', notDisclosed: 'Not disclosed: poverty score or household roster', status: 'ok', @@ -310,7 +308,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'not-already-enrolled': { - notary: 'programme', + authority: 'programme', service: 'childBenefit', claimId: 'not-already-enrolled', claimVersion: '2026-07', @@ -339,7 +337,7 @@ export const SCENARIOS: Record = { // pension-survivor (multi-authority decision) // --------------------------------------------------------------------------- 'person-is-alive': { - notary: 'civil', + authority: 'civil', service: 'pension', claimId: 'person-is-deceased', claimVersion: '2026-07', @@ -363,7 +361,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'disability-determination': { - notary: 'social', + authority: 'social', service: 'pension', claimId: 'pension-payment-should-stop', claimVersion: '2026-07', @@ -388,7 +386,7 @@ export const SCENARIOS: Record = { sourceCount: 2 }, 'functioning-assessment': { - notary: 'social', + authority: 'social', service: 'pension', claimId: 'survivor-is-eligible', claimVersion: '2026-07', @@ -412,7 +410,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'pension-payment-active': { - notary: 'social', + authority: 'social', service: 'pension', claimId: 'pension-payment-active', claimVersion: '2026-07', @@ -436,7 +434,7 @@ export const SCENARIOS: Record = { sourceCount: 1 }, 'combined-support-eligibility': { - notary: 'social', + authority: 'social', service: 'pension', claimId: 'survivor-benefit-eligible', claimVersion: '2026-07', @@ -470,7 +468,7 @@ export const SCENARIOS: Record = { // citizen self-service gallery fixture // --------------------------------------------------------------------------- 'citizen-record-status': { - notary: 'certs', + authority: 'certs', service: 'citizen', claimId: 'citizen-self-service-ready', claimVersion: '2026-07', @@ -497,10 +495,10 @@ export const SCENARIOS: Record = { // --------------------------------------------------------------------------- // Denial beat (cross-person, stranger Karim 2300073046): a real denied - // evaluation, 403 subject_mismatch, NO source read. + // refusal, 403 not_authorized, NO source read. // --------------------------------------------------------------------------- denial: { - notary: 'civil', + authority: 'civil', service: 'pension', claimId: 'person-is-deceased', claimVersion: '2026-07', @@ -514,13 +512,13 @@ export const SCENARIOS: Record = { asOf: '2026-06-21', state: 'error', display: 'Denied: you cannot query this person', - reasonCode: 'subject_mismatch', + reasonCode: 'not_authorized', headline: 'Denied by Civil Registry before any record was read', - answered: 'Civil Registration Authority answered: 403 subject_mismatch, no data returned', + answered: 'Portal authorization gate answered: 403 not_authorized, no data returned', notDisclosed: 'Not disclosed: nothing, the boundary held and no source was read', status: 'denied', httpStatus: 403, - denial: { code: 'subject_mismatch', message: 'requester is not authorized for this target' }, + denial: { code: 'not_authorized', message: 'requester is not authorized for this target' }, latencyMs: 600, staggerOrder: 0, sourceCount: 0 @@ -532,7 +530,7 @@ export const SCENARIOS: Record = { // SLOW: a still-in-flight live call that crosses the ~6-8s SLOW threshold but // eventually resolves verified. The provider surfaces SLOW before VERIFIED. slow: { - notary: 'agri', + authority: 'agri', service: 'nagdi', claimId: 'farmer-registered', claimVersion: '2026-07', @@ -558,7 +556,7 @@ export const SCENARIOS: Record = { // ERROR: a hard failure (503). Scoped to the field, framed as minimization. No // source read, no value. error: { - notary: 'socialRegistry', + authority: 'socialRegistry', service: 'childBenefit', claimId: 'household-below-poverty-threshold', claimVersion: '2026-07', @@ -572,7 +570,7 @@ export const SCENARIOS: Record = { asOf: '2026-06-21', state: 'error', display: 'Could not reach Social Protection; other evidence is unaffected', - reasonCode: 'upstream_unavailable', + reasonCode: 'service_unavailable', headline: 'Could not reach Social Protection, the other authorities are unaffected', answered: 'Social Registry Office answered: 503, no data returned', notDisclosed: 'Not disclosed: nothing, there is no central lake so this failure is isolated', @@ -584,7 +582,7 @@ export const SCENARIOS: Record = { }, // STALE: fetched but older than the freshness rule (BLUE + AMBER flag). stale: { - notary: 'social', + authority: 'social', service: 'pension', claimId: 'survivor-is-eligible', claimVersion: '2026-07', @@ -609,7 +607,7 @@ export const SCENARIOS: Record = { }, // AMBIGUOUS: more than one record matched; never collapses to false. ambiguous: { - notary: 'civil', + authority: 'civil', service: 'pension', claimId: 'person-is-deceased', claimVersion: '2026-07', diff --git a/portal/src/lib/providers/mock/wire.ts b/portal/src/lib/providers/mock/wire.ts index 55f5c1b..7a64394 100644 --- a/portal/src/lib/providers/mock/wire.ts +++ b/portal/src/lib/providers/mock/wire.ts @@ -1,199 +1,72 @@ -// Wire-shape builders. Registry Notary services use EvaluateRequest and -// EvaluationResponse. Child benefit uses an ordinary application-owned JSON -// request and source-attributed evidence response. -// -// Volatile fields (evaluation_id, issued_at, expires_at) are stamped here from -// the passed clock/ids so they are present but value-variable. The live provider -// will emit the same shape; only the values differ. +// Authority Evidence wire-shape builders used by the portal mock. The request +// matches EvidenceRequest v1 and the response is a signed flattened JWS. The +// portal treats the encoded assertion as opaque transport data and projects the +// reviewed concept into its stable ClaimResult UI model. +import type { AuthorityId } from '$lib/types'; import type { ScenarioResult } from './scenarios'; -import { - AUTHORITY_LABEL, - NOTARY_SERVICE_ID -} from './scenarios'; +import { AUTHORITY_LABEL, EVIDENCE_SERVICE_ID } from './scenarios'; +import { SOLMARA_AUTHORITIES } from '$lib/fields/authorities'; -// A monotonic, demo-stable ULID-shaped id. Not a real ULID; deterministic so the -// mock is reproducible while still looking like the wire format. -export function makeEvaluationId(seed: number): string { +const REQUEST_NONCE_BASE = 'U29sbWFyYVJlZ2lzdHJ5RXZpZGVuY2VEZW1vMDAwMDA'; +const SIGNATURE = 'A'.repeat(86); + +export const EVIDENCE_RESPONSE_FORMAT = 'application/jose+json'; + +export function makeOperationId(seed: number): string { const base = '01HX7Y5F2WAJ7ZP0Q4M5K9E8N'; - const tail = (seed % 36).toString(36).toUpperCase(); - return `${base}${tail}`; + return `${base.slice(0, 25)}${(seed % 32).toString(32).toUpperCase()}`; } -// The full target/subject value the BFF holds server-side. This is the RAW shape -// the BFF builds and would send upstream; it is redacted before it ever reaches -// the proof feed. We expose it so the redaction test can prove the raw subject -// never appears in the streamed output. -export type RawEvaluateRequest = { - claims: string[]; - purpose: string; - disclosure: string; - format: string; - relationship: { type: string }; - target: { type: string; identifiers: { scheme: string; value: string }[] }; - on_behalf_of?: { actor: { type: string; id_hash: string }; delegation_ref: string }; +export type EvidenceSelector = { + profile: string; + values: Record; }; -export const CLAIM_RESULT_FORMAT = 'application/vnd.registry-notary.claim-result+json'; -export const CHILD_BENEFIT_FORMAT = 'application/json'; - -export type RawChildBenefitRequest = { - target: { type: 'Person'; identifiers: { scheme: 'solmara_uin'; value: string }[] }; - claims: string[]; - disclosure: 'predicate'; - format: typeof CHILD_BENEFIT_FORMAT; - variables?: { as_of_date: string }; - purpose?: never; - relationship?: never; - on_behalf_of?: never; +export type RawEvidenceRequest = { + requestNonce: string; + requirement: string; + purpose: string; + subjects: { role: string; selector: EvidenceSelector }[]; }; export type RawApplicationRequest = { purpose: string; - disclosure: 'decision'; - composition: 'application'; + composition: 'portal-application'; requests: { authority: string; service_id: string; - body: RawEvaluateRequest; + body: RawEvidenceRequest; }[]; }; -export type RawProviderRequest = - | RawEvaluateRequest - | RawChildBenefitRequest - | RawApplicationRequest; - -export function buildChildBenefitRequest( - scenario: ScenarioResult, - subject: string -): RawChildBenefitRequest { - return { - target: { - type: 'Person', - identifiers: [{ scheme: 'solmara_uin', value: subject }] - }, - claims: [scenario.claimId], - disclosure: 'predicate', - format: CHILD_BENEFIT_FORMAT, - ...(scenario.claimId === 'child-age-under-5' - ? { variables: { as_of_date: scenario.asOf } } - : {}) - }; -} - -export function buildEvaluationRequest( - claimId: string, - subject: string, - purpose: string, - scheme = 'solmara_uin' -): RawEvaluateRequest { - return { - claims: [claimId], - purpose, - disclosure: 'predicate', - format: CLAIM_RESULT_FORMAT, - relationship: { type: 'self' }, - target: { - type: 'Person', - identifiers: [{ scheme, value: subject }] - } - }; -} - -// Build the RAW EvaluateRequest the BFF would send. `subject` is the national id -// the BFF resolved server-side (never client-supplied). For delegated reads the -// subject is the dependent and actorIdHash binds the requesting guardian. -export function buildRawRequest( - scenario: ScenarioResult, - subject: string, - opts?: { actorIdHash?: string; delegationRef?: string } -): RawEvaluateRequest { - const req = buildEvaluationRequest( - scenario.claimId, - subject, - scenario.purpose, - subjectScheme(scenario) - ); - req.disclosure = scenario.disclosure; - req.relationship = { type: scenario.delegated ? 'guardian' : 'self' }; - if (scenario.delegated) { - req.on_behalf_of = { - actor: { - type: 'Person', - // Already a keyed hash placeholder; the redactor still re-hashes any raw - // id, but the wire shape carries id_hash, never a raw principal. - id_hash: opts?.actorIdHash ?? 'hmac-sha256:0000' - }, - delegation_ref: opts?.delegationRef ?? 'rnref:v1:caregiver-link' - }; - } - return req; -} - -// One ClaimResultView, structurally identical to the OpenAPI required key set and -// ordering. Volatile fields stamped from `clock`/`evaluationId`. -export type ClaimResultView = { - claim_id: string; - claim_version: string; - disclosure: string; - evaluation_id: string; - expires_at: string | null; - format: string; - issued_at: string; - provenance: { - derived_from: object[]; - generated_by: { - claim_id: string; - claim_version: string; - evaluation_id: string; - policy_hash?: string; - policy_id?: string; - policy_version?: string; - service_id: string; - type: 'claim_evaluation'; - }; - schema_version: 'registry-notary-claim-provenance/v2'; - used: { - relay_consultation_count: number; - }; - }; - satisfied: boolean | null; - subject_type: string; - target_ref: { - handle: string; - identifier_schemes?: string[]; - profile?: string; - type?: string; - }; - value: unknown; +export type RawProviderRequest = RawEvidenceRequest | RawApplicationRequest; + +export type EvidenceAssertion = { + schema: 'registry.assertion-evidence/v1'; + assuranceProfile: 'evidence-grade'; + subjectBinding: 'audience-scoped'; + requestNonce: string; + id: string; + type: 'Evidence'; + supportsRequirement: string; + isConformantTo: string; + issuedBy: string; + providedBy: string; + issuedAt: string; + observedAt: string; + validUntil: string; + purpose: string; + audience: string; + configurationRevision: string; + subjects: { role: string; binding: string }[]; + supportedValues: { providesValueFor: string; value: unknown }[]; }; -export type RawEvaluationResponse = { results: ClaimResultView[] }; - -export type ChildBenefitResult = { - claim_id: string; - claim_version: string; - format: string; - issued_at: string; - expires_at: string | null; - notary_service_id: string; - authority: string; - satisfied: boolean; - disclosure: 'predicate'; -}; - -export type RawChildBenefitResponse = { - schema_version: 'solmara-child-benefit-evidence/v1'; - evidence_set_id: string; - orchestration: { - service_id: 'child-benefit-federator'; - decision: 'not_composed'; - }; - purpose: string; - target: { type?: string; identifier_schemes: string[] }; - results: ChildBenefitResult[]; - source_trace: object[]; +export type RawEvidenceResponse = { + protected: string; + payload: string; + signature: string; }; export type RawApplicationResponse = { @@ -202,164 +75,160 @@ export type RawApplicationResponse = { service_id: 'citizen-portal'; decision: 'application_composed'; }; - results: ClaimResultView[]; + signed_evidence: { + authority: string; + service_id: string; + assertion: RawEvidenceResponse; + }[]; source_trace: { authority: string; service_id: string; status: number; - claims: string[]; + requirements: string[]; }[]; - derived_decisions?: Record; + derived_decisions: Record; }; -export type RawProviderResponse = - | RawEvaluationResponse - | RawChildBenefitResponse - | RawApplicationResponse; +export type RawProviderResponse = RawEvidenceResponse | RawApplicationResponse; -export type ClaimResultFixture = { - claimId: string; - claimVersion: string; - serviceId: string; - subjectType: string; - satisfied: boolean | null; - value: unknown; - disclosure: string; - sourceCount: number; - identifierScheme: string; - freshnessDays: number; -}; +export function requirementId(claimId: string): string { + return `urn:solmara:requirement:${claimId}:v1`; +} -export function buildChildBenefitResponse( - scenario: ScenarioResult, - evaluationId: string, - issuedAt: Date, - target: RawChildBenefitRequest['target'] -): RawChildBenefitResponse { - const serviceId = NOTARY_SERVICE_ID[scenario.notary]; +export function conceptId(claimId: string): string { + return `urn:solmara:concept:${claimId}`; +} + +export function buildEvidenceRequest( + claimId: string, + subject: string, + purpose: string, + scheme = 'solmara_uin', + seed = 0 +): RawEvidenceRequest { + void seed; return { - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: `cbe_${evaluationId}`, - orchestration: { - service_id: 'child-benefit-federator', - decision: 'not_composed' - }, - purpose: scenario.purpose, - target: { - type: target.type, - identifier_schemes: target.identifiers.map((identifier) => identifier.scheme) - }, - results: [ - { - claim_id: scenario.claimId, - claim_version: scenario.claimVersion, - format: CLAIM_RESULT_FORMAT, - issued_at: issuedAt.toISOString(), - expires_at: new Date( - issuedAt.getTime() + scenario.freshnessDays * 24 * 60 * 60 * 1000 - ).toISOString(), - notary_service_id: serviceId, - authority: AUTHORITY_LABEL[scenario.notary], - satisfied: scenario.satisfied === true, - disclosure: 'predicate' - } - ], - source_trace: [ + requestNonce: requestNonce(0), + requirement: requirementId(claimId), + purpose, + subjects: [ { - authority: AUTHORITY_LABEL[scenario.notary], - service_id: serviceId, - claims: [scenario.claimId] + role: 'subject', + selector: { + profile: scheme === 'farmer_id' ? 'solmara-farmer-v1' : 'solmara-person-v1', + values: { [scheme]: subject } + } } ] }; } -// Build the RAW 200 EvaluationResponse. issuedAt is an ISO string; expiresAt is -// issuedAt + freshnessDays (can be in the past for the stale scenario). -export function buildRawResponse( +export function buildRawRequest( scenario: ScenarioResult, - evaluationId: string, - issuedAt: Date -): RawEvaluationResponse { + subject: string, + seed = 0 +): RawEvidenceRequest { + return buildEvidenceRequest( + scenario.claimId, + subject, + scenario.purpose, + subjectScheme(scenario), + seed + ); +} + +export function buildEvidenceAssertion( + scenario: ScenarioResult, + operationId: string, + issuedAt: Date, + claimId = scenario.claimId, + serviceId = EVIDENCE_SERVICE_ID[scenario.authority], + value: unknown = scenario.value, + issuerId: AuthorityId = scenario.authority +): EvidenceAssertion { + const requirement = requirementId(claimId); return { - results: [ - buildClaimResultView( - { - claimId: scenario.claimId, - claimVersion: scenario.claimVersion, - serviceId: NOTARY_SERVICE_ID[scenario.notary], - subjectType: scenario.subjectType, - satisfied: scenario.satisfied, - value: scenario.value, - disclosure: scenario.disclosure, - sourceCount: scenario.sourceCount, - identifierScheme: subjectScheme(scenario), - freshnessDays: scenario.freshnessDays - }, - evaluationId, - issuedAt - ) - ] + schema: 'registry.assertion-evidence/v1', + assuranceProfile: 'evidence-grade', + subjectBinding: 'audience-scoped', + requestNonce: requestNonce(0), + id: `urn:solmara:evidence:${operationId}`, + type: 'Evidence', + supportsRequirement: requirement, + isConformantTo: `urn:solmara:evidence-type:${claimId}:v1`, + issuedBy: SOLMARA_AUTHORITIES[issuerId].issuer, + providedBy: `${SOLMARA_AUTHORITIES[issuerId].origin}/`, + issuedAt: issuedAt.toISOString(), + observedAt: `${scenario.asOf}T00:00:00.000Z`, + validUntil: new Date( + issuedAt.getTime() + scenario.freshnessDays * 24 * 60 * 60 * 1000 + ).toISOString(), + purpose: scenario.purpose, + audience: 'urn:solmara:portal:citizen-services', + configurationRevision: `sha256:${'a'.repeat(64)}`, + subjects: [{ role: 'subject', binding: `urn:evidence:subject:v1_${'B'.repeat(43)}` }], + supportedValues: [{ providesValueFor: conceptId(claimId), value: publicValue(value, scenario) }] }; } -export function buildClaimResultView( - fixture: ClaimResultFixture, - evaluationId: string, - issuedAt: Date -): ClaimResultView { - const issued = issuedAt.toISOString(); - const expires = new Date( - issuedAt.getTime() + fixture.freshnessDays * 24 * 60 * 60 * 1000 - ).toISOString(); +export function signEvidence(assertion: EvidenceAssertion, serviceId: string): RawEvidenceResponse { return { - claim_id: fixture.claimId, - claim_version: fixture.claimVersion, - disclosure: fixture.disclosure, - evaluation_id: evaluationId, - expires_at: expires, - format: CLAIM_RESULT_FORMAT, - issued_at: issued, - provenance: { - derived_from: [], - generated_by: { - claim_id: fixture.claimId, - claim_version: fixture.claimVersion, - evaluation_id: evaluationId, - service_id: fixture.serviceId, - type: 'claim_evaluation' - }, - schema_version: 'registry-notary-claim-provenance/v2', - used: { - relay_consultation_count: fixture.sourceCount - } - }, - satisfied: fixture.satisfied, - subject_type: fixture.subjectType, - target_ref: { - handle: 'rnref:v1:target', - identifier_schemes: [fixture.identifierScheme], - profile: 'resident', - type: 'Person' - }, - value: fixture.value + protected: encode({ + alg: 'ES256', + kid: `${serviceId}-2026-01`, + typ: 'evidence+jws', + cty: 'application/evidence+json' + }), + payload: encode(assertion), + signature: SIGNATURE }; } -// The HTTP method + URL the proof inspector shows. URL carries the notary host -// but never a raw subject (subjects go in the body target, which is redacted). -export function notaryUrl(scenario: ScenarioResult): string { - if (scenario.service === 'childBenefit') { - return 'https://child-benefit-federator.solmara.registrystack.org/v1/evaluations'; - } - return `https://${NOTARY_SERVICE_ID[scenario.notary]}.solmara.registrystack.org/v1/evaluations`; +export function buildRawResponse( + scenario: ScenarioResult, + operationId: string, + issuedAt: Date +): RawEvidenceResponse { + const serviceId = EVIDENCE_SERVICE_ID[scenario.authority]; + return signEvidence( + buildEvidenceAssertion(scenario, operationId, issuedAt), + serviceId + ); +} + +export function evidenceUrl(scenario: ScenarioResult): string { + return `${SOLMARA_AUTHORITIES[scenario.authority].origin}/v1/evidence`; } -// The authority label the result attributes to (depth-1 answered line). export function authorityLabel(scenario: ScenarioResult): string { - return AUTHORITY_LABEL[scenario.notary]; + return AUTHORITY_LABEL[scenario.authority]; +} + +export function decodeEvidencePayload(response: RawEvidenceResponse): EvidenceAssertion { + return JSON.parse(Buffer.from(response.payload, 'base64url').toString('utf8')) as EvidenceAssertion; +} + +function requestNonce(seed: number): string { + const suffix = (seed % 64).toString(36).toUpperCase().padStart(2, '0'); + return `${REQUEST_NONCE_BASE.slice(0, 41)}${suffix}`; } function subjectScheme(scenario: ScenarioResult): string { - return scenario.notary === 'agri' ? 'farmer_id' : 'solmara_uin'; + return scenario.authority === 'agri' ? 'farmer_id' : 'solmara_uin'; +} + +function publicValue(value: unknown, scenario: ScenarioResult): unknown { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return { + form: 'reviewed-structured-value', + schema: `urn:solmara:value-schema:${scenario.claimId}:v1`, + fields: value + }; + } + if (value === null) return scenario.state === 'ambiguous' ? 'multiple-matches' : 'not-available'; + return value; +} + +function encode(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); } diff --git a/portal/src/lib/rail/MinistryRail.svelte b/portal/src/lib/rail/MinistryRail.svelte index 5c965f0..45c8019 100644 --- a/portal/src/lib/rail/MinistryRail.svelte +++ b/portal/src/lib/rail/MinistryRail.svelte @@ -7,7 +7,7 @@ // // prefers-reduced-motion: all animation is suppressed; a numbered-sequence // list renders instead so the gating and fan-out story remains legible. - import type { RailEvent, NotaryId } from '$lib/types'; + import type { AuthorityId, RailEvent } from '$lib/types'; import { AUTHORITY_NAMES } from '$lib/fields/authorities'; type Props = { @@ -25,7 +25,7 @@ const ORBIT_R = 90; // Ministry nodes in clockwise order around the citizen seat. - type NodeId = NotaryId; + type NodeId = AuthorityId; type NodeDef = { id: NodeId; label: string; @@ -47,7 +47,7 @@ { id: 'certs', label: 'Certs', angle: 225, color: 'var(--color-ministry-civil)', glyph: 'Ce' } ]; - // NodeId -> NodeDef lookup. We use string keys so NotaryId events from the + // NodeId -> NodeDef lookup. We use string keys so authority events from the // wire can be looked up directly. const nodeMap = new Map(NODES.map((n) => [n.id, n])); @@ -87,7 +87,7 @@ // pairs: a request event whose id is not superseded by a sealed/denied event. type Packet = { id: string; - authority: NotaryId; + authority: AuthorityId; channel: string; // 'verify' | 'fetch' | 'denied' motion: 'pulse-target' | 'travel-stamp' | 'bounce'; }; @@ -112,7 +112,7 @@ ev.channel === 'verify' ? 'pulse-target' : ev.channel === 'fetch' ? 'travel-stamp' : 'bounce'; - packets.push({ id: ev.id, authority: ev.authority as NotaryId, channel: ev.channel, motion } satisfies Packet); + packets.push({ id: ev.id, authority: ev.authority as AuthorityId, channel: ev.channel, motion } satisfies Packet); } return packets; })() @@ -130,7 +130,7 @@ const sequenceSteps = $derived( events.map((ev, i): SequenceStep => { - const authLabel = AUTHORITY_NAMES[ev.authority as NotaryId] ?? ev.authority; + const authLabel = AUTHORITY_NAMES[ev.authority as AuthorityId] ?? ev.authority; const channelLabel = ev.channel === 'verify' ? 'verify' : ev.channel === 'fetch' ? 'fetch' : @@ -206,7 +206,7 @@ data-motion={packet.motion} data-channel={packet.channel} data-authority={packet.authority} - aria-label={`${packet.channel} packet to ${AUTHORITY_NAMES[packet.authority as NotaryId] ?? packet.authority}`} + aria-label={`${packet.channel} packet to ${AUTHORITY_NAMES[packet.authority as AuthorityId] ?? packet.authority}`} > {#if packet.motion === 'travel-stamp' || packet.motion === 'bounce'} diff --git a/portal/src/lib/server/bff.ts b/portal/src/lib/server/bff.ts index c39a31c..2172884 100644 --- a/portal/src/lib/server/bff.ts +++ b/portal/src/lib/server/bff.ts @@ -5,20 +5,19 @@ import type { MockEvaluation } from '$lib/providers/mock'; import type { ProofTrace, RailChannel, RailEvent } from '$lib/types'; import { proofFeed, railFeed } from '$lib/providers/feeds.svelte'; -import { redactRequest, redactResponse, scrubString } from './redact'; +import { scrubString } from './redact'; let traceSeq = 0; // Build a redacted ProofTrace from a full MockEvaluation. The depth-1 human copy // is allowlist-safe by construction (it never embeds a raw identifier; the mock // authors it). depth-2 bodies are run through the redactor. depth-3 crypto carries -// no raw identifier (dids, audit ids, algorithm). +// no raw identifier, internal event id, or cryptographic bytes. export function buildRedactedTrace( ev: MockEvaluation, opts?: { fieldId?: string } ): ProofTrace { const seq = ++traceSeq; - const redactedReq = redactRequest(ev.raw.request); const trace: ProofTrace = { id: ev.result.traceId, seq, @@ -30,36 +29,35 @@ export function buildRedactedTrace( notDisclosed: scrubString(ev.proof.notDisclosed), status: ev.proof.status, ts: new Date().toISOString(), - // depth 2: redacted wire bodies (allowlist only). - request: redactedReq, - response: redactResponse({ - status: ev.raw.response.status, - body: ev.raw.response.body as Record - }), - // depth 3: crypto. Present for resolved AND denied traces (the denial is still - // a signed, audited evaluation result). + purpose: ev.proof.purpose, + resultState: ev.result.state, + presentations: ev.proof.presentations, + responseStatus: ev.raw.response.status, proof: ev.proof.crypto }; return trace; } -// Map a proof status to a rail channel + phase. -function railFromStatus(ev: MockEvaluation): { channel: RailChannel; phase: RailEvent['phase'] } { - switch (ev.proof.status) { - case 'denied': - return { channel: 'denied', phase: 'denied' }; - case 'error': - return { channel: 'denied', phase: 'denied' }; +// Rail behavior is derived from the stable result state, never from a request +// body or upstream transport detail. +export function railFromStatus(ev: MockEvaluation): { channel: RailChannel; phase: RailEvent['phase'] } { + switch (ev.result.state) { + case 'fetched': + case 'stale': + return { channel: 'fetch', phase: 'sealed' }; + case 'verified': case 'false': - // a signed "no" is still a sealed verify, not a denial. + case 'recovered': return { channel: 'verify', phase: 'sealed' }; - case 'ok': { - const disclosure = (ev.raw.request.body.disclosure ?? '') as string; - const channel: RailChannel = disclosure === 'predicate' || disclosure === 'decision' ? 'verify' : 'fetch'; - return { channel, phase: 'sealed' }; - } - default: + case 'error': + case 'ambiguous': + return { channel: 'denied', phase: 'denied' }; + case 'in_flight': + case 'slow': return { channel: 'verify', phase: 'request' }; + case 'idle': + case 'prefilled': + return { channel: 'verify', phase: 'sealed' }; } } diff --git a/portal/src/lib/server/provider.test.ts b/portal/src/lib/server/provider.test.ts index fa718b5..6da6bca 100644 --- a/portal/src/lib/server/provider.test.ts +++ b/portal/src/lib/server/provider.test.ts @@ -18,16 +18,13 @@ describe('getProvider', () => { it('constructs live mode from environment config', () => { Object.assign(env, { PORTAL_PROVIDER: 'live', - CRA_NOTARY_URL: 'https://cra-notary.solmara.registrystack.org', - CRA_CITIZEN_CLIENT_TOKEN: 'cra-citizen-token', - NIA_NOTARY_URL: 'https://nia-notary.solmara.registrystack.org', - NIA_CITIZEN_CLIENT_TOKEN: 'nia-citizen-token' + SCENARIO_RUNNER_URL: 'http://scenario-runner:8080' }); expect(getProvider()).not.toBeInstanceOf(MockEvidenceProvider); }); - it('does not require obsolete Relay or generic Notary configuration', () => { + it('constructs lazily without requiring credentials in the portal process', () => { Object.assign(env, { PORTAL_PROVIDER: 'live' }); diff --git a/portal/src/lib/server/redact.test.ts b/portal/src/lib/server/redact.test.ts index 5f27e69..6a1a8fa 100644 --- a/portal/src/lib/server/redact.test.ts +++ b/portal/src/lib/server/redact.test.ts @@ -7,222 +7,54 @@ import { redactResponse, scrubString } from './redact'; -import { serializeTraceEvent } from './bff'; -import type { ProofTrace } from '$lib/types'; -// The load-bearing redaction test (spec 5.2 / 10): build a payload containing a -// fixture UIN, a person id, and fake API credentials; run it through the -// redactor AND the SSE serialization; assert NONE of the UIN, /CP-\d+/, or the -// credential strings survive, and that the allowlisted keys DO. - -const FAKE_BEARER = 'Bearer eyJhbGciOiJFZERTQSJ9.FAKE_TOKEN_PAYLOAD.FAKE_SIGNATURE_abc123'; -const FAKE_API_KEY = 'rn_api_FAKE_TOKEN_PAYLOAD_abc123456789'; - -// A raw request body shaped like the BFF's un-redacted EvaluateRequest, carrying -// the subject the BFF holds (2300010248) and a delegated dependent (2300036523). -const rawRequest = { - method: 'POST', - url: 'https://civil-notary.gov.solmara.example/v1/evaluations', - body: { - claims: [{ id: 'person-is-deceased', version: '2026-07' }], - purpose: 'https://id.registrystack.org/solmara/purpose/pension-payment-review', - disclosure: 'predicate', - format: 'application/vnd.registry-notary.claim-result+json', - relationship: { type: 'self' }, - target: { - type: 'Person', - identifiers: [{ scheme: 'solmara_uin', value: '2300010248' }] - }, - on_behalf_of: { - actor: { type: 'Person', id_hash: 'hmac-sha256:deadbeef' }, - delegation_ref: 'rnref:v1:REL-1001-MOTHER' - } - } -}; - -// A raw response that echoes a person id (CP-2001) in a target_ref handle, plus a -// freshness timestamp, plus the satisfied predicate. -const rawResponse = { - status: 200, - body: { - results: [ - { - claim_id: 'person-is-deceased', - claim_version: '2026-07', - disclosure: 'predicate', - evaluation_id: '01HX7Y5F2WAJ7ZP0Q4M5K9E8NC', - expires_at: '2026-05-25T12:00:00Z', - issued_at: '2026-05-24T12:00:00Z', - satisfied: true, - subject_type: 'person', - target_ref: { - handle: 'rnref:v1:CP-2001', - identifier_schemes: ['solmara_uin'], - profile: 'resident', - type: 'Person' - }, - value: true - } - ] - } -}; - -describe('scrubString', () => { - it('blanks UIN, CP, bearer, and x-api-key material', () => { - const dirty = `subject 2300010248 (CP-2001) with Authorization: ${FAKE_BEARER} and x-api-key: ${FAKE_API_KEY}`; +describe('portal proof redaction boundary', () => { + it('scrubs identifiers, credentials, private keys, and compact JWS values', () => { + const dirty = [ + '2300010248', + 'CP-2001', + 'FR-1001', + 'Bearer secret-token-123', + 'x-api-key: secret-key-123', + '-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----', + `${'a'.repeat(20)}.${'b'.repeat(20)}.${'c'.repeat(20)}` + ].join(' '); const clean = scrubString(dirty); - expect(clean).not.toContain('2300010248'); - expect(clean).not.toMatch(/CP-\d+/); - expect(clean).not.toContain('FAKE_TOKEN_PAYLOAD'); - expect(clean).toContain('Bearer •••••••• (redacted)'); - expect(clean).toContain('x-api-key: •••••••• (redacted)'); - }); -}); -describe('redactBody / request / response', () => { - it('drops identifier values but keeps the structural key set', () => { - const red = redactRequest(rawRequest); - const serialized = JSON.stringify(red); - // raw identifiers gone - expect(serialized).not.toContain('2300010248'); - // structural keys preserved (allowlisted wire shape) - expect(red.body).toHaveProperty('claims'); - expect(red.body).toHaveProperty('purpose', 'https://id.registrystack.org/solmara/purpose/pension-payment-review'); - expect(red.body).toHaveProperty('disclosure', 'predicate'); - expect(red.body).toHaveProperty('relationship'); - // target envelope kept structurally but identifiers stripped - expect(red.body).toHaveProperty('target'); - const target = red.body.target as { type?: string; identifiers?: unknown[] }; - expect(target.identifiers).toEqual([]); + expect(containsRawIdentifier(clean)).toBe(false); + expect(clean).not.toMatch(/2300010248|CP-2001|FR-1001|secret-token|secret-key/); + expect(clean).toContain('Bearer [redacted]'); + expect(clean).toContain('[private key redacted]'); + expect(clean).toContain('[JWS redacted]'); }); - it('redacts a response that echoes a person id in a handle', () => { - const red = redactResponse(rawResponse); - const serialized = JSON.stringify(red); - expect(serialized).not.toMatch(/CP-\d+/); - // allowlisted result keys survive - const result = (red.body.results as Record[])[0]; - expect(result).toHaveProperty('claim_id', 'person-is-deceased'); - expect(result).toHaveProperty('satisfied', true); - expect(result).toHaveProperty('issued_at', '2026-05-24T12:00:00Z'); - }); - - it('keeps child application source attribution without a target identifier', () => { - const red = redactResponse({ - status: 200, - body: { - schema_version: 'solmara-child-benefit-evidence/v1', - evidence_set_id: 'cbe_01TEST', - orchestration: { - service_id: 'child-benefit-federator', - decision: 'not_composed' - }, - target: { - type: 'Person', - identifier_schemes: ['solmara_uin'] - }, - results: [ - { - claim_id: 'population-record-active', - notary_service_id: 'nia-notary', - authority: 'National Identity Agency', - satisfied: true - } - ], - source_trace: [{ service_id: 'nia-notary', claims: ['population-record-active'] }] - } - }); - - expect(JSON.stringify(red)).not.toContain('2300010248'); - expect(red.body).toHaveProperty('evidence_set_id', 'cbe_01TEST'); - expect(red.body).toHaveProperty('orchestration.decision', 'not_composed'); - expect(red.body).toHaveProperty( - 'results.0.notary_service_id', - 'nia-notary' - ); - expect(red.body).toHaveProperty('source_trace.0.service_id', 'nia-notary'); - }); - - it('keeps the application-owned survivor decision value', () => { - const red = redactResponse({ - status: 200, - body: { - schema_version: 'solmara-portal-evidence/v1', - derived_decisions: { 'survivor-benefit-eligible': true } - } + it('never projects request or response bodies', () => { + const request = redactRequest({ + method: 'POST', + url: 'https://cra-evidence.example/v1/evidence?uin=2300010248', + body: { subjects: [{ selector: { values: { uin: '2300010248', farmer: 'FR-1001' } } }] } }); - - expect(red.body).toHaveProperty( - 'derived_decisions.survivor-benefit-eligible', - true - ); - }); -}); - -describe('SSE serialization is identifier-free end to end', () => { - it('produces an event frame with no UIN / CP / bearer material', () => { - const trace: ProofTrace = { - id: 'event 1', - seq: 1, - fieldId: 'person-is-deceased', - authority: 'civil', - headline: 'Confirmed by Civil Registry', - answered: 'Civil Registry answered: person-is-deceased = true', - notDisclosed: 'Not disclosed: any other civil record detail', - status: 'ok', - ts: '2026-06-21T10:00:00.000Z', - request: redactRequest(rawRequest), - response: redactResponse(rawResponse) - }; - const frame = serializeTraceEvent(trace); - - // the streamed bytes carry NONE of the secrets / identifiers - expect(frame).not.toContain('2300010248'); - expect(frame).not.toMatch(/CP-\d+/); - expect(frame).not.toContain('FAKE_TOKEN_PAYLOAD'); - expect(frame).not.toContain(FAKE_API_KEY); - expect(containsRawIdentifier(frame)).toBe(false); - - // but the allowlisted, structural content DID survive - expect(frame).toContain('person-is-deceased'); - expect(frame).toContain('https://id.registrystack.org/solmara/purpose/pension-payment-review'); - expect(frame).toContain('Not disclosed:'); - expect(frame.startsWith('event: trace\ndata: ')).toBe(true); - }); -}); - -describe('pickAllowedMeta', () => { - it('keeps only allowlisted keys and scrubs their values', () => { - const picked = pickAllowedMeta({ - claim: 'person-is-deceased', - purpose: 'https://id.registrystack.org/solmara/purpose/pension-payment-review', - disclosure: 'predicate', - authority: 'Civil Registry', - result: 'true', - freshness: '2026-05-24', - // these must be dropped - bearer: FAKE_BEARER, - api_key: FAKE_API_KEY, - target: '2300010248', - subject: 'CP-2001' - }); - expect(Object.keys(picked).sort()).toEqual( - ['authority', 'claim', 'disclosure', 'freshness', 'purpose', 'result'].sort() - ); - expect(JSON.stringify(picked)).not.toContain('2300010248'); - expect(JSON.stringify(picked)).not.toMatch(/CP-\d+/); - expect(JSON.stringify(picked)).not.toContain('FAKE_TOKEN_PAYLOAD'); - }); -}); - -describe('redactBody catches a leaked identifier inside an allowlisted value', () => { - it('scrubs a UIN embedded in an otherwise-allowed string value', () => { - const leaked = redactBody({ - purpose: 'lookup for 2300010248', - value: 'belongs to CP-2001' + const response = redactResponse({ status: 200, body: { protected: 'raw', payload: 'raw', signature: 'raw' } }); + + expect(request).toEqual({ method: 'POST', url: 'https://cra-evidence.example/v1/evidence?uin=[redacted]' }); + expect(response).toEqual({ status: 200 }); + expect(redactBody({ selector: 'FR-1001' })).toEqual({}); + expect(JSON.stringify({ request, response })).not.toMatch(/subjects|selector|protected|payload|signature|FR-1001/); + }); + + it('allowlists only safe presentation metadata and scrubs string values', () => { + expect(pickAllowedMeta({ + authority: 'Civil Registration Authority', + issuer: 'did:web:id.registrystack.org:solmara:authority:cra', + serviceId: 'cra-evidence', + source: 'Relay lookup', + selector: 'FR-1001', + token: 'secret' + })).toEqual({ + authority: 'Civil Registration Authority', + issuer: 'did:web:id.registrystack.org:solmara:authority:cra', + serviceId: 'cra-evidence', + source: 'Relay lookup' }); - const serialized = JSON.stringify(leaked); - expect(serialized).not.toContain('2300010248'); - expect(serialized).not.toMatch(/CP-\d+/); }); }); diff --git a/portal/src/lib/server/redact.ts b/portal/src/lib/server/redact.ts index 01dda87..37226ca 100644 --- a/portal/src/lib/server/redact.ts +++ b/portal/src/lib/server/redact.ts @@ -1,237 +1,83 @@ -// Allowlist redaction at the BFF boundary (spec 5.2 / 10). -// -// This is denylist-free by construction: nothing passes UNLESS its key is on an -// explicit allowlist. Everything else (bearer/x-api-key values, the request -// `target` / subject the BFF holds, any raw identifier echoed in a response) is -// dropped before anything reaches the proof feed. The structural WIRE SHAPE the -// proof inspector renders is preserved, but raw identifier values inside it are -// blanked so the inspector shows the derived self-attestation framing, never a -// caller-supplied target. -// -// Server-only: this module is imported by +server.ts routes and the BFF, never -// into a client bundle. +// Server boundary helpers. Raw bodies are intentionally not projectable into a +// ProofTrace. These functions retain only transport status and scrub defensive +// text fields before an SSE frame is serialized. -import type { ProofStatus } from '$lib/types'; +const SOLMARA_UIN_RE = /\b[2-9]\d{9}\b/g; +const CASE_REFERENCE_RE = /\bCP-\d+\b/g; +const FARMER_ID_RE = /\bFR-\d+\b/g; +const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi; +const API_KEY_RE = /(x-api-key\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/gi; +const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; +const COMPACT_JWS_RE = /\b[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g; -// --------------------------------------------------------------------------- -// The allowlist. Only these top-level proof keys are forwarded to the feed. -// (claim, purpose, disclosure, authority, result, freshness) plus the structural -// wire shape (request/response bodies, themselves re-redacted field-by-field). -// --------------------------------------------------------------------------- export const PROOF_ALLOWLIST = [ 'claim', 'purpose', 'disclosure', 'authority', + 'issuer', + 'serviceId', + 'source', 'result', 'freshness' ] as const; -// The allowlist of body keys that may keep their VALUES when serializing a wire -// request/response body to the feed. Anything not here is structurally kept (so -// the inspector still shows the key) but its value is blanked / hashed. -// -// Crucially: `target`, `requester`, `requester_ref`, `target_ref`, `identifiers`, -// `id_hash`, `value` (when it could echo an identifier) are NOT value-preserved -// at the identifier level: identifier strings inside them are blanked. -const BODY_VALUE_ALLOWLIST = new Set([ - 'claims', - 'id', - 'version', - 'purpose', - 'disclosure', - 'format', - 'relationship', - 'type', - 'on_behalf_of', - 'delegation_ref', - 'results', - 'evidence_set_id', - 'orchestration', - 'decision', - 'source_trace', - 'composition', - 'requests', - 'body', - 'status', - 'request_summary', - 'response_summary', - 'derived_decisions', - 'pension-payment-should-stop', - 'citizen-self-service-ready', - 'survivor-benefit-eligible', - 'claim_id', - 'claim_version', - 'evaluation_id', - 'subject_type', - 'satisfied', - 'value', - 'issued_at', - 'expires_at', - 'matching', - 'confidence', - 'method', - 'policy_id', - 'score', - 'provenance', - 'schema_version', - 'generated_by', - 'used', - 'derived_from', - 'policy_hash', - 'policy_version', - 'service_id', - 'notary_service_id', - 'authority', - 'source_count', - 'source_versions', - 'source_runtimes', - 'profile' -]); - -// Keys whose string values are STRUCTURAL identifier handles, not raw ids. They -// stay (handles are already opaque rnref:/did: tokens), but we still pass them -// through the identifier scrubber to catch any leaked raw id. -const HANDLE_KEYS = new Set(['handle', 'identifier_schemes', 'profile']); - -// Patterns for raw identifiers / secrets that must NEVER reach the feed. -// Allowlist design means we do not RELY on these to redact (the allowlist already -// drops unknown keys); they are a belt-and-braces scrub of any allowlisted value -// that could still embed a fixture id, plus the test's assertion surface. -const SOLMARA_UIN_RE = /\b[2-9]\d{9}\b/g; -const CP_RE = /CP-\d+/g; -const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi; -const X_API_KEY_RE = /(x-api-key\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/gi; - -// Replace any embedded raw identifier / bearer material inside a string value. export function scrubString(input: string): string { return input - .replace(BEARER_RE, 'Bearer •••••••• (redacted)') - .replace(X_API_KEY_RE, '$1•••••••• (redacted)') - .replace(SOLMARA_UIN_RE, '••••') - .replace(CP_RE, '••••'); + .replace(PRIVATE_KEY_RE, '[private key redacted]') + .replace(BEARER_RE, 'Bearer [redacted]') + .replace(API_KEY_RE, '$1[redacted]') + .replace(COMPACT_JWS_RE, '[JWS redacted]') + .replace(SOLMARA_UIN_RE, '[redacted]') + .replace(CASE_REFERENCE_RE, '[redacted]') + .replace(FARMER_ID_RE, '[redacted]'); } -type Json = unknown; - -// Recursively redact a wire body: keep the structure (keys + types), but blank -// the VALUES of any key not on BODY_VALUE_ALLOWLIST, and scrub identifier-shaped -// strings everywhere. The `target`/`requester` envelopes keep their `type` but -// drop the identifier arrays entirely (the inspector shows relationship:self and -// the ABSENCE of a caller-supplied target, which is the engineer-facing point). -export function redactBody(body: Json): Json { - return walk(body, true); -} - -function walk(node: Json, valuePreserved: boolean): Json { - if (typeof node === 'string') { - return valuePreserved ? scrubString(node) : '••••(redacted)'; - } - if (typeof node === 'number' || typeof node === 'boolean' || node === null) { - return valuePreserved ? node : null; - } - if (Array.isArray(node)) { - return node.map((item) => walk(item, valuePreserved)); - } - if (typeof node === 'object') { - const out: Record = {}; - for (const [key, val] of Object.entries(node as Record)) { - // `target` and `requester` envelopes carry the subject the BFF holds. Keep - // the key so the shape matches, keep `type`, but DROP identifier values. - if (key === 'target' || key === 'requester') { - out[key] = redactEntityEnvelope(val); - continue; - } - // identifier arrays anywhere are dropped to an empty, shape-preserving form. - if (key === 'identifiers') { - out[key] = []; - continue; - } - const childPreserved = - valuePreserved && - (BODY_VALUE_ALLOWLIST.has(key) || HANDLE_KEYS.has(key)); - out[key] = walk(val, childPreserved); - } - return out; - } - return null; -} - -// Keep { type } of an entity envelope but strip identifiers so no raw subject -// leaks. This is what makes the inspector show "relationship:self, no caller -// target". -function redactEntityEnvelope(node: Json): Json { - if (node === null || typeof node !== 'object' || Array.isArray(node)) { - return null; - } - const obj = node as Record; - const out: Record = {}; - if (typeof obj.type === 'string') out.type = scrubString(obj.type); - // identifiers deliberately dropped to an empty array (shape preserved). - out.identifiers = []; - return out; -} - -// The redacted depth-2 request shown to the feed. The body is structurally -// preserved and identifier values are scrubbed. export type RedactedRequest = { method: string; url: string; - body: Record; }; export type RedactedResponse = { status: number; - body: Record; }; -export function redactRequest(req: { +export function redactBody(_body: unknown): Record { + return {}; +} + +export function redactRequest(request: { method: string; url: string; - body: Json; + body?: unknown; }): RedactedRequest { - return { - method: req.method, - url: scrubString(req.url), - body: redactBody(req.body) as Record - }; + return { method: scrubString(request.method), url: scrubString(request.url) }; } -export function redactResponse(res: { +export function redactResponse(response: { status: number; - body: Json; + body?: unknown; }): RedactedResponse { - return { - status: res.status, - body: redactBody(res.body) as Record - }; + return { status: response.status }; } -// A small structural assertion used by the SSE serializer and the test: does a -// serialized string contain any raw identifier or bearer material? export function containsRawIdentifier(serialized: string): boolean { return ( /\b[2-9]\d{9}\b/.test(serialized) || - /CP-\d+/.test(serialized) || - /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/.test(serialized) || - /x-api-key\s*[:=]\s*[A-Za-z0-9._~+/=-]{8,}/i.test(serialized) + /\bCP-\d+\b/.test(serialized) || + /\bFR-\d+\b/.test(serialized) || + /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/i.test(serialized) || + /x-api-key\s*[:=]\s*[A-Za-z0-9._~+/=-]{8,}/i.test(serialized) || + /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(serialized) || + /\b[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/.test(serialized) ); } -// Allowlist guard for a flat metadata object (claim/purpose/disclosure/authority/ -// result/freshness). Drops any key not on PROOF_ALLOWLIST. -export function pickAllowedMeta( - meta: Record -): Record { +export function pickAllowedMeta(meta: Record): Record { const allow = new Set(PROOF_ALLOWLIST); - const out: Record = {}; - for (const [k, v] of Object.entries(meta)) { - if (allow.has(k)) { - out[k] = typeof v === 'string' ? scrubString(v) : v; - } - } - return out; + return Object.fromEntries( + Object.entries(meta) + .filter(([key]) => allow.has(key)) + .map(([key, value]) => [key, typeof value === 'string' ? scrubString(value) : value]) + ); } - -// Re-exported type for callers that build a status alongside redacted bodies. -export type { ProofStatus }; diff --git a/portal/src/lib/types.ts b/portal/src/lib/types.ts index 62fbae5..995710f 100644 --- a/portal/src/lib/types.ts +++ b/portal/src/lib/types.ts @@ -1,6 +1,6 @@ // Provenance kinds -> field types (portal spec section 5.3, UX section 3). export type FieldKind = 'self' | 'verify' | 'fetch' | 'decision'; -export type NotaryId = +export type AuthorityId = | 'civil' | 'social' | 'agri' @@ -29,7 +29,7 @@ export type Field = { label: string; kind: FieldKind; claim?: string; // e.g. 'household-below-poverty-threshold' - notary?: NotaryId; + authority?: AuthorityId; purpose?: string; // declared purpose, shown in the proof inspector disclose?: string; // what is NOT disclosed (the minimization "money shot") selfPlaceholder?: string; // placeholder for kind:'self' inputs @@ -40,20 +40,18 @@ export type Field = { export type ServiceForm = { slug: string; title: string; - authorities: NotaryId[]; // seal glyphs shown on the card / form header + authorities: AuthorityId[]; // seal glyphs shown on the card / form header fields: Field[]; }; -// ---- Wire-facing shapes ---- -// ClaimResult and ProofTrace are the STABLE portal-facing projection. The raw -// Upstream request/response JSON lives inside ProofTrace.request.body / -// response.body and follows the owning service contract. Renderer and proof -// agents treat those bodies as opaque records and pretty-print them. +// ---- Portal-facing shapes ---- +// Raw requests, source rows, bearer tokens, and signed JWS segments remain on +// the server. The browser receives only this bounded presentation projection. export type ClaimResult = { state: FieldState; display: string; // value or predicate sentence shown in the field - authority?: NotaryId; + authority?: AuthorityId; asOf?: string; // freshness timestamp reasonCode?: string; // e.g. 'VR-RED-02' traceId: string; // links to the ProofTrace ('event N') @@ -61,28 +59,38 @@ export type ClaimResult = { export type ProofStatus = 'in_flight' | 'ok' | 'false' | 'denied' | 'error'; +export type EvidenceSource = 'immutable extract' | 'Relay lookup'; + +export type EvidencePresentation = { + authority: string; + issuer: string; + serviceId: string; + source: EvidenceSource; +}; + export type ProofTrace = { id: string; // 'event N' label seq: number; fieldId?: string; - authority?: NotaryId; + authority?: AuthorityId; // depth 1 - human headline: string; // consequence-first answered: string; // "{Authority} answered: {claim} = {value}" notDisclosed: string; // ALWAYS present status: ProofStatus; ts: string; - // depth 2 - wire (redacted; allowlist only). Body follows the owning service. - request: { method: string; url: string; body: Record }; - response?: { status: number; body: Record }; - // depth 3 - crypto + purpose?: string; + resultState: FieldState; + presentations: EvidencePresentation[]; + responseStatus?: number; + // Depth 2 is deliberately descriptive. Cryptographic bytes and internal event ids do + // not cross the BFF boundary. proof?: { signedBy: string; algorithm: string; issuerKey: string; holderBound: string; credential: string; - auditId: string; }; }; @@ -90,7 +98,7 @@ export type ProofTrace = { export type RailChannel = 'verify' | 'fetch' | 'denied'; export type RailEvent = { id: string; - authority: NotaryId; + authority: AuthorityId; channel: RailChannel; phase: 'request' | 'sealed' | 'denied'; ts: string; diff --git a/portal/src/routes/+layout.svelte b/portal/src/routes/+layout.svelte index ef4c1e6..6fcdbd0 100644 --- a/portal/src/routes/+layout.svelte +++ b/portal/src/routes/+layout.svelte @@ -8,9 +8,9 @@ let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props(); - // The footer audit log is a drawer: collapsed to its most-recent rows by default, + // The footer proof log is a drawer: collapsed to its most-recent rows by default, // expanded (and scrollable) on demand so the full proof history is reachable. - let auditOpen = $state(false); + let proofLogOpen = $state(false); // One app-wide SSE connection to the redacted proof feed. Browser only (onMount // never runs during SSR), so EventSource is safe here. The rail, ticker, and @@ -51,24 +51,24 @@ - -
+
-
+
@@ -128,7 +128,7 @@ min-width: 0; } - /* The audit-log drawer, pinned to the foot of the viewport. Collapsed it shows its + /* The proof-log drawer, pinned to the foot of the viewport. Collapsed it shows its most-recent rows; opened it grows upward and the rows scroll, so the full history is reachable without the log ever dominating the page. */ .ticker-band { diff --git a/portal/src/routes/api/evaluate/+server.ts b/portal/src/routes/api/evaluate/+server.ts index 85c0de0..993adff 100644 --- a/portal/src/routes/api/evaluate/+server.ts +++ b/portal/src/routes/api/evaluate/+server.ts @@ -1,5 +1,4 @@ -// POST /api/evaluate : proxy a single field's claim to its Notary (Phase 0: the -// MockEvidenceProvider). +// POST /api/evaluate: request one reviewed Evidence field through the provider. // // Body: { slug, fieldId, scenarioKey?, delegated? } // - slug / fieldId identify a field in the server-side form catalogue. @@ -101,7 +100,7 @@ function resolveAllowedField( id: fieldId, label: 'Guardian link verified', kind: 'verify', - notary: 'social' + authority: 'social' }; } @@ -110,7 +109,7 @@ function resolveAllowedField( id: fieldId, label: 'Cross-person denial', kind: 'verify', - notary: 'civil' + authority: 'civil' }; } diff --git a/portal/src/routes/gallery/fields/+page.svelte b/portal/src/routes/gallery/fields/+page.svelte index c3a3c27..6b0fc08 100644 --- a/portal/src/routes/gallery/fields/+page.svelte +++ b/portal/src/routes/gallery/fields/+page.svelte @@ -16,27 +16,27 @@ label: 'Farmer registration', kind: 'verify', claim: 'farmer-registered', - notary: 'agri' + authority: 'agri' }; const fetchField: Field = { id: 'g-fetch', label: 'Child age under 5', kind: 'verify', claim: 'child-age-under-5', - notary: 'civil' + authority: 'civil' }; const householdField: Field = { id: 'g-household', label: 'Household below threshold', kind: 'verify', claim: 'household-below-poverty-threshold', - notary: 'social' + authority: 'social' }; const decisionField: Field = { id: 'g-decision', label: 'Combined eligibility', kind: 'decision', - notary: 'social', + authority: 'social', manual: true }; diff --git a/portal/src/routes/gallery/proof/+page.svelte b/portal/src/routes/gallery/proof/+page.svelte index 1a2d0f5..0ecf7e8 100644 --- a/portal/src/routes/gallery/proof/+page.svelte +++ b/portal/src/routes/gallery/proof/+page.svelte @@ -35,7 +35,7 @@ {connected ? 'Simulate: disconnect' : 'Simulate: reconnect'} - (Disconnected state shows the "reconnecting to audit feed" pill) + (Disconnected state shows the "reconnecting to proof feed" pill) @@ -63,7 +63,7 @@
  • denied - event-4: denied (Civil, subject_mismatch - cross-person attempt) + event-4: denied (Civil, not_authorized - cross-person attempt)
  • identity @@ -79,7 +79,7 @@ -
    +

    Proof ticker (always present, ARIA live-region)

    diff --git a/portal/src/routes/proof/stream/server.test.ts b/portal/src/routes/proof/stream/server.test.ts index abec822..d1e0dd8 100644 --- a/portal/src/routes/proof/stream/server.test.ts +++ b/portal/src/routes/proof/stream/server.test.ts @@ -42,15 +42,8 @@ function trace(id: string): ProofTrace { notDisclosed: 'Not disclosed: any other civil record detail', status: 'ok', ts: '2026-06-22T12:00:00.000Z', - request: { - method: 'POST', - url: 'https://civil-notary.gov.solmara.example/v1/evaluations', - body: { claim: 'person-is-deceased' } - }, - response: { - status: 200, - body: { result: true } - } + resultState: 'verified', + presentations: [] }; } diff --git a/portal/src/routes/services/[slug]/+page.svelte b/portal/src/routes/services/[slug]/+page.svelte index 10b2a6c..6a4ee0b 100644 --- a/portal/src/routes/services/[slug]/+page.svelte +++ b/portal/src/routes/services/[slug]/+page.svelte @@ -20,11 +20,11 @@ let guardianVerified = $state(false); function inFlight(field: Field): ClaimResult { - return { state: 'in_flight', display: '', authority: field.notary, traceId: '' }; + return { state: 'in_flight', display: '', authority: field.authority, traceId: '' }; } function errored(field: Field): ClaimResult { - return { state: 'error', display: '', authority: field.notary, traceId: '' }; + return { state: 'error', display: '', authority: field.authority, traceId: '' }; } function sleep(ms: number): Promise { @@ -56,7 +56,7 @@ id: 'guardian-link-verified', label: 'Guardian link verified', kind: 'verify', - notary: 'social' + authority: 'social' }; const identityResult = $derived({ diff --git a/projects/cra-civil/.registry-stack-editor/manifest.json b/projects/cra-civil/.registry-stack-editor/manifest.json deleted file mode 100644 index 3603a8e..0000000 --- a/projects/cra-civil/.registry-stack-editor/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "format": "registry.stack.editor-manifest", - "version": 1, - "registryctl_version": "0.15.2", - "schemas": [ - { - "kind": "project", - "path": "schemas/project.schema.json", - "file_glob": "registry-stack.yaml", - "sha256": "sha256:9fd76663c866c52bb5a17a4cfea821049b7428c812f1666da9db0d43a00da144" - }, - { - "kind": "environment", - "path": "schemas/environment.schema.json", - "file_glob": "environments/*.yaml", - "sha256": "sha256:d16828629e001a30a1705af90bcc12a214be18cf5805a9833e65b03d631c9e9b" - }, - { - "kind": "integration", - "path": "schemas/integration.schema.json", - "file_glob": "integrations/*/integration.yaml", - "sha256": "sha256:2e45452f63c3c610ed14b79244bc9aa086d79d811ad0899223b36c160d449a79" - }, - { - "kind": "fixture", - "path": "schemas/fixture.schema.json", - "file_glob": "integrations/*/fixtures/*.yaml", - "sha256": "sha256:98c0ab21e35cecd143b51e4b5dd487de07ceea24e505bbe351d212e0628c8c8c" - }, - { - "kind": "entity", - "path": "schemas/entity.schema.json", - "file_glob": "entities/*.yaml", - "sha256": "sha256:4cbb31c8addeb54f8b57aaaff8f30c39e651a21a2d741a1b775ecb0d7c248342" - } - ] -} diff --git a/projects/cra-civil/.registry-stack-editor/schemas/entity.schema.json b/projects/cra-civil/.registry-stack-editor/schemas/entity.schema.json deleted file mode 100644 index ffc57c8..0000000 --- a/projects/cra-civil/.registry-stack-editor/schemas/entity.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/entity.v1.json", - "title": "Registry Stack project entity v1", - "description": "Defines a bounded, materialized entity that Relay can query without coupling the project to a specific source product.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "primary_key", "schema", "materialization"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Entity authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the entity." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this entity definition.", "examples": [1] }, - "primary_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Field whose value uniquely identifies each materialized record.", "examples": ["person_id"] }, - "schema": {"x-registry-field": "property", "$ref": "#/$defs/objectSchema", "description": "Closed JSON object schema for materialized records." }, - "materialization": { - "x-registry-field": "property", - "description": "Authored resource, refresh, and bounded recovery-set retention applied while building entity generations.", - "type": "object", - "additionalProperties": false, - "required": ["max_records", "max_bytes", "refresh", "retain_generations"], - "properties": { - "max_records": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 100000000, "description": "Maximum number of records allowed in one generation.", "examples": [10000] }, - "max_bytes": { - "x-registry-field": "property", - "description": "Maximum encoded size of one generation, up to 1 GiB, as bytes or a KiB/MiB quantity.", - "examples": ["16MiB"], - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1073741824 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,5}|10[0-3][0-9]{4}|104[0-7][0-9]{3}|1048[0-4][0-9]{2}|10485[0-6][0-9]|104857[0-6])KiB|(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])MiB)$" } - ] - }, - "refresh": { - "x-registry-field": "property", - "description": "Refresh cadence as a canonical positive duration no greater than 30 days, or manual when an operator initiates every refresh.", - "examples": ["1h"], - "oneOf": [ - {"x-registry-field": "branch", "const": "manual" }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-4][0-9]{8}|25[0-8][0-9]{7}|259[0-1][0-9]{6}|2592000000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-4][0-9]{5}|25[0-8][0-9]{4}|259[0-1][0-9]{3}|2592000)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-2][0-9]{3}|43[0-1][0-9]{2}|43200)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-1][0-9]{1}|720)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30)d)$" } - ] - }, - "retain_generations": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16, "description": "Number of completed cache generations, including the active generation, retained as a bounded recovery set after successful publication. This does not make arbitrary retained generations selectable for rollback.", "examples": [2] } - } - } - }, - "examples": [ - { - "version": 1, - "id": "people", - "revision": 1, - "primary_key": "person_id", - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["person_id"], - "properties": { "person_id": { "type": "string" } } - }, - "materialization": { - "max_records": 10000, - "max_bytes": "16MiB", - "refresh": "1h", - "retain_generations": 2 - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "propertyName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$", "description": "Portable lowercase record field name." }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for a nullable field.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "items": false, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "fieldSchema": { - "description": "Bounded schema keywords supported for one scalar materialized field.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "enum": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true }, - "const": {"x-registry-field": "property"}, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 65536 }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 } - } - }, - "objectSchema": { - "description": "Closed object schema whose declared required fields must appear in properties.", - "type": "object", - "additionalProperties": false, - "required": ["type", "additionalProperties", "required", "properties"], - "properties": { - "type": {"x-registry-field": "property", "const": "object" }, - "additionalProperties": {"x-registry-field": "property", "const": false }, - "required": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 256, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/propertyName" } - }, - "properties": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 256, - "propertyNames": {"x-registry-field": "property", "$ref": "#/$defs/propertyName" }, - "additionalProperties": {"x-registry-field": "property", "$ref": "#/$defs/fieldSchema" } - } - } - } - } -} diff --git a/projects/cra-civil/.registry-stack-editor/schemas/environment.schema.json b/projects/cra-civil/.registry-stack-editor/schemas/environment.schema.json deleted file mode 100644 index f04c70b..0000000 --- a/projects/cra-civil/.registry-stack-editor/schemas/environment.schema.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", - "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", - "type": "object", - "additionalProperties": false, - "required": ["version", "deployment"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { - "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "callers": { - "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } - } - }, - "relay": { - "x-registry-field": "property", - "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_cel": { - "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], - "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } - } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] - } - }, - "allOf": [ - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, - "then": { - "x-registry-field": "branch", - "required": ["notary_state"], - "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } - } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { - "x-registry-field": "branch", - "properties": { - "relay": { - "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - }, - "oid4vci": { - "x-registry-field": "property", - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - } - } - } - } - } - } - ], - "examples": [ - { - "version": 1, - "deployment": { - "profile": "local", - "notary": { "service": "notary" } - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", - "type": "object", - "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } - }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } - } - } - }, - "privateCidrs": { - "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } - }, - "ca": { - "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "mtls": { - "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "endpoint": { - "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } - } - }, - "credential": { - "description": "One supported source credential shape, containing references rather than values.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } - ] - }, - "provider": { - "description": "Environment-specific physical provider for a materialized entity.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } - ] - }, - "entity": { - "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } - }, - "source": { - "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, - "timeout": { - "x-registry-field": "property", - "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - }, - "integration": { - "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } - }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } - } -} diff --git a/projects/cra-civil/.registry-stack-editor/schemas/fixture.schema.json b/projects/cra-civil/.registry-stack-editor/schemas/fixture.schema.json deleted file mode 100644 index b853192..0000000 --- a/projects/cra-civil/.registry-stack-editor/schemas/fixture.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/fixture.v1.json", - "title": "Registry Stack project integration fixture v1", - "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", - "type": "object", - "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], - "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." - }, - "input": { - "x-registry-field": "redacted_fixture_property", - "description": "Typed consultation inputs supplied to the adapter under test.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] - } - }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Named date values available to deterministic fixture interpolation.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "interactions": { - "x-registry-field": "redacted_fixture_property", - "description": "Ordered upstream request and response exchanges expected during execution.", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } - }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } - }, - "examples": [ - { - "name": "existing household matches", - "classification": "synthetic", - "input": { "household_id": "HH-1001" }, - "interactions": [ - { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } - } - ], - "expect": { "outcome": "match", "outputs": { "active": true } } - } - ], - "$defs": { - "governedRequest": { - "description": "The same closed governed request shape accepted by project test --live.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the same governed request boundary as a live Notary evaluation.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } - }, - "attributes": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } - } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } - } - }, - "request": { - "description": "Exact HTTP request shape the adapter must produce.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "query": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, - { - "x-registry-field": "branch", - "type": "array", - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } - } - ] - } - }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - "response": { - "description": "Synthetic HTTP response or timeout returned to the adapter.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["timeout"], - "properties": { - "timeout": { - "x-registry-field": "redacted_fixture_property", - "description": "Reviewed synthetic timeout intent no greater than 20 seconds; offline execution returns a timeout outcome without sleeping.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - ] - }, - "fixtureBody": { - "description": "Inline JSON, or an exact closed reference to a strict JSON file below the fixture bodies directory. The top-level file key is reserved for file references.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": { - "x-registry-field": "redacted_fixture_property", - "type": "string", - "minLength": 8, - "maxLength": 4096, - "pattern": "^bodies/(?!\\.\\.?/)(?!.*(?:/)\\.\\.?/)(?!.*//).+$" - } - } - }, - { - "x-registry-field": "branch", - "not": { - "x-registry-field": "branch", - "type": "object", - "required": ["file"] - } - } - ] - }, - "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } - } - } - } -} diff --git a/projects/cra-civil/.registry-stack-editor/schemas/integration.schema.json b/projects/cra-civil/.registry-stack-editor/schemas/integration.schema.json deleted file mode 100644 index 9bb2ad0..0000000 --- a/projects/cra-civil/.registry-stack-editor/schemas/integration.schema.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/integration.v1.json", - "title": "Registry Stack project integration v1", - "description": "Defines a product-neutral source adaptation contract using bounded HTTP, Rhai script, or exact snapshot capabilities.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "input", "capability", "outputs"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Integration authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the integration." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this integration contract.", "examples": [1] }, - "source": {"x-registry-field": "property", "$ref": "#/$defs/source", "description": "Optional source product metadata and transport policy; capabilities remain product-neutral." }, - "input": { - "x-registry-field": "property", - "description": "Typed selector and parameter inputs accepted by this integration.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/input" } - }, - "capability": {"x-registry-field": "property", "$ref": "#/$defs/capability", "description": "Exactly one bounded execution mechanism for the source adaptation." }, - "outputs": { - "x-registry-field": "property", - "description": "Named typed outputs, or a shorthand list of output names inferred by the authoring compiler.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": { - "x-registry-field": "map_key", - "allOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputName" }, - {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "enum": ["matched", "outcome"] } } - ] - }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/output" } - }, - { - "x-registry-field": "branch", - "type": "array", - "minItems": 1, - "maxItems": 64, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/inputName" } - } - ] - }, - "limits": {"x-registry-field": "property", "$ref": "#/$defs/limits", "description": "Optional tighter resource limits for one integration execution." }, - "not_applicable": {"x-registry-field": "property", "$ref": "#/$defs/notApplicable", "description": "Explicit rationale and request-fixture evidence for a normally required outcome that the source contract cannot produce." } - }, - "examples": [ - { - "version": 1, - "id": "household_lookup", - "revision": 1, - "input": { - "household_id": { "role": "selector", "type": "string" } - }, - "capability": { - "script": { "file": "adapter.rhai" } - }, - "outputs": { - "active": { "type": "boolean" } - } - } - ], - "$defs": { - "notApplicable": { - "description": "Closed declarations for outcomes that the source protocol, exact singleton endpoint, or local unique-key constraint proves impossible.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ambiguity": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed source contract cannot produce more than one matching record." }, - "subject_mismatch": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed response contract contains no identifier comparable with a requested selector." } - } - }, - "notApplicableReason": { - "description": "A reviewable reason that names a real request fixture proving the source access shape used by the rationale.", - "type": "object", - "additionalProperties": false, - "required": ["rationale", "request_fixture"], - "properties": { - "rationale": {"x-registry-field": "property", "type": "string", "minLength": 24, "maxLength": 512, "description": "Why the named normally required outcome cannot occur for this source contract." }, - "request_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Fixture name containing the supporting bounded source request." } - } - }, - "stableId": { - "description": "Lowercase stable identifier used in project references.", - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,95}$" - }, - "inputName": { - "description": "Portable lowercase name for an integration input or output.", - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "relativePath": { - "description": "Project-relative path without traversal or duplicate separators.", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" - }, - "integrationResponseByteSize": { - "description": "Maximum bytes accepted from one source response, defaulting to 512 KiB and capped at 8 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "512KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 8388608 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|[1-7][0-9]{3}|80[0-9]{2}|81[0-8][0-9]|819[0-2])KiB|[1-8]MiB)$" } - ] - }, - "integrationRequestByteSize": { - "description": "Maximum aggregate author-controlled request bytes for one execution, defaulting to 64 KiB and capped at 1 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "64KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1048576 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])KiB|1MiB)$" } - ] - }, - "integrationSourceByteSize": { - "description": "Maximum aggregate source-response bytes for one execution, defaulting to 2 MiB and capped at 16 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "2MiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 16777216 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-5][0-9]{3}|16[0-2][0-9]{2}|163[0-7][0-9]|1638[0-4])KiB|(?:[1-9]|1[0-6])MiB)$" } - ] - }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for nullable values.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "input": { - "description": "Type, validation, and canonicalization rules for one authored input.", - "type": "object", - "additionalProperties": false, - "required": ["role", "type"], - "properties": { - "role": {"x-registry-field": "property", "enum": ["selector", "parameter"] }, - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "description": "Maximum Unicode scalar-value length. The 1024-character ceiling derives a conservative 4096-byte UTF-8 ceiling for one canonical input.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 16384 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "canonicalization": {"x-registry-field": "property", "enum": ["identity", "ascii_lowercase"] } - } - }, - "versions": { - "description": "Source versions tested by the project and versions explicitly accepted as unverified.", - "type": "object", - "additionalProperties": false, - "properties": { - "tested": {"x-registry-field": "property", "$ref": "#/$defs/versionList" }, - "unverified": {"x-registry-field": "property", "$ref": "#/$defs/versionList" } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["tested"], "properties": { "tested": {"x-registry-field": "property", "minItems": 1 } } }, - {"x-registry-field": "branch", "required": ["unverified"], "properties": { "unverified": {"x-registry-field": "property", "minItems": 1 } } } - ] - }, - "versionList": { - "description": "Bounded list of source version labels.", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } - }, - "credential": { - "description": "Authentication mechanism expected by the source adapter, without environment secret values.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "enum": ["none", "basic", "static_bearer"] } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_header" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_query" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9._:~-]{0,95}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "request", "response_profile"], - "properties": { - "type": {"x-registry-field": "property", "const": "oauth2_client_credentials" }, - "request": {"x-registry-field": "property", "enum": ["form", "json"] }, - "response_profile": {"x-registry-field": "property", "const": "oauth2_bearer" }, - "scope": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 }, - "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "refresh_skew": { - "x-registry-field": "property", - "description": "Positive OAuth refresh interval below the generated token contract's 60-second minimum lifetime.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4})ms|(?:[1-9]|[1-5][0-9])s)$" - } - } - } - ] - }, - "allowRule": { - "description": "One read-only upstream method and path pattern the adapter may call.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": { - "x-registry-field": "sensitive_property", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^/[^?#]*$" - }, - "semantics": {"x-registry-field": "property", "const": "read_only" } - } - }, - "source": { - "description": "Optional source metadata, authentication shape, allowlist, and protocol profile.", - "type": "object", - "additionalProperties": false, - "required": ["auth"], - "properties": { - "product": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "versions": {"x-registry-field": "property", "$ref": "#/$defs/versions" }, - "auth": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "allow": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/allowRule" } - }, - "request_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "format": {"x-registry-field": "property", "enum": ["json", "text"] }, - "max_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationResponseByteSize" } - } - }, - "protocol": {"x-registry-field": "property", "$ref": "#/$defs/protocol" } - } - }, - "protocol": { - "description": "Optional interoperable protocol profiles layered over the source transport.", - "type": "object", - "additionalProperties": false, - "properties": { - "signed_dci": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["profile", "path", "jwks_profile", "sender", "receiver", "registry_type", "record_type", "locale", "selectors"], - "properties": { - "profile": {"x-registry-field": "property", "const": "dci-search-v1" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^/[^?#]*$" }, - "jwks_profile": {"x-registry-field": "property", "const": "rsa-signing-jwks-v1" }, - "sender": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "receiver": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "registry_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "record_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "locale": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" }, - "selectors": { - "x-registry-field": "property", - "type": "object", "minProperties": 1, "maxProperties": 8, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["field", "response_pointer"], - "properties": { - "field": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 160 }, - "response_pointer": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" } - } - } - } - } - } - }, - "minProperties": 1 - }, - "inputReference": { - "description": "Reference to a declared integration input.", - "type": "object", - "additionalProperties": false, - "required": ["input"], - "properties": { "input": {"x-registry-field": "property", "$ref": "#/$defs/inputName" } } - }, - "inputOrLiteral": { - "description": "Request value supplied from a declared input or an authored scalar literal.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputReference" }, - {"x-registry-field": "branch", "type": ["string", "boolean", "integer"] } - ] - }, - "httpRequest": { - "description": "Bounded read-only HTTP request template.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "semantics": {"x-registry-field": "property", "const": "read_only" }, - "query": {"x-registry-field": "property", "type": "object", "maxProperties": 64, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "headers": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "body": { "x-registry-field": "property" } - } - }, - "httpResponse": { - "description": "Rules for translating upstream status and body cardinality into normalized outcomes.", - "type": "object", - "additionalProperties": false, - "properties": { - "no_match": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "ambiguous": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "shape": { - "x-registry-field": "property", - "oneOf": [ - {"x-registry-field": "branch", "const": "singleton" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["records", "cardinality"], - "properties": { - "records": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" }, - "cardinality": {"x-registry-field": "property", "const": "probe_two" } - } - } - ] - } - } - }, - "capability": { - "description": "Closed choice among direct HTTP, Rhai script, and exact snapshot execution.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["http"], - "properties": { - "http": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["request"], - "properties": { - "request": {"x-registry-field": "property", "$ref": "#/$defs/httpRequest" }, - "response": {"x-registry-field": "property", "$ref": "#/$defs/httpResponse" } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "script": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, - "modules": { - "x-registry-field": "sensitive_property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/relativePath" } - } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["snapshot"], - "properties": { - "snapshot": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["entity", "exact", "freshness"], - "properties": { - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "exact": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 8, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputReference" } - }, - "freshness": { - "x-registry-field": "property", - "type": "string", - "description": "Maximum accepted snapshot age as a canonical positive duration no greater than 31 days.", - "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-5][0-9]{8}|26[0-6][0-9]{7}|267[0-7][0-9]{6}|2678[0-3][0-9]{5}|2678400000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-5][0-9]{5}|26[0-6][0-9]{4}|267[0-7][0-9]{3}|2678[0-3][0-9]{2}|2678400)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-3][0-9]{3}|44[0-5][0-9]{2}|446[0-3][0-9]{1}|44640)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-3][0-9]{1}|74[0-3]|744)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30|31)d)$" - } - } - } - } - } - ] - }, - "output": { - "description": "Type and optional source pointer for one normalized integration output.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "x-registry-source": { - "x-registry-field": "property", - "type": "string", - "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" - } - } - }, - "limits": { - "description": "Per-execution call, byte, and deadline budgets.", - "type": "object", - "additionalProperties": false, - "properties": { - "calls": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16 }, - "request_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationRequestByteSize" }, - "source_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationSourceByteSize" }, - "deadline": { - "x-registry-field": "property", - "description": "Positive total integration deadline no greater than 20 seconds.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - } -} diff --git a/projects/cra-civil/.registry-stack-editor/schemas/project.schema.json b/projects/cra-civil/.registry-stack-editor/schemas/project.schema.json deleted file mode 100644 index 91b7ea7..0000000 --- a/projects/cra-civil/.registry-stack-editor/schemas/project.schema.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", - "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", - "type": "object", - "additionalProperties": false, - "required": ["version", "registry", "services"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, - "starter": { - "x-registry-field": "property", - "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", - "type": "object", - "additionalProperties": false, - "required": ["id", "release", "content_digest"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } - } - }, - "registry": { - "x-registry-field": "property", - "description": "Stable identity of the Registry Stack project.", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } - }, - "integrations": { - "x-registry-field": "property", - "description": "Source adaptation definitions keyed by project-local integration identifier.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "entities": { - "x-registry-field": "property", - "description": "Materialized entity definitions keyed by project-local entity identifier.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "services": { - "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } - } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } - ], - "examples": [ - { - "version": 1, - "registry": { "id": "benefits-registry" }, - "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } - }, - "services": {} - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, - "recordsApi": { - "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", - "type": "object", - "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], - "properties": { - "scopes": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["metadata", "rows"], - "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } - } - }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "pagination": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["default_limit", "max_limit"], - "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } - } - }, - "filters": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } - }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "relationships": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - } - }, - "aggregates": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } - }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "standards": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], - "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } - } - } - } - }, - "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } - }, - "recordAttributeReleaseProfile": { - "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", - "type": "object", - "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], - "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "subject": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["source_field", "id_type"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } - } - }, - "release_conditions": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } - }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["required", "sensitivity"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } - }, - "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } - ] - } - } - } - }, - "recordAttributeReleaseExpression": { - "description": "Bounded CEL evaluated only against the projected source object.", - "type": "object", - "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } - }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "recordAggregate": { - "description": "Governed aggregate definition with disclosure controls.", - "type": "object", - "additionalProperties": false, - "required": ["description", "disclosure_control"], - "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, - "disclosure_control": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } - } - } - }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] - }, - "recordAggregateAccess": { - "description": "Authorization scopes and execution policy for one governed aggregate.", - "type": "object", - "additionalProperties": false, - "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } - } - }, - "recordAggregateSpatial": { - "description": "Administrative-area geometry join used to spatially group aggregate results.", - "type": "object", - "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], - "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatial": { - "description": "OGC API Features collection metadata and bounded geometry mapping.", - "type": "object", - "additionalProperties": false, - "required": ["geometry"], - "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatialGeometry": { - "description": "Supported point or encoded-geometry field mapping for spatial records.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - } - ] - }, - "recordSpatialBbox": { - "description": "Record fields carrying the four coordinates of a bounding box.", - "type": "object", - "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], - "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordSpdci": { - "description": "SP-DCI registry identity and field mappings for a records service.", - "type": "object", - "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], - "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } - } - }, - "recordFieldMap": { - "description": "Bounded mapping from standard field names to entity field identifiers.", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } - }, - "recordAggregateDimension": { - "description": "Named grouping dimension backed by one entity field.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "field"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateIndicator": { - "description": "SDMX-style aggregate indicator with units and display metadata.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateMeasure": { - "description": "Named aggregate calculation over one entity column.", - "type": "object", - "additionalProperties": false, - "required": ["name", "function", "column"], - "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordAggregateFunction": { - "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] - }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } - } - ] - }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, - "recordsService": { - "description": "Relay records API backed by one authored materialized entity.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "entity", "api"], - "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Source-free claims remain evaluation-only and cannot be selected by a credential profile.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { "output": {"x-registry-field": "property", "type": "string" }, "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } - }, - "credential_profiles": { - "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation. Source-free claim evaluation cannot be exposed as credential capability.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } - } - } - } - }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, - "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } - }, - "consultations": { - "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/targetRequestMapping" } } } } - }, - "targetRequestMapping": { - "description": "Closed target request binding. Attribute values are bounded typed caller-supplied context, not authenticated identifiers.", - "type": "string", - "maxLength": 128, - "pattern": "^request\\.target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})$" - }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for source-free evaluation. A source-free claim cannot be selected by a credential profile.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } - } - } -} diff --git a/projects/cra-civil/.vscode/extensions.json b/projects/cra-civil/.vscode/extensions.json deleted file mode 100644 index 6098b6e..0000000 --- a/projects/cra-civil/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "redhat.vscode-yaml" - ] -} diff --git a/projects/cra-civil/.vscode/settings.json b/projects/cra-civil/.vscode/settings.json deleted file mode 100644 index db92a93..0000000 --- a/projects/cra-civil/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "yaml.schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } -} diff --git a/projects/cra-civil/.zed/settings.json b/projects/cra-civil/.zed/settings.json deleted file mode 100644 index fac07ac..0000000 --- a/projects/cra-civil/.zed/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } - } - } - } - } -} diff --git a/projects/cra-civil/entities/civil_people.yaml b/projects/cra-civil/entities/civil_people.yaml deleted file mode 100644 index 7580f97..0000000 --- a/projects/cra-civil/entities/civil_people.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -id: civil_people -revision: 1 -primary_key: uin -schema: - type: object - additionalProperties: false - required: [uin, birth_date, birth_brn, deceased] - properties: - uin: - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - birth_date: - type: string - format: date - maxLength: 10 - birth_brn: - type: [string, "null"] - maxLength: 32 - deceased: - type: boolean -materialization: - max_records: 1000000 - max_bytes: 256MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/cra-civil/environments/hosted.yaml b/projects/cra-civil/environments/hosted.yaml deleted file mode 100644 index f5fdebd..0000000 --- a/projects/cra-civil/environments/hosted.yaml +++ /dev/null @@ -1,44 +0,0 @@ -version: 1 -entities: - civil_people: - provider: - type: csv - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - header_row: 1 - columns: - uin: uin - birth_date: birth_date - birth_brn: birth_brn - deceased: deceased - source_revision: cra-civil-projection-v1 - generation: 2026-07-14 -callers: - child-benefit-federator: - api_key_fingerprint: { secret: CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["evidence:cra:child-benefit:read"] - pension-review-desk: - api_key_fingerprint: { secret: CRA_PENSION_CLIENT_TOKEN_HASH } - scopes: ["evidence:cra:pension:read"] - citizen-portal: - api_key_fingerprint: { secret: CRA_CITIZEN_CLIENT_TOKEN_HASH } - scopes: ["evidence:cra:citizen:read"] -relay: - origin: https://cra-relay.solmara.registrystack.org - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json - audience: registry-relay - allowed_clients: [] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: cra-notary - token_file: /run/secrets/cra-notary-relay-token -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -deployment: - profile: hosted_lab - relay: { service: cra-civil-relay } - notary: { service: cra-notary } diff --git a/projects/cra-civil/environments/local.yaml b/projects/cra-civil/environments/local.yaml deleted file mode 100644 index 70299bb..0000000 --- a/projects/cra-civil/environments/local.yaml +++ /dev/null @@ -1,49 +0,0 @@ -version: 1 -entities: - civil_people: - provider: - type: csv - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - header_row: 1 - columns: - uin: uin - birth_date: birth_date - birth_brn: birth_brn - deceased: deceased - source_revision: cra-civil-projection-v1 - generation: 2026-07-14 -callers: - child-benefit-federator: - api_key_fingerprint: { secret: CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["evidence:cra:child-benefit:read"] - pension-review-desk: - api_key_fingerprint: { secret: CRA_PENSION_CLIENT_TOKEN_HASH } - scopes: ["evidence:cra:pension:read"] - citizen-portal: - api_key_fingerprint: { secret: CRA_CITIZEN_CLIENT_TOKEN_HASH } - scopes: ["evidence:cra:citizen:read"] -relay: - origin: http://127.0.0.1:8080 - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json - audience: registry-relay - allowed_clients: [] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: cra-notary - token_file: /run/secrets/cra-notary-relay-token -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -# Emulated local images require Registry Stack's bounded 1 GiB CEL worker ceiling. -# Hosted deployments intentionally retain the 128 MiB default. -notary_cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local - relay: { service: cra-civil-relay } - notary: { service: cra-notary } diff --git a/projects/cra-civil/integrations/civil-person-by-uin/fixtures/future-birth-date.yaml b/projects/cra-civil/integrations/civil-person-by-uin/fixtures/future-birth-date.yaml deleted file mode 100644 index a114369..0000000 --- a/projects/cra-civil/integrations/civil-person-by-uin/fixtures/future-birth-date.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: cra-civil-person-future-birth-date -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300010248" }] - variables: { as_of_date: 2026-07-14 } - claims: [birth-is-registered, child-age-under-5] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review -input: { uin: "2300010248" } -variables: { as_of_date: 2026-07-14 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - birth_date: 2027-03-14 - birth_brn: BRN-2027-0101-00001 - deceased: false -expect: - outcome: match - outputs: - birth_date: 2027-03-14 - birth_brn: BRN-2027-0101-00001 - deceased: false - claims: - birth-is-registered: true - child-age-under-5: false - person-is-deceased: false - civil-record-linked: true diff --git a/projects/cra-civil/integrations/civil-person-by-uin/fixtures/match.yaml b/projects/cra-civil/integrations/civil-person-by-uin/fixtures/match.yaml deleted file mode 100644 index 9576003..0000000 --- a/projects/cra-civil/integrations/civil-person-by-uin/fixtures/match.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: cra-civil-person-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300010248" }] - claims: [civil-record-linked] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service -input: { uin: "2300010248" } -variables: { as_of_date: 2026-07-14 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - birth_date: 2022-03-14 - birth_brn: BRN-2022-0101-00001 - deceased: false -expect: - outcome: match - outputs: - birth_date: 2022-03-14 - birth_brn: BRN-2022-0101-00001 - deceased: false - claims: - birth-is-registered: true - child-age-under-5: true - person-is-deceased: false - civil-record-linked: true diff --git a/projects/cra-civil/integrations/civil-person-by-uin/fixtures/no-match.yaml b/projects/cra-civil/integrations/civil-person-by-uin/fixtures/no-match.yaml deleted file mode 100644 index 364dfc2..0000000 --- a/projects/cra-civil/integrations/civil-person-by-uin/fixtures/no-match.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: cra-civil-person-no-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "9999999999" }] - claims: [person-is-deceased] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review -input: { uin: "9999999999" } -variables: { as_of_date: 2026-07-14 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: - birth-is-registered: false - child-age-under-5: false - person-is-deceased: false - civil-record-linked: false diff --git a/projects/cra-civil/integrations/civil-person-by-uin/integration.yaml b/projects/cra-civil/integrations/civil-person-by-uin/integration.yaml deleted file mode 100644 index dd13213..0000000 --- a/projects/cra-civil/integrations/civil-person-by-uin/integration.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -id: solmara-cra-civil-person-by-uin -revision: 1 - -input: - uin: - role: selector - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - -capability: - snapshot: - entity: civil_people - exact: - uin: { input: uin } - freshness: 24h - -outputs: [birth_date, birth_brn, deceased] - -not_applicable: - ambiguity: - rationale: The exact UIN selector is the entity primary key, whose materialized unique-key constraint permits at most one civil record. - request_fixture: cra-civil-person-match - subject_mismatch: - rationale: The minimized output projection omits UIN, so it contains no identifier comparable with the requested UIN. - request_fixture: cra-civil-person-match diff --git a/projects/cra-civil/registry-stack.yaml b/projects/cra-civil/registry-stack.yaml deleted file mode 100644 index db54e87..0000000 --- a/projects/cra-civil/registry-stack.yaml +++ /dev/null @@ -1,78 +0,0 @@ -version: 1 - -registry: - id: solmara-cra-civil - -integrations: - civil-person-by-uin: - file: integrations/civil-person-by-uin/integration.yaml - -entities: - civil_people: - file: entities/civil_people.yaml - -services: - cra-child-benefit: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - legal_basis: child-benefit-act - consent: not_required - access: - scopes: ["evidence:cra:child-benefit:read"] - variables: - as_of_date: - from: request.variables.as_of_date - type: date - consultations: - civil: - integration: civil-person-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - birth-is-registered: - cel: "civil.matched && civil.birth_brn != null ? civil.birth_brn != '' : false" - disclosure: predicate - child-age-under-5: - cel: >- - civil.matched && civil.birth_date != null && civil.deceased == false - ? date.age_on(civil.birth_date, as_of_date) >= 0 - && date.age_on(civil.birth_date, as_of_date) < 5 - : false - disclosure: predicate - - cra-pension-death: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - legal_basis: social-insurance-act - consent: not_required - access: - scopes: ["evidence:cra:pension:read"] - consultations: - civil: - integration: civil-person-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - person-is-deceased: - cel: "civil.matched && civil.deceased == true" - disclosure: predicate - - cra-citizen-record: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service - legal_basis: citizen-self-service - consent: not_required - access: - scopes: ["evidence:cra:citizen:read"] - consultations: - civil: - integration: civil-person-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - civil-record-linked: - cel: "civil.matched" - disclosure: predicate diff --git a/projects/mosd-programme/.registry-stack-editor/manifest.json b/projects/mosd-programme/.registry-stack-editor/manifest.json deleted file mode 100644 index 3603a8e..0000000 --- a/projects/mosd-programme/.registry-stack-editor/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "format": "registry.stack.editor-manifest", - "version": 1, - "registryctl_version": "0.15.2", - "schemas": [ - { - "kind": "project", - "path": "schemas/project.schema.json", - "file_glob": "registry-stack.yaml", - "sha256": "sha256:9fd76663c866c52bb5a17a4cfea821049b7428c812f1666da9db0d43a00da144" - }, - { - "kind": "environment", - "path": "schemas/environment.schema.json", - "file_glob": "environments/*.yaml", - "sha256": "sha256:d16828629e001a30a1705af90bcc12a214be18cf5805a9833e65b03d631c9e9b" - }, - { - "kind": "integration", - "path": "schemas/integration.schema.json", - "file_glob": "integrations/*/integration.yaml", - "sha256": "sha256:2e45452f63c3c610ed14b79244bc9aa086d79d811ad0899223b36c160d449a79" - }, - { - "kind": "fixture", - "path": "schemas/fixture.schema.json", - "file_glob": "integrations/*/fixtures/*.yaml", - "sha256": "sha256:98c0ab21e35cecd143b51e4b5dd487de07ceea24e505bbe351d212e0628c8c8c" - }, - { - "kind": "entity", - "path": "schemas/entity.schema.json", - "file_glob": "entities/*.yaml", - "sha256": "sha256:4cbb31c8addeb54f8b57aaaff8f30c39e651a21a2d741a1b775ecb0d7c248342" - } - ] -} diff --git a/projects/mosd-programme/.registry-stack-editor/schemas/entity.schema.json b/projects/mosd-programme/.registry-stack-editor/schemas/entity.schema.json deleted file mode 100644 index ffc57c8..0000000 --- a/projects/mosd-programme/.registry-stack-editor/schemas/entity.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/entity.v1.json", - "title": "Registry Stack project entity v1", - "description": "Defines a bounded, materialized entity that Relay can query without coupling the project to a specific source product.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "primary_key", "schema", "materialization"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Entity authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the entity." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this entity definition.", "examples": [1] }, - "primary_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Field whose value uniquely identifies each materialized record.", "examples": ["person_id"] }, - "schema": {"x-registry-field": "property", "$ref": "#/$defs/objectSchema", "description": "Closed JSON object schema for materialized records." }, - "materialization": { - "x-registry-field": "property", - "description": "Authored resource, refresh, and bounded recovery-set retention applied while building entity generations.", - "type": "object", - "additionalProperties": false, - "required": ["max_records", "max_bytes", "refresh", "retain_generations"], - "properties": { - "max_records": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 100000000, "description": "Maximum number of records allowed in one generation.", "examples": [10000] }, - "max_bytes": { - "x-registry-field": "property", - "description": "Maximum encoded size of one generation, up to 1 GiB, as bytes or a KiB/MiB quantity.", - "examples": ["16MiB"], - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1073741824 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,5}|10[0-3][0-9]{4}|104[0-7][0-9]{3}|1048[0-4][0-9]{2}|10485[0-6][0-9]|104857[0-6])KiB|(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])MiB)$" } - ] - }, - "refresh": { - "x-registry-field": "property", - "description": "Refresh cadence as a canonical positive duration no greater than 30 days, or manual when an operator initiates every refresh.", - "examples": ["1h"], - "oneOf": [ - {"x-registry-field": "branch", "const": "manual" }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-4][0-9]{8}|25[0-8][0-9]{7}|259[0-1][0-9]{6}|2592000000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-4][0-9]{5}|25[0-8][0-9]{4}|259[0-1][0-9]{3}|2592000)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-2][0-9]{3}|43[0-1][0-9]{2}|43200)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-1][0-9]{1}|720)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30)d)$" } - ] - }, - "retain_generations": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16, "description": "Number of completed cache generations, including the active generation, retained as a bounded recovery set after successful publication. This does not make arbitrary retained generations selectable for rollback.", "examples": [2] } - } - } - }, - "examples": [ - { - "version": 1, - "id": "people", - "revision": 1, - "primary_key": "person_id", - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["person_id"], - "properties": { "person_id": { "type": "string" } } - }, - "materialization": { - "max_records": 10000, - "max_bytes": "16MiB", - "refresh": "1h", - "retain_generations": 2 - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "propertyName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$", "description": "Portable lowercase record field name." }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for a nullable field.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "items": false, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "fieldSchema": { - "description": "Bounded schema keywords supported for one scalar materialized field.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "enum": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true }, - "const": {"x-registry-field": "property"}, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 65536 }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 } - } - }, - "objectSchema": { - "description": "Closed object schema whose declared required fields must appear in properties.", - "type": "object", - "additionalProperties": false, - "required": ["type", "additionalProperties", "required", "properties"], - "properties": { - "type": {"x-registry-field": "property", "const": "object" }, - "additionalProperties": {"x-registry-field": "property", "const": false }, - "required": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 256, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/propertyName" } - }, - "properties": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 256, - "propertyNames": {"x-registry-field": "property", "$ref": "#/$defs/propertyName" }, - "additionalProperties": {"x-registry-field": "property", "$ref": "#/$defs/fieldSchema" } - } - } - } - } -} diff --git a/projects/mosd-programme/.registry-stack-editor/schemas/environment.schema.json b/projects/mosd-programme/.registry-stack-editor/schemas/environment.schema.json deleted file mode 100644 index f04c70b..0000000 --- a/projects/mosd-programme/.registry-stack-editor/schemas/environment.schema.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", - "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", - "type": "object", - "additionalProperties": false, - "required": ["version", "deployment"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { - "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "callers": { - "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } - } - }, - "relay": { - "x-registry-field": "property", - "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_cel": { - "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], - "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } - } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] - } - }, - "allOf": [ - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, - "then": { - "x-registry-field": "branch", - "required": ["notary_state"], - "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } - } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { - "x-registry-field": "branch", - "properties": { - "relay": { - "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - }, - "oid4vci": { - "x-registry-field": "property", - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - } - } - } - } - } - } - ], - "examples": [ - { - "version": 1, - "deployment": { - "profile": "local", - "notary": { "service": "notary" } - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", - "type": "object", - "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } - }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } - } - } - }, - "privateCidrs": { - "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } - }, - "ca": { - "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "mtls": { - "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "endpoint": { - "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } - } - }, - "credential": { - "description": "One supported source credential shape, containing references rather than values.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } - ] - }, - "provider": { - "description": "Environment-specific physical provider for a materialized entity.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } - ] - }, - "entity": { - "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } - }, - "source": { - "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, - "timeout": { - "x-registry-field": "property", - "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - }, - "integration": { - "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } - }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } - } -} diff --git a/projects/mosd-programme/.registry-stack-editor/schemas/fixture.schema.json b/projects/mosd-programme/.registry-stack-editor/schemas/fixture.schema.json deleted file mode 100644 index b853192..0000000 --- a/projects/mosd-programme/.registry-stack-editor/schemas/fixture.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/fixture.v1.json", - "title": "Registry Stack project integration fixture v1", - "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", - "type": "object", - "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], - "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." - }, - "input": { - "x-registry-field": "redacted_fixture_property", - "description": "Typed consultation inputs supplied to the adapter under test.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] - } - }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Named date values available to deterministic fixture interpolation.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "interactions": { - "x-registry-field": "redacted_fixture_property", - "description": "Ordered upstream request and response exchanges expected during execution.", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } - }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } - }, - "examples": [ - { - "name": "existing household matches", - "classification": "synthetic", - "input": { "household_id": "HH-1001" }, - "interactions": [ - { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } - } - ], - "expect": { "outcome": "match", "outputs": { "active": true } } - } - ], - "$defs": { - "governedRequest": { - "description": "The same closed governed request shape accepted by project test --live.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the same governed request boundary as a live Notary evaluation.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } - }, - "attributes": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } - } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } - } - }, - "request": { - "description": "Exact HTTP request shape the adapter must produce.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "query": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, - { - "x-registry-field": "branch", - "type": "array", - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } - } - ] - } - }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - "response": { - "description": "Synthetic HTTP response or timeout returned to the adapter.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["timeout"], - "properties": { - "timeout": { - "x-registry-field": "redacted_fixture_property", - "description": "Reviewed synthetic timeout intent no greater than 20 seconds; offline execution returns a timeout outcome without sleeping.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - ] - }, - "fixtureBody": { - "description": "Inline JSON, or an exact closed reference to a strict JSON file below the fixture bodies directory. The top-level file key is reserved for file references.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": { - "x-registry-field": "redacted_fixture_property", - "type": "string", - "minLength": 8, - "maxLength": 4096, - "pattern": "^bodies/(?!\\.\\.?/)(?!.*(?:/)\\.\\.?/)(?!.*//).+$" - } - } - }, - { - "x-registry-field": "branch", - "not": { - "x-registry-field": "branch", - "type": "object", - "required": ["file"] - } - } - ] - }, - "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } - } - } - } -} diff --git a/projects/mosd-programme/.registry-stack-editor/schemas/integration.schema.json b/projects/mosd-programme/.registry-stack-editor/schemas/integration.schema.json deleted file mode 100644 index 9bb2ad0..0000000 --- a/projects/mosd-programme/.registry-stack-editor/schemas/integration.schema.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/integration.v1.json", - "title": "Registry Stack project integration v1", - "description": "Defines a product-neutral source adaptation contract using bounded HTTP, Rhai script, or exact snapshot capabilities.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "input", "capability", "outputs"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Integration authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the integration." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this integration contract.", "examples": [1] }, - "source": {"x-registry-field": "property", "$ref": "#/$defs/source", "description": "Optional source product metadata and transport policy; capabilities remain product-neutral." }, - "input": { - "x-registry-field": "property", - "description": "Typed selector and parameter inputs accepted by this integration.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/input" } - }, - "capability": {"x-registry-field": "property", "$ref": "#/$defs/capability", "description": "Exactly one bounded execution mechanism for the source adaptation." }, - "outputs": { - "x-registry-field": "property", - "description": "Named typed outputs, or a shorthand list of output names inferred by the authoring compiler.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": { - "x-registry-field": "map_key", - "allOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputName" }, - {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "enum": ["matched", "outcome"] } } - ] - }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/output" } - }, - { - "x-registry-field": "branch", - "type": "array", - "minItems": 1, - "maxItems": 64, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/inputName" } - } - ] - }, - "limits": {"x-registry-field": "property", "$ref": "#/$defs/limits", "description": "Optional tighter resource limits for one integration execution." }, - "not_applicable": {"x-registry-field": "property", "$ref": "#/$defs/notApplicable", "description": "Explicit rationale and request-fixture evidence for a normally required outcome that the source contract cannot produce." } - }, - "examples": [ - { - "version": 1, - "id": "household_lookup", - "revision": 1, - "input": { - "household_id": { "role": "selector", "type": "string" } - }, - "capability": { - "script": { "file": "adapter.rhai" } - }, - "outputs": { - "active": { "type": "boolean" } - } - } - ], - "$defs": { - "notApplicable": { - "description": "Closed declarations for outcomes that the source protocol, exact singleton endpoint, or local unique-key constraint proves impossible.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ambiguity": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed source contract cannot produce more than one matching record." }, - "subject_mismatch": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed response contract contains no identifier comparable with a requested selector." } - } - }, - "notApplicableReason": { - "description": "A reviewable reason that names a real request fixture proving the source access shape used by the rationale.", - "type": "object", - "additionalProperties": false, - "required": ["rationale", "request_fixture"], - "properties": { - "rationale": {"x-registry-field": "property", "type": "string", "minLength": 24, "maxLength": 512, "description": "Why the named normally required outcome cannot occur for this source contract." }, - "request_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Fixture name containing the supporting bounded source request." } - } - }, - "stableId": { - "description": "Lowercase stable identifier used in project references.", - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,95}$" - }, - "inputName": { - "description": "Portable lowercase name for an integration input or output.", - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "relativePath": { - "description": "Project-relative path without traversal or duplicate separators.", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" - }, - "integrationResponseByteSize": { - "description": "Maximum bytes accepted from one source response, defaulting to 512 KiB and capped at 8 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "512KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 8388608 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|[1-7][0-9]{3}|80[0-9]{2}|81[0-8][0-9]|819[0-2])KiB|[1-8]MiB)$" } - ] - }, - "integrationRequestByteSize": { - "description": "Maximum aggregate author-controlled request bytes for one execution, defaulting to 64 KiB and capped at 1 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "64KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1048576 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])KiB|1MiB)$" } - ] - }, - "integrationSourceByteSize": { - "description": "Maximum aggregate source-response bytes for one execution, defaulting to 2 MiB and capped at 16 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "2MiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 16777216 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-5][0-9]{3}|16[0-2][0-9]{2}|163[0-7][0-9]|1638[0-4])KiB|(?:[1-9]|1[0-6])MiB)$" } - ] - }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for nullable values.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "input": { - "description": "Type, validation, and canonicalization rules for one authored input.", - "type": "object", - "additionalProperties": false, - "required": ["role", "type"], - "properties": { - "role": {"x-registry-field": "property", "enum": ["selector", "parameter"] }, - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "description": "Maximum Unicode scalar-value length. The 1024-character ceiling derives a conservative 4096-byte UTF-8 ceiling for one canonical input.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 16384 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "canonicalization": {"x-registry-field": "property", "enum": ["identity", "ascii_lowercase"] } - } - }, - "versions": { - "description": "Source versions tested by the project and versions explicitly accepted as unverified.", - "type": "object", - "additionalProperties": false, - "properties": { - "tested": {"x-registry-field": "property", "$ref": "#/$defs/versionList" }, - "unverified": {"x-registry-field": "property", "$ref": "#/$defs/versionList" } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["tested"], "properties": { "tested": {"x-registry-field": "property", "minItems": 1 } } }, - {"x-registry-field": "branch", "required": ["unverified"], "properties": { "unverified": {"x-registry-field": "property", "minItems": 1 } } } - ] - }, - "versionList": { - "description": "Bounded list of source version labels.", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } - }, - "credential": { - "description": "Authentication mechanism expected by the source adapter, without environment secret values.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "enum": ["none", "basic", "static_bearer"] } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_header" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_query" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9._:~-]{0,95}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "request", "response_profile"], - "properties": { - "type": {"x-registry-field": "property", "const": "oauth2_client_credentials" }, - "request": {"x-registry-field": "property", "enum": ["form", "json"] }, - "response_profile": {"x-registry-field": "property", "const": "oauth2_bearer" }, - "scope": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 }, - "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "refresh_skew": { - "x-registry-field": "property", - "description": "Positive OAuth refresh interval below the generated token contract's 60-second minimum lifetime.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4})ms|(?:[1-9]|[1-5][0-9])s)$" - } - } - } - ] - }, - "allowRule": { - "description": "One read-only upstream method and path pattern the adapter may call.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": { - "x-registry-field": "sensitive_property", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^/[^?#]*$" - }, - "semantics": {"x-registry-field": "property", "const": "read_only" } - } - }, - "source": { - "description": "Optional source metadata, authentication shape, allowlist, and protocol profile.", - "type": "object", - "additionalProperties": false, - "required": ["auth"], - "properties": { - "product": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "versions": {"x-registry-field": "property", "$ref": "#/$defs/versions" }, - "auth": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "allow": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/allowRule" } - }, - "request_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "format": {"x-registry-field": "property", "enum": ["json", "text"] }, - "max_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationResponseByteSize" } - } - }, - "protocol": {"x-registry-field": "property", "$ref": "#/$defs/protocol" } - } - }, - "protocol": { - "description": "Optional interoperable protocol profiles layered over the source transport.", - "type": "object", - "additionalProperties": false, - "properties": { - "signed_dci": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["profile", "path", "jwks_profile", "sender", "receiver", "registry_type", "record_type", "locale", "selectors"], - "properties": { - "profile": {"x-registry-field": "property", "const": "dci-search-v1" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^/[^?#]*$" }, - "jwks_profile": {"x-registry-field": "property", "const": "rsa-signing-jwks-v1" }, - "sender": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "receiver": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "registry_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "record_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "locale": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" }, - "selectors": { - "x-registry-field": "property", - "type": "object", "minProperties": 1, "maxProperties": 8, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["field", "response_pointer"], - "properties": { - "field": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 160 }, - "response_pointer": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" } - } - } - } - } - } - }, - "minProperties": 1 - }, - "inputReference": { - "description": "Reference to a declared integration input.", - "type": "object", - "additionalProperties": false, - "required": ["input"], - "properties": { "input": {"x-registry-field": "property", "$ref": "#/$defs/inputName" } } - }, - "inputOrLiteral": { - "description": "Request value supplied from a declared input or an authored scalar literal.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputReference" }, - {"x-registry-field": "branch", "type": ["string", "boolean", "integer"] } - ] - }, - "httpRequest": { - "description": "Bounded read-only HTTP request template.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "semantics": {"x-registry-field": "property", "const": "read_only" }, - "query": {"x-registry-field": "property", "type": "object", "maxProperties": 64, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "headers": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "body": { "x-registry-field": "property" } - } - }, - "httpResponse": { - "description": "Rules for translating upstream status and body cardinality into normalized outcomes.", - "type": "object", - "additionalProperties": false, - "properties": { - "no_match": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "ambiguous": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "shape": { - "x-registry-field": "property", - "oneOf": [ - {"x-registry-field": "branch", "const": "singleton" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["records", "cardinality"], - "properties": { - "records": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" }, - "cardinality": {"x-registry-field": "property", "const": "probe_two" } - } - } - ] - } - } - }, - "capability": { - "description": "Closed choice among direct HTTP, Rhai script, and exact snapshot execution.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["http"], - "properties": { - "http": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["request"], - "properties": { - "request": {"x-registry-field": "property", "$ref": "#/$defs/httpRequest" }, - "response": {"x-registry-field": "property", "$ref": "#/$defs/httpResponse" } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "script": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, - "modules": { - "x-registry-field": "sensitive_property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/relativePath" } - } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["snapshot"], - "properties": { - "snapshot": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["entity", "exact", "freshness"], - "properties": { - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "exact": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 8, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputReference" } - }, - "freshness": { - "x-registry-field": "property", - "type": "string", - "description": "Maximum accepted snapshot age as a canonical positive duration no greater than 31 days.", - "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-5][0-9]{8}|26[0-6][0-9]{7}|267[0-7][0-9]{6}|2678[0-3][0-9]{5}|2678400000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-5][0-9]{5}|26[0-6][0-9]{4}|267[0-7][0-9]{3}|2678[0-3][0-9]{2}|2678400)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-3][0-9]{3}|44[0-5][0-9]{2}|446[0-3][0-9]{1}|44640)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-3][0-9]{1}|74[0-3]|744)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30|31)d)$" - } - } - } - } - } - ] - }, - "output": { - "description": "Type and optional source pointer for one normalized integration output.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "x-registry-source": { - "x-registry-field": "property", - "type": "string", - "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" - } - } - }, - "limits": { - "description": "Per-execution call, byte, and deadline budgets.", - "type": "object", - "additionalProperties": false, - "properties": { - "calls": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16 }, - "request_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationRequestByteSize" }, - "source_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationSourceByteSize" }, - "deadline": { - "x-registry-field": "property", - "description": "Positive total integration deadline no greater than 20 seconds.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - } -} diff --git a/projects/mosd-programme/.registry-stack-editor/schemas/project.schema.json b/projects/mosd-programme/.registry-stack-editor/schemas/project.schema.json deleted file mode 100644 index 91b7ea7..0000000 --- a/projects/mosd-programme/.registry-stack-editor/schemas/project.schema.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", - "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", - "type": "object", - "additionalProperties": false, - "required": ["version", "registry", "services"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, - "starter": { - "x-registry-field": "property", - "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", - "type": "object", - "additionalProperties": false, - "required": ["id", "release", "content_digest"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } - } - }, - "registry": { - "x-registry-field": "property", - "description": "Stable identity of the Registry Stack project.", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } - }, - "integrations": { - "x-registry-field": "property", - "description": "Source adaptation definitions keyed by project-local integration identifier.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "entities": { - "x-registry-field": "property", - "description": "Materialized entity definitions keyed by project-local entity identifier.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "services": { - "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } - } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } - ], - "examples": [ - { - "version": 1, - "registry": { "id": "benefits-registry" }, - "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } - }, - "services": {} - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, - "recordsApi": { - "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", - "type": "object", - "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], - "properties": { - "scopes": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["metadata", "rows"], - "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } - } - }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "pagination": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["default_limit", "max_limit"], - "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } - } - }, - "filters": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } - }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "relationships": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - } - }, - "aggregates": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } - }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "standards": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], - "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } - } - } - } - }, - "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } - }, - "recordAttributeReleaseProfile": { - "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", - "type": "object", - "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], - "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "subject": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["source_field", "id_type"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } - } - }, - "release_conditions": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } - }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["required", "sensitivity"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } - }, - "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } - ] - } - } - } - }, - "recordAttributeReleaseExpression": { - "description": "Bounded CEL evaluated only against the projected source object.", - "type": "object", - "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } - }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "recordAggregate": { - "description": "Governed aggregate definition with disclosure controls.", - "type": "object", - "additionalProperties": false, - "required": ["description", "disclosure_control"], - "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, - "disclosure_control": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } - } - } - }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] - }, - "recordAggregateAccess": { - "description": "Authorization scopes and execution policy for one governed aggregate.", - "type": "object", - "additionalProperties": false, - "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } - } - }, - "recordAggregateSpatial": { - "description": "Administrative-area geometry join used to spatially group aggregate results.", - "type": "object", - "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], - "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatial": { - "description": "OGC API Features collection metadata and bounded geometry mapping.", - "type": "object", - "additionalProperties": false, - "required": ["geometry"], - "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatialGeometry": { - "description": "Supported point or encoded-geometry field mapping for spatial records.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - } - ] - }, - "recordSpatialBbox": { - "description": "Record fields carrying the four coordinates of a bounding box.", - "type": "object", - "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], - "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordSpdci": { - "description": "SP-DCI registry identity and field mappings for a records service.", - "type": "object", - "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], - "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } - } - }, - "recordFieldMap": { - "description": "Bounded mapping from standard field names to entity field identifiers.", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } - }, - "recordAggregateDimension": { - "description": "Named grouping dimension backed by one entity field.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "field"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateIndicator": { - "description": "SDMX-style aggregate indicator with units and display metadata.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateMeasure": { - "description": "Named aggregate calculation over one entity column.", - "type": "object", - "additionalProperties": false, - "required": ["name", "function", "column"], - "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordAggregateFunction": { - "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] - }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } - } - ] - }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, - "recordsService": { - "description": "Relay records API backed by one authored materialized entity.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "entity", "api"], - "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Source-free claims remain evaluation-only and cannot be selected by a credential profile.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { "output": {"x-registry-field": "property", "type": "string" }, "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } - }, - "credential_profiles": { - "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation. Source-free claim evaluation cannot be exposed as credential capability.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } - } - } - } - }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, - "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } - }, - "consultations": { - "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/targetRequestMapping" } } } } - }, - "targetRequestMapping": { - "description": "Closed target request binding. Attribute values are bounded typed caller-supplied context, not authenticated identifiers.", - "type": "string", - "maxLength": 128, - "pattern": "^request\\.target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})$" - }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for source-free evaluation. A source-free claim cannot be selected by a credential profile.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } - } - } -} diff --git a/projects/mosd-programme/.vscode/extensions.json b/projects/mosd-programme/.vscode/extensions.json deleted file mode 100644 index 6098b6e..0000000 --- a/projects/mosd-programme/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "redhat.vscode-yaml" - ] -} diff --git a/projects/mosd-programme/.vscode/settings.json b/projects/mosd-programme/.vscode/settings.json deleted file mode 100644 index db92a93..0000000 --- a/projects/mosd-programme/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "yaml.schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } -} diff --git a/projects/mosd-programme/.zed/settings.json b/projects/mosd-programme/.zed/settings.json deleted file mode 100644 index fac07ac..0000000 --- a/projects/mosd-programme/.zed/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } - } - } - } - } -} diff --git a/projects/mosd-programme/entities/mosd_enroll.yaml b/projects/mosd-programme/entities/mosd_enroll.yaml deleted file mode 100644 index f4c8aba..0000000 --- a/projects/mosd-programme/entities/mosd_enroll.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: 1 -id: mosd_enroll -revision: 1 -primary_key: uin -schema: - type: object - additionalProperties: false - required: [uin, duplicate_flag] - properties: - uin: - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - duplicate_flag: { type: boolean } -materialization: - max_records: 1000000 - max_bytes: 64MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/mosd-programme/environments/hosted.yaml b/projects/mosd-programme/environments/hosted.yaml deleted file mode 100644 index 26fb468..0000000 --- a/projects/mosd-programme/environments/hosted.yaml +++ /dev/null @@ -1,42 +0,0 @@ -version: 1 - -entities: - mosd_enroll: - provider: - type: csv - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - header_row: 1 - columns: - uin: uin - duplicate_flag: duplicate_flag - source_revision: solmara-mosd-programme-projection-v1 - generation: "2026-07-14" - -callers: - child-benefit-federator: - api_key_fingerprint: { secret: PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["mosd_programme:evidence_verification"] - -relay: - origin: https://mosd-programme-relay.solmara.registrystack.org - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json - audience: registry-relay - allowed_clients: [programme-notary] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: programme-notary - token_file: /run/secrets/programme-notary-relay-token - -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -deployment: - profile: hosted_lab - relay: { service: programme-mis-relay } - notary: { service: programme-notary } diff --git a/projects/mosd-programme/environments/local.yaml b/projects/mosd-programme/environments/local.yaml deleted file mode 100644 index 4e3aca0..0000000 --- a/projects/mosd-programme/environments/local.yaml +++ /dev/null @@ -1,48 +0,0 @@ -version: 1 - -entities: - mosd_enroll: - provider: - type: csv - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - header_row: 1 - columns: - uin: uin - duplicate_flag: duplicate_flag - source_revision: solmara-mosd-programme-projection-v1 - generation: "2026-07-14" - -callers: - child-benefit-federator: - api_key_fingerprint: { secret: PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["mosd_programme:evidence_verification"] - -relay: - origin: http://127.0.0.1:8080 - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json - audience: registry-relay - allowed_clients: [programme-notary] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: programme-notary - token_file: /run/secrets/programme-notary-relay-token - -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -# Emulated local images require Registry Stack's bounded 1 GiB CEL worker ceiling. -# Hosted deployments intentionally retain the 128 MiB default. -notary_cel: - worker_memory_bytes: 1073741824 - -deployment: - profile: local - relay: { service: programme-mis-relay } - notary: { service: programme-notary } diff --git a/projects/mosd-programme/integrations/enrollment-by-uin/fixtures/match.yaml b/projects/mosd-programme/integrations/enrollment-by-uin/fixtures/match.yaml deleted file mode 100644 index b063bc4..0000000 --- a/projects/mosd-programme/integrations/enrollment-by-uin/fixtures/match.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: enrollment-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300010248" }] - claims: [not-already-enrolled] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review -input: { uin: "2300010248" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: { duplicate_flag: false } } -expect: - outcome: match - outputs: { duplicate_flag: false } - claims: { not-already-enrolled: true } diff --git a/projects/mosd-programme/integrations/enrollment-by-uin/fixtures/no-match.yaml b/projects/mosd-programme/integrations/enrollment-by-uin/fixtures/no-match.yaml deleted file mode 100644 index 990e5e0..0000000 --- a/projects/mosd-programme/integrations/enrollment-by-uin/fixtures/no-match.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: enrollment-no-match -classification: synthetic -input: { uin: "2300999997" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: { not-already-enrolled: false } diff --git a/projects/mosd-programme/integrations/enrollment-by-uin/integration.yaml b/projects/mosd-programme/integrations/enrollment-by-uin/integration.yaml deleted file mode 100644 index 2b35969..0000000 --- a/projects/mosd-programme/integrations/enrollment-by-uin/integration.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -id: enrollment-by-uin -revision: 1 - -input: - uin: - role: selector - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - -capability: - snapshot: - entity: mosd_enroll - exact: - uin: { input: uin } - freshness: 24h - -outputs: [duplicate_flag] - -not_applicable: - ambiguity: - rationale: The exact UIN selector is the entity primary key, whose materialized unique-key constraint permits at most one enrollment projection. - request_fixture: enrollment-match - subject_mismatch: - rationale: The minimized output projection omits UIN, so it contains no identifier comparable with the requested UIN. - request_fixture: enrollment-match diff --git a/projects/mosd-programme/registry-stack.yaml b/projects/mosd-programme/registry-stack.yaml deleted file mode 100644 index 341ccb5..0000000 --- a/projects/mosd-programme/registry-stack.yaml +++ /dev/null @@ -1,33 +0,0 @@ -version: 1 - -registry: - id: solmara-mosd-programme - -integrations: - enrollment-by-uin: - file: integrations/enrollment-by-uin/integration.yaml - -entities: - mosd_enroll: - file: entities/mosd_enroll.yaml - -services: - child-benefit: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - legal_basis: child-benefit-act - consent: not_required - access: - scopes: ["mosd_programme:evidence_verification"] - consultations: - enrollment: - integration: enrollment-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - not-already-enrolled: - cel: enrollment.matched && enrollment.duplicate_flag == false - disclosure: - default: predicate - allowed: [predicate, redacted] diff --git a/projects/nagdi-agriculture/.registry-stack-editor/manifest.json b/projects/nagdi-agriculture/.registry-stack-editor/manifest.json deleted file mode 100644 index 3603a8e..0000000 --- a/projects/nagdi-agriculture/.registry-stack-editor/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "format": "registry.stack.editor-manifest", - "version": 1, - "registryctl_version": "0.15.2", - "schemas": [ - { - "kind": "project", - "path": "schemas/project.schema.json", - "file_glob": "registry-stack.yaml", - "sha256": "sha256:9fd76663c866c52bb5a17a4cfea821049b7428c812f1666da9db0d43a00da144" - }, - { - "kind": "environment", - "path": "schemas/environment.schema.json", - "file_glob": "environments/*.yaml", - "sha256": "sha256:d16828629e001a30a1705af90bcc12a214be18cf5805a9833e65b03d631c9e9b" - }, - { - "kind": "integration", - "path": "schemas/integration.schema.json", - "file_glob": "integrations/*/integration.yaml", - "sha256": "sha256:2e45452f63c3c610ed14b79244bc9aa086d79d811ad0899223b36c160d449a79" - }, - { - "kind": "fixture", - "path": "schemas/fixture.schema.json", - "file_glob": "integrations/*/fixtures/*.yaml", - "sha256": "sha256:98c0ab21e35cecd143b51e4b5dd487de07ceea24e505bbe351d212e0628c8c8c" - }, - { - "kind": "entity", - "path": "schemas/entity.schema.json", - "file_glob": "entities/*.yaml", - "sha256": "sha256:4cbb31c8addeb54f8b57aaaff8f30c39e651a21a2d741a1b775ecb0d7c248342" - } - ] -} diff --git a/projects/nagdi-agriculture/.registry-stack-editor/schemas/entity.schema.json b/projects/nagdi-agriculture/.registry-stack-editor/schemas/entity.schema.json deleted file mode 100644 index ffc57c8..0000000 --- a/projects/nagdi-agriculture/.registry-stack-editor/schemas/entity.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/entity.v1.json", - "title": "Registry Stack project entity v1", - "description": "Defines a bounded, materialized entity that Relay can query without coupling the project to a specific source product.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "primary_key", "schema", "materialization"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Entity authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the entity." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this entity definition.", "examples": [1] }, - "primary_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Field whose value uniquely identifies each materialized record.", "examples": ["person_id"] }, - "schema": {"x-registry-field": "property", "$ref": "#/$defs/objectSchema", "description": "Closed JSON object schema for materialized records." }, - "materialization": { - "x-registry-field": "property", - "description": "Authored resource, refresh, and bounded recovery-set retention applied while building entity generations.", - "type": "object", - "additionalProperties": false, - "required": ["max_records", "max_bytes", "refresh", "retain_generations"], - "properties": { - "max_records": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 100000000, "description": "Maximum number of records allowed in one generation.", "examples": [10000] }, - "max_bytes": { - "x-registry-field": "property", - "description": "Maximum encoded size of one generation, up to 1 GiB, as bytes or a KiB/MiB quantity.", - "examples": ["16MiB"], - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1073741824 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,5}|10[0-3][0-9]{4}|104[0-7][0-9]{3}|1048[0-4][0-9]{2}|10485[0-6][0-9]|104857[0-6])KiB|(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])MiB)$" } - ] - }, - "refresh": { - "x-registry-field": "property", - "description": "Refresh cadence as a canonical positive duration no greater than 30 days, or manual when an operator initiates every refresh.", - "examples": ["1h"], - "oneOf": [ - {"x-registry-field": "branch", "const": "manual" }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-4][0-9]{8}|25[0-8][0-9]{7}|259[0-1][0-9]{6}|2592000000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-4][0-9]{5}|25[0-8][0-9]{4}|259[0-1][0-9]{3}|2592000)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-2][0-9]{3}|43[0-1][0-9]{2}|43200)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-1][0-9]{1}|720)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30)d)$" } - ] - }, - "retain_generations": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16, "description": "Number of completed cache generations, including the active generation, retained as a bounded recovery set after successful publication. This does not make arbitrary retained generations selectable for rollback.", "examples": [2] } - } - } - }, - "examples": [ - { - "version": 1, - "id": "people", - "revision": 1, - "primary_key": "person_id", - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["person_id"], - "properties": { "person_id": { "type": "string" } } - }, - "materialization": { - "max_records": 10000, - "max_bytes": "16MiB", - "refresh": "1h", - "retain_generations": 2 - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "propertyName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$", "description": "Portable lowercase record field name." }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for a nullable field.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "items": false, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "fieldSchema": { - "description": "Bounded schema keywords supported for one scalar materialized field.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "enum": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true }, - "const": {"x-registry-field": "property"}, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 65536 }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 } - } - }, - "objectSchema": { - "description": "Closed object schema whose declared required fields must appear in properties.", - "type": "object", - "additionalProperties": false, - "required": ["type", "additionalProperties", "required", "properties"], - "properties": { - "type": {"x-registry-field": "property", "const": "object" }, - "additionalProperties": {"x-registry-field": "property", "const": false }, - "required": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 256, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/propertyName" } - }, - "properties": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 256, - "propertyNames": {"x-registry-field": "property", "$ref": "#/$defs/propertyName" }, - "additionalProperties": {"x-registry-field": "property", "$ref": "#/$defs/fieldSchema" } - } - } - } - } -} diff --git a/projects/nagdi-agriculture/.registry-stack-editor/schemas/environment.schema.json b/projects/nagdi-agriculture/.registry-stack-editor/schemas/environment.schema.json deleted file mode 100644 index f04c70b..0000000 --- a/projects/nagdi-agriculture/.registry-stack-editor/schemas/environment.schema.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", - "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", - "type": "object", - "additionalProperties": false, - "required": ["version", "deployment"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { - "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "callers": { - "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } - } - }, - "relay": { - "x-registry-field": "property", - "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_cel": { - "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], - "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } - } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] - } - }, - "allOf": [ - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, - "then": { - "x-registry-field": "branch", - "required": ["notary_state"], - "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } - } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { - "x-registry-field": "branch", - "properties": { - "relay": { - "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - }, - "oid4vci": { - "x-registry-field": "property", - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - } - } - } - } - } - } - ], - "examples": [ - { - "version": 1, - "deployment": { - "profile": "local", - "notary": { "service": "notary" } - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", - "type": "object", - "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } - }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } - } - } - }, - "privateCidrs": { - "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } - }, - "ca": { - "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "mtls": { - "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "endpoint": { - "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } - } - }, - "credential": { - "description": "One supported source credential shape, containing references rather than values.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } - ] - }, - "provider": { - "description": "Environment-specific physical provider for a materialized entity.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } - ] - }, - "entity": { - "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } - }, - "source": { - "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, - "timeout": { - "x-registry-field": "property", - "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - }, - "integration": { - "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } - }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } - } -} diff --git a/projects/nagdi-agriculture/.registry-stack-editor/schemas/fixture.schema.json b/projects/nagdi-agriculture/.registry-stack-editor/schemas/fixture.schema.json deleted file mode 100644 index b853192..0000000 --- a/projects/nagdi-agriculture/.registry-stack-editor/schemas/fixture.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/fixture.v1.json", - "title": "Registry Stack project integration fixture v1", - "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", - "type": "object", - "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], - "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." - }, - "input": { - "x-registry-field": "redacted_fixture_property", - "description": "Typed consultation inputs supplied to the adapter under test.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] - } - }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Named date values available to deterministic fixture interpolation.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "interactions": { - "x-registry-field": "redacted_fixture_property", - "description": "Ordered upstream request and response exchanges expected during execution.", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } - }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } - }, - "examples": [ - { - "name": "existing household matches", - "classification": "synthetic", - "input": { "household_id": "HH-1001" }, - "interactions": [ - { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } - } - ], - "expect": { "outcome": "match", "outputs": { "active": true } } - } - ], - "$defs": { - "governedRequest": { - "description": "The same closed governed request shape accepted by project test --live.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the same governed request boundary as a live Notary evaluation.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } - }, - "attributes": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } - } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } - } - }, - "request": { - "description": "Exact HTTP request shape the adapter must produce.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "query": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, - { - "x-registry-field": "branch", - "type": "array", - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } - } - ] - } - }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - "response": { - "description": "Synthetic HTTP response or timeout returned to the adapter.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["timeout"], - "properties": { - "timeout": { - "x-registry-field": "redacted_fixture_property", - "description": "Reviewed synthetic timeout intent no greater than 20 seconds; offline execution returns a timeout outcome without sleeping.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - ] - }, - "fixtureBody": { - "description": "Inline JSON, or an exact closed reference to a strict JSON file below the fixture bodies directory. The top-level file key is reserved for file references.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": { - "x-registry-field": "redacted_fixture_property", - "type": "string", - "minLength": 8, - "maxLength": 4096, - "pattern": "^bodies/(?!\\.\\.?/)(?!.*(?:/)\\.\\.?/)(?!.*//).+$" - } - } - }, - { - "x-registry-field": "branch", - "not": { - "x-registry-field": "branch", - "type": "object", - "required": ["file"] - } - } - ] - }, - "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } - } - } - } -} diff --git a/projects/nagdi-agriculture/.registry-stack-editor/schemas/integration.schema.json b/projects/nagdi-agriculture/.registry-stack-editor/schemas/integration.schema.json deleted file mode 100644 index 9bb2ad0..0000000 --- a/projects/nagdi-agriculture/.registry-stack-editor/schemas/integration.schema.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/integration.v1.json", - "title": "Registry Stack project integration v1", - "description": "Defines a product-neutral source adaptation contract using bounded HTTP, Rhai script, or exact snapshot capabilities.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "input", "capability", "outputs"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Integration authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the integration." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this integration contract.", "examples": [1] }, - "source": {"x-registry-field": "property", "$ref": "#/$defs/source", "description": "Optional source product metadata and transport policy; capabilities remain product-neutral." }, - "input": { - "x-registry-field": "property", - "description": "Typed selector and parameter inputs accepted by this integration.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/input" } - }, - "capability": {"x-registry-field": "property", "$ref": "#/$defs/capability", "description": "Exactly one bounded execution mechanism for the source adaptation." }, - "outputs": { - "x-registry-field": "property", - "description": "Named typed outputs, or a shorthand list of output names inferred by the authoring compiler.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": { - "x-registry-field": "map_key", - "allOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputName" }, - {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "enum": ["matched", "outcome"] } } - ] - }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/output" } - }, - { - "x-registry-field": "branch", - "type": "array", - "minItems": 1, - "maxItems": 64, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/inputName" } - } - ] - }, - "limits": {"x-registry-field": "property", "$ref": "#/$defs/limits", "description": "Optional tighter resource limits for one integration execution." }, - "not_applicable": {"x-registry-field": "property", "$ref": "#/$defs/notApplicable", "description": "Explicit rationale and request-fixture evidence for a normally required outcome that the source contract cannot produce." } - }, - "examples": [ - { - "version": 1, - "id": "household_lookup", - "revision": 1, - "input": { - "household_id": { "role": "selector", "type": "string" } - }, - "capability": { - "script": { "file": "adapter.rhai" } - }, - "outputs": { - "active": { "type": "boolean" } - } - } - ], - "$defs": { - "notApplicable": { - "description": "Closed declarations for outcomes that the source protocol, exact singleton endpoint, or local unique-key constraint proves impossible.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ambiguity": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed source contract cannot produce more than one matching record." }, - "subject_mismatch": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed response contract contains no identifier comparable with a requested selector." } - } - }, - "notApplicableReason": { - "description": "A reviewable reason that names a real request fixture proving the source access shape used by the rationale.", - "type": "object", - "additionalProperties": false, - "required": ["rationale", "request_fixture"], - "properties": { - "rationale": {"x-registry-field": "property", "type": "string", "minLength": 24, "maxLength": 512, "description": "Why the named normally required outcome cannot occur for this source contract." }, - "request_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Fixture name containing the supporting bounded source request." } - } - }, - "stableId": { - "description": "Lowercase stable identifier used in project references.", - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,95}$" - }, - "inputName": { - "description": "Portable lowercase name for an integration input or output.", - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "relativePath": { - "description": "Project-relative path without traversal or duplicate separators.", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" - }, - "integrationResponseByteSize": { - "description": "Maximum bytes accepted from one source response, defaulting to 512 KiB and capped at 8 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "512KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 8388608 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|[1-7][0-9]{3}|80[0-9]{2}|81[0-8][0-9]|819[0-2])KiB|[1-8]MiB)$" } - ] - }, - "integrationRequestByteSize": { - "description": "Maximum aggregate author-controlled request bytes for one execution, defaulting to 64 KiB and capped at 1 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "64KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1048576 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])KiB|1MiB)$" } - ] - }, - "integrationSourceByteSize": { - "description": "Maximum aggregate source-response bytes for one execution, defaulting to 2 MiB and capped at 16 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "2MiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 16777216 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-5][0-9]{3}|16[0-2][0-9]{2}|163[0-7][0-9]|1638[0-4])KiB|(?:[1-9]|1[0-6])MiB)$" } - ] - }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for nullable values.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "input": { - "description": "Type, validation, and canonicalization rules for one authored input.", - "type": "object", - "additionalProperties": false, - "required": ["role", "type"], - "properties": { - "role": {"x-registry-field": "property", "enum": ["selector", "parameter"] }, - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "description": "Maximum Unicode scalar-value length. The 1024-character ceiling derives a conservative 4096-byte UTF-8 ceiling for one canonical input.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 16384 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "canonicalization": {"x-registry-field": "property", "enum": ["identity", "ascii_lowercase"] } - } - }, - "versions": { - "description": "Source versions tested by the project and versions explicitly accepted as unverified.", - "type": "object", - "additionalProperties": false, - "properties": { - "tested": {"x-registry-field": "property", "$ref": "#/$defs/versionList" }, - "unverified": {"x-registry-field": "property", "$ref": "#/$defs/versionList" } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["tested"], "properties": { "tested": {"x-registry-field": "property", "minItems": 1 } } }, - {"x-registry-field": "branch", "required": ["unverified"], "properties": { "unverified": {"x-registry-field": "property", "minItems": 1 } } } - ] - }, - "versionList": { - "description": "Bounded list of source version labels.", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } - }, - "credential": { - "description": "Authentication mechanism expected by the source adapter, without environment secret values.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "enum": ["none", "basic", "static_bearer"] } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_header" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_query" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9._:~-]{0,95}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "request", "response_profile"], - "properties": { - "type": {"x-registry-field": "property", "const": "oauth2_client_credentials" }, - "request": {"x-registry-field": "property", "enum": ["form", "json"] }, - "response_profile": {"x-registry-field": "property", "const": "oauth2_bearer" }, - "scope": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 }, - "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "refresh_skew": { - "x-registry-field": "property", - "description": "Positive OAuth refresh interval below the generated token contract's 60-second minimum lifetime.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4})ms|(?:[1-9]|[1-5][0-9])s)$" - } - } - } - ] - }, - "allowRule": { - "description": "One read-only upstream method and path pattern the adapter may call.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": { - "x-registry-field": "sensitive_property", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^/[^?#]*$" - }, - "semantics": {"x-registry-field": "property", "const": "read_only" } - } - }, - "source": { - "description": "Optional source metadata, authentication shape, allowlist, and protocol profile.", - "type": "object", - "additionalProperties": false, - "required": ["auth"], - "properties": { - "product": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "versions": {"x-registry-field": "property", "$ref": "#/$defs/versions" }, - "auth": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "allow": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/allowRule" } - }, - "request_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "format": {"x-registry-field": "property", "enum": ["json", "text"] }, - "max_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationResponseByteSize" } - } - }, - "protocol": {"x-registry-field": "property", "$ref": "#/$defs/protocol" } - } - }, - "protocol": { - "description": "Optional interoperable protocol profiles layered over the source transport.", - "type": "object", - "additionalProperties": false, - "properties": { - "signed_dci": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["profile", "path", "jwks_profile", "sender", "receiver", "registry_type", "record_type", "locale", "selectors"], - "properties": { - "profile": {"x-registry-field": "property", "const": "dci-search-v1" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^/[^?#]*$" }, - "jwks_profile": {"x-registry-field": "property", "const": "rsa-signing-jwks-v1" }, - "sender": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "receiver": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "registry_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "record_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "locale": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" }, - "selectors": { - "x-registry-field": "property", - "type": "object", "minProperties": 1, "maxProperties": 8, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["field", "response_pointer"], - "properties": { - "field": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 160 }, - "response_pointer": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" } - } - } - } - } - } - }, - "minProperties": 1 - }, - "inputReference": { - "description": "Reference to a declared integration input.", - "type": "object", - "additionalProperties": false, - "required": ["input"], - "properties": { "input": {"x-registry-field": "property", "$ref": "#/$defs/inputName" } } - }, - "inputOrLiteral": { - "description": "Request value supplied from a declared input or an authored scalar literal.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputReference" }, - {"x-registry-field": "branch", "type": ["string", "boolean", "integer"] } - ] - }, - "httpRequest": { - "description": "Bounded read-only HTTP request template.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "semantics": {"x-registry-field": "property", "const": "read_only" }, - "query": {"x-registry-field": "property", "type": "object", "maxProperties": 64, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "headers": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "body": { "x-registry-field": "property" } - } - }, - "httpResponse": { - "description": "Rules for translating upstream status and body cardinality into normalized outcomes.", - "type": "object", - "additionalProperties": false, - "properties": { - "no_match": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "ambiguous": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "shape": { - "x-registry-field": "property", - "oneOf": [ - {"x-registry-field": "branch", "const": "singleton" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["records", "cardinality"], - "properties": { - "records": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" }, - "cardinality": {"x-registry-field": "property", "const": "probe_two" } - } - } - ] - } - } - }, - "capability": { - "description": "Closed choice among direct HTTP, Rhai script, and exact snapshot execution.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["http"], - "properties": { - "http": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["request"], - "properties": { - "request": {"x-registry-field": "property", "$ref": "#/$defs/httpRequest" }, - "response": {"x-registry-field": "property", "$ref": "#/$defs/httpResponse" } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "script": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, - "modules": { - "x-registry-field": "sensitive_property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/relativePath" } - } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["snapshot"], - "properties": { - "snapshot": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["entity", "exact", "freshness"], - "properties": { - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "exact": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 8, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputReference" } - }, - "freshness": { - "x-registry-field": "property", - "type": "string", - "description": "Maximum accepted snapshot age as a canonical positive duration no greater than 31 days.", - "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-5][0-9]{8}|26[0-6][0-9]{7}|267[0-7][0-9]{6}|2678[0-3][0-9]{5}|2678400000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-5][0-9]{5}|26[0-6][0-9]{4}|267[0-7][0-9]{3}|2678[0-3][0-9]{2}|2678400)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-3][0-9]{3}|44[0-5][0-9]{2}|446[0-3][0-9]{1}|44640)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-3][0-9]{1}|74[0-3]|744)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30|31)d)$" - } - } - } - } - } - ] - }, - "output": { - "description": "Type and optional source pointer for one normalized integration output.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "x-registry-source": { - "x-registry-field": "property", - "type": "string", - "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" - } - } - }, - "limits": { - "description": "Per-execution call, byte, and deadline budgets.", - "type": "object", - "additionalProperties": false, - "properties": { - "calls": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16 }, - "request_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationRequestByteSize" }, - "source_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationSourceByteSize" }, - "deadline": { - "x-registry-field": "property", - "description": "Positive total integration deadline no greater than 20 seconds.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - } -} diff --git a/projects/nagdi-agriculture/.registry-stack-editor/schemas/project.schema.json b/projects/nagdi-agriculture/.registry-stack-editor/schemas/project.schema.json deleted file mode 100644 index 91b7ea7..0000000 --- a/projects/nagdi-agriculture/.registry-stack-editor/schemas/project.schema.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", - "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", - "type": "object", - "additionalProperties": false, - "required": ["version", "registry", "services"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, - "starter": { - "x-registry-field": "property", - "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", - "type": "object", - "additionalProperties": false, - "required": ["id", "release", "content_digest"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } - } - }, - "registry": { - "x-registry-field": "property", - "description": "Stable identity of the Registry Stack project.", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } - }, - "integrations": { - "x-registry-field": "property", - "description": "Source adaptation definitions keyed by project-local integration identifier.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "entities": { - "x-registry-field": "property", - "description": "Materialized entity definitions keyed by project-local entity identifier.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "services": { - "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } - } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } - ], - "examples": [ - { - "version": 1, - "registry": { "id": "benefits-registry" }, - "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } - }, - "services": {} - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, - "recordsApi": { - "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", - "type": "object", - "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], - "properties": { - "scopes": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["metadata", "rows"], - "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } - } - }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "pagination": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["default_limit", "max_limit"], - "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } - } - }, - "filters": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } - }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "relationships": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - } - }, - "aggregates": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } - }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "standards": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], - "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } - } - } - } - }, - "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } - }, - "recordAttributeReleaseProfile": { - "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", - "type": "object", - "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], - "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "subject": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["source_field", "id_type"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } - } - }, - "release_conditions": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } - }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["required", "sensitivity"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } - }, - "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } - ] - } - } - } - }, - "recordAttributeReleaseExpression": { - "description": "Bounded CEL evaluated only against the projected source object.", - "type": "object", - "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } - }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "recordAggregate": { - "description": "Governed aggregate definition with disclosure controls.", - "type": "object", - "additionalProperties": false, - "required": ["description", "disclosure_control"], - "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, - "disclosure_control": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } - } - } - }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] - }, - "recordAggregateAccess": { - "description": "Authorization scopes and execution policy for one governed aggregate.", - "type": "object", - "additionalProperties": false, - "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } - } - }, - "recordAggregateSpatial": { - "description": "Administrative-area geometry join used to spatially group aggregate results.", - "type": "object", - "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], - "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatial": { - "description": "OGC API Features collection metadata and bounded geometry mapping.", - "type": "object", - "additionalProperties": false, - "required": ["geometry"], - "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatialGeometry": { - "description": "Supported point or encoded-geometry field mapping for spatial records.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - } - ] - }, - "recordSpatialBbox": { - "description": "Record fields carrying the four coordinates of a bounding box.", - "type": "object", - "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], - "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordSpdci": { - "description": "SP-DCI registry identity and field mappings for a records service.", - "type": "object", - "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], - "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } - } - }, - "recordFieldMap": { - "description": "Bounded mapping from standard field names to entity field identifiers.", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } - }, - "recordAggregateDimension": { - "description": "Named grouping dimension backed by one entity field.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "field"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateIndicator": { - "description": "SDMX-style aggregate indicator with units and display metadata.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateMeasure": { - "description": "Named aggregate calculation over one entity column.", - "type": "object", - "additionalProperties": false, - "required": ["name", "function", "column"], - "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordAggregateFunction": { - "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] - }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } - } - ] - }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, - "recordsService": { - "description": "Relay records API backed by one authored materialized entity.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "entity", "api"], - "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Source-free claims remain evaluation-only and cannot be selected by a credential profile.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { "output": {"x-registry-field": "property", "type": "string" }, "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } - }, - "credential_profiles": { - "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation. Source-free claim evaluation cannot be exposed as credential capability.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } - } - } - } - }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, - "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } - }, - "consultations": { - "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/targetRequestMapping" } } } } - }, - "targetRequestMapping": { - "description": "Closed target request binding. Attribute values are bounded typed caller-supplied context, not authenticated identifiers.", - "type": "string", - "maxLength": 128, - "pattern": "^request\\.target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})$" - }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for source-free evaluation. A source-free claim cannot be selected by a credential profile.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } - } - } -} diff --git a/projects/nagdi-agriculture/.vscode/extensions.json b/projects/nagdi-agriculture/.vscode/extensions.json deleted file mode 100644 index 6098b6e..0000000 --- a/projects/nagdi-agriculture/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "redhat.vscode-yaml" - ] -} diff --git a/projects/nagdi-agriculture/.vscode/settings.json b/projects/nagdi-agriculture/.vscode/settings.json deleted file mode 100644 index db92a93..0000000 --- a/projects/nagdi-agriculture/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "yaml.schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } -} diff --git a/projects/nagdi-agriculture/.zed/settings.json b/projects/nagdi-agriculture/.zed/settings.json deleted file mode 100644 index fac07ac..0000000 --- a/projects/nagdi-agriculture/.zed/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } - } - } - } - } -} diff --git a/projects/nagdi-agriculture/entities/nagdi_farmer.yaml b/projects/nagdi-agriculture/entities/nagdi_farmer.yaml deleted file mode 100644 index e978185..0000000 --- a/projects/nagdi-agriculture/entities/nagdi_farmer.yaml +++ /dev/null @@ -1,36 +0,0 @@ -version: 1 -id: nagdi_farmer -revision: 1 -primary_key: farmer_id -schema: - type: object - additionalProperties: false - required: - - farmer_id - - farmer_registered - - data_use_authorized - - active_smallholder_farmer - - active_farm_parcel - - crop_declared_for_season - - district_climate_risk_active - - voucher_entitlement_current - - voucher_not_redeemed - properties: - farmer_id: - type: string - minLength: 7 - maxLength: 16 - pattern: "^FR-[0-9]+$" - farmer_registered: { type: boolean } - data_use_authorized: { type: boolean } - active_smallholder_farmer: { type: boolean } - active_farm_parcel: { type: boolean } - crop_declared_for_season: { type: boolean } - district_climate_risk_active: { type: boolean } - voucher_entitlement_current: { type: boolean } - voucher_not_redeemed: { type: boolean } -materialization: - max_records: 1000000 - max_bytes: 128MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/nagdi-agriculture/entities/nagdi_herd.yaml b/projects/nagdi-agriculture/entities/nagdi_herd.yaml deleted file mode 100644 index 282ba81..0000000 --- a/projects/nagdi-agriculture/entities/nagdi_herd.yaml +++ /dev/null @@ -1,36 +0,0 @@ -version: 1 -id: nagdi_herd -revision: 1 -primary_key: herd_id -schema: - type: object - additionalProperties: false - required: - - herd_id - - farmer_id - - registered_herd - - herd_vaccination_current - - origin_district_not_quarantined_for_species - - destination_district_open - - no_conflicting_open_movement_permit - properties: - herd_id: - type: string - minLength: 11 - maxLength: 11 - pattern: "^HERD-[0-9]{6}$" - farmer_id: - type: string - minLength: 7 - maxLength: 16 - pattern: "^FR-[0-9]+$" - registered_herd: { type: boolean } - herd_vaccination_current: { type: boolean } - origin_district_not_quarantined_for_species: { type: boolean } - destination_district_open: { type: boolean } - no_conflicting_open_movement_permit: { type: boolean } -materialization: - max_records: 1000000 - max_bytes: 128MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/nagdi-agriculture/environments/hosted.yaml b/projects/nagdi-agriculture/environments/hosted.yaml deleted file mode 100644 index e7a65f1..0000000 --- a/projects/nagdi-agriculture/environments/hosted.yaml +++ /dev/null @@ -1,70 +0,0 @@ -version: 1 - -entities: - nagdi_farmer: - provider: - type: csv - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - header_row: 1 - columns: - farmer_id: farmer_id - farmer_registered: farmer_registered - data_use_authorized: data_use_authorized - active_smallholder_farmer: active_smallholder_farmer - active_farm_parcel: active_farm_parcel - crop_declared_for_season: crop_declared_for_season - district_climate_risk_active: district_climate_risk_active - voucher_entitlement_current: voucher_entitlement_current - voucher_not_redeemed: voucher_not_redeemed - source_revision: nagdi-farmer-voucher-v1 - generation: "2026-07-14" - nagdi_herd: - provider: - type: csv - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - header_row: 1 - columns: - herd_id: herd_id - farmer_id: farmer_id - registered_herd: registered_herd - herd_vaccination_current: herd_vaccination_current - origin_district_not_quarantined_for_species: origin_district_not_quarantined_for_species - destination_district_open: destination_district_open - no_conflicting_open_movement_permit: no_conflicting_open_movement_permit - source_revision: nagdi-livestock-movement-v1 - generation: "2026-07-14" - -issuance: - issuer: did:web:id.registrystack.org:solmara:authority:nagdi - signing_kid: did:web:id.registrystack.org:solmara:authority:nagdi#issuer-key-1 - signing_key: { secret: NAGDI_NOTARY_ISSUER_JWK } - generation: 1 - -callers: - nagdi-service: - api_key_fingerprint: { secret: NAGDI_CLIENT_TOKEN_HASH } - scopes: ["nagdi_agriculture:evidence_verification"] - -relay: - origin: https://nagdi-relay.solmara.registrystack.org - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json - audience: registry-relay - allowed_clients: [nagdi-notary] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: nagdi-notary - token_file: /run/secrets/nagdi-notary-relay-token - -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -deployment: - profile: hosted_lab - relay: { service: nagdi-agriculture-relay } - notary: { service: nagdi-notary } diff --git a/projects/nagdi-agriculture/environments/local.yaml b/projects/nagdi-agriculture/environments/local.yaml deleted file mode 100644 index 9ae1c1d..0000000 --- a/projects/nagdi-agriculture/environments/local.yaml +++ /dev/null @@ -1,76 +0,0 @@ -version: 1 - -entities: - nagdi_farmer: - provider: - type: csv - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - header_row: 1 - columns: - farmer_id: farmer_id - farmer_registered: farmer_registered - data_use_authorized: data_use_authorized - active_smallholder_farmer: active_smallholder_farmer - active_farm_parcel: active_farm_parcel - crop_declared_for_season: crop_declared_for_season - district_climate_risk_active: district_climate_risk_active - voucher_entitlement_current: voucher_entitlement_current - voucher_not_redeemed: voucher_not_redeemed - source_revision: nagdi-farmer-voucher-v1 - generation: "2026-07-14" - nagdi_herd: - provider: - type: csv - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - header_row: 1 - columns: - herd_id: herd_id - farmer_id: farmer_id - registered_herd: registered_herd - herd_vaccination_current: herd_vaccination_current - origin_district_not_quarantined_for_species: origin_district_not_quarantined_for_species - destination_district_open: destination_district_open - no_conflicting_open_movement_permit: no_conflicting_open_movement_permit - source_revision: nagdi-livestock-movement-v1 - generation: "2026-07-14" - -issuance: - issuer: did:web:id.registrystack.org:solmara:authority:nagdi - signing_kid: did:web:id.registrystack.org:solmara:authority:nagdi#issuer-key-1 - signing_key: { secret: NAGDI_NOTARY_ISSUER_JWK } - generation: 1 - -callers: - nagdi-service: - api_key_fingerprint: { secret: NAGDI_CLIENT_TOKEN_HASH } - scopes: ["nagdi_agriculture:evidence_verification"] - -relay: - origin: http://127.0.0.1:8080 - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json - audience: registry-relay - allowed_clients: [nagdi-notary] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: nagdi-notary - token_file: /run/secrets/nagdi-notary-relay-token - -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -# Emulated local images require Registry Stack's bounded 1 GiB CEL worker ceiling. -# Hosted deployments intentionally retain the 128 MiB default. -notary_cel: - worker_memory_bytes: 1073741824 - -deployment: - profile: local - relay: { service: nagdi-agriculture-relay } - notary: { service: nagdi-notary } diff --git a/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/fixtures/match.yaml b/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/fixtures/match.yaml deleted file mode 100644 index 4e1f809..0000000 --- a/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/fixtures/match.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: farmer-voucher-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: farmer_id, value: FR-1001 }] - claims: - - farmer-registered - - data-use-authorized-for-purpose - - eligible-for-climate-smart-input-voucher - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review -input: { farmer_id: FR-1001 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - farmer_registered: true - data_use_authorized: true - active_smallholder_farmer: true - active_farm_parcel: true - crop_declared_for_season: true - district_climate_risk_active: true - voucher_entitlement_current: true - voucher_not_redeemed: true -expect: - outcome: match - outputs: - farmer_registered: true - data_use_authorized: true - active_smallholder_farmer: true - active_farm_parcel: true - crop_declared_for_season: true - district_climate_risk_active: true - voucher_entitlement_current: true - voucher_not_redeemed: true - claims: - farmer-registered: true - data-use-authorized-for-purpose: true - eligible-for-climate-smart-input-voucher: true diff --git a/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/fixtures/no-match.yaml b/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/fixtures/no-match.yaml deleted file mode 100644 index 9ba7e3b..0000000 --- a/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/fixtures/no-match.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: farmer-voucher-no-match -classification: synthetic -input: { farmer_id: FR-9999 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: - farmer-registered: false - data-use-authorized-for-purpose: false - eligible-for-climate-smart-input-voucher: false diff --git a/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/integration.yaml b/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/integration.yaml deleted file mode 100644 index 60253df..0000000 --- a/projects/nagdi-agriculture/integrations/farmer-voucher-by-farmer/integration.yaml +++ /dev/null @@ -1,36 +0,0 @@ -version: 1 -id: farmer-voucher-by-farmer -revision: 1 - -input: - farmer_id: - role: selector - type: string - minLength: 7 - maxLength: 16 - pattern: "^FR-[0-9]+$" - -capability: - snapshot: - entity: nagdi_farmer - exact: - farmer_id: { input: farmer_id } - freshness: 7d - -outputs: - - farmer_registered - - data_use_authorized - - active_smallholder_farmer - - active_farm_parcel - - crop_declared_for_season - - district_climate_risk_active - - voucher_entitlement_current - - voucher_not_redeemed - -not_applicable: - ambiguity: - rationale: The exact farmer identifier is the entity primary key, whose materialized unique-key constraint permits at most one voucher record. - request_fixture: farmer-voucher-match - subject_mismatch: - rationale: The minimized output projection omits farmer_id, so it contains no identifier comparable with the requested farmer identifier. - request_fixture: farmer-voucher-match diff --git a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/ambiguous.yaml b/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/ambiguous.yaml deleted file mode 100644 index bdf033c..0000000 --- a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/ambiguous.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: livestock-movement-ambiguous -classification: synthetic -input: { farmer_id: FR-8888 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - - registered_herd: true - herd_vaccination_current: true - origin_district_not_quarantined_for_species: true - destination_district_open: true - no_conflicting_open_movement_permit: true - - registered_herd: false - herd_vaccination_current: false - origin_district_not_quarantined_for_species: false - destination_district_open: false - no_conflicting_open_movement_permit: false -expect: { outcome: ambiguous, outputs: {}, claims: {} } diff --git a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/match.yaml b/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/match.yaml deleted file mode 100644 index 18c87e1..0000000 --- a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/match.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: livestock-movement-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: farmer_id, value: FR-1001 }] - claims: - - registered-herd - - origin-district-not-quarantined-for-species - - eligible-for-livestock-movement-permit - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control -input: { farmer_id: FR-1001 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - registered_herd: true - herd_vaccination_current: true - origin_district_not_quarantined_for_species: true - destination_district_open: true - no_conflicting_open_movement_permit: true -expect: - outcome: match - outputs: - registered_herd: true - herd_vaccination_current: true - origin_district_not_quarantined_for_species: true - destination_district_open: true - no_conflicting_open_movement_permit: true - claims: - registered-herd: true - origin-district-not-quarantined-for-species: true - eligible-for-livestock-movement-permit: true diff --git a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/no-match.yaml b/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/no-match.yaml deleted file mode 100644 index 10d7312..0000000 --- a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/fixtures/no-match.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: livestock-movement-no-match -classification: synthetic -input: { farmer_id: FR-9999 } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: - registered-herd: false - origin-district-not-quarantined-for-species: false - eligible-for-livestock-movement-permit: false diff --git a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/integration.yaml b/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/integration.yaml deleted file mode 100644 index 023a76b..0000000 --- a/projects/nagdi-agriculture/integrations/livestock-movement-by-farmer/integration.yaml +++ /dev/null @@ -1,30 +0,0 @@ -version: 1 -id: livestock-movement-by-farmer -revision: 1 - -input: - farmer_id: - role: selector - type: string - minLength: 7 - maxLength: 16 - pattern: "^FR-[0-9]+$" - -capability: - snapshot: - entity: nagdi_herd - exact: - farmer_id: { input: farmer_id } - freshness: 7d - -outputs: - - registered_herd - - herd_vaccination_current - - origin_district_not_quarantined_for_species - - destination_district_open - - no_conflicting_open_movement_permit - -not_applicable: - subject_mismatch: - rationale: The minimized output projection omits farmer_id, so it contains no identifier comparable with the requested farmer identifier. - request_fixture: livestock-movement-match diff --git a/projects/nagdi-agriculture/registry-stack.yaml b/projects/nagdi-agriculture/registry-stack.yaml deleted file mode 100644 index 0333d7b..0000000 --- a/projects/nagdi-agriculture/registry-stack.yaml +++ /dev/null @@ -1,108 +0,0 @@ -version: 1 - -registry: - id: solmara-nagdi-agriculture - -integrations: - farmer-voucher-by-farmer: - file: integrations/farmer-voucher-by-farmer/integration.yaml - livestock-movement-by-farmer: - file: integrations/livestock-movement-by-farmer/integration.yaml - -entities: - nagdi_farmer: - file: entities/nagdi_farmer.yaml - nagdi_herd: - file: entities/nagdi_herd.yaml - -services: - voucher: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - legal_basis: agricultural-data-act - consent: not_required - access: - scopes: ["nagdi_agriculture:evidence_verification"] - consultations: - farmer: - integration: farmer-voucher-by-farmer - input: - farmer_id: request.target.identifiers.farmer_id - claims: - farmer-registered: - cel: farmer.matched && farmer.farmer_registered - disclosure: - default: predicate - allowed: [predicate, redacted] - data-use-authorized-for-purpose: - cel: farmer.matched && farmer.data_use_authorized - disclosure: - default: predicate - allowed: [predicate, redacted] - eligible-for-climate-smart-input-voucher: - cel: >- - farmer.matched && farmer.farmer_registered - && farmer.data_use_authorized - && farmer.active_smallholder_farmer - && farmer.active_farm_parcel - && farmer.crop_declared_for_season - && farmer.district_climate_risk_active - && farmer.voucher_entitlement_current - && farmer.voucher_not_redeemed - disclosure: - default: predicate - allowed: [predicate, redacted] - credential_profiles: - climate_smart_voucher_sd_jwt: - format: dc+sd-jwt - type: https://id.registrystack.org/solmara/vct/climate-smart-voucher-eligibility - validity: 10m - claims: - - farmer-registered - - data-use-authorized-for-purpose - - eligible-for-climate-smart-input-voucher - - livestock: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - legal_basis: agricultural-data-act - consent: not_required - access: - scopes: ["nagdi_agriculture:evidence_verification"] - consultations: - herd: - integration: livestock-movement-by-farmer - input: - farmer_id: request.target.identifiers.farmer_id - claims: - registered-herd: - cel: herd.matched && herd.registered_herd - disclosure: - default: predicate - allowed: [predicate, redacted] - origin-district-not-quarantined-for-species: - cel: herd.matched && herd.origin_district_not_quarantined_for_species - disclosure: - default: predicate - allowed: [predicate, redacted] - eligible-for-livestock-movement-permit: - cel: >- - herd.matched && herd.registered_herd - && herd.origin_district_not_quarantined_for_species - && herd.herd_vaccination_current - && herd.destination_district_open - && herd.no_conflicting_open_movement_permit - disclosure: - default: predicate - allowed: [predicate, redacted] - credential_profiles: - livestock_movement_sd_jwt: - format: dc+sd-jwt - type: https://id.registrystack.org/solmara/vct/livestock-movement-permit - validity: 10m - claims: - - registered-herd - - origin-district-not-quarantined-for-species - - eligible-for-livestock-movement-permit diff --git a/projects/nia-population/.registry-stack-editor/manifest.json b/projects/nia-population/.registry-stack-editor/manifest.json deleted file mode 100644 index 3603a8e..0000000 --- a/projects/nia-population/.registry-stack-editor/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "format": "registry.stack.editor-manifest", - "version": 1, - "registryctl_version": "0.15.2", - "schemas": [ - { - "kind": "project", - "path": "schemas/project.schema.json", - "file_glob": "registry-stack.yaml", - "sha256": "sha256:9fd76663c866c52bb5a17a4cfea821049b7428c812f1666da9db0d43a00da144" - }, - { - "kind": "environment", - "path": "schemas/environment.schema.json", - "file_glob": "environments/*.yaml", - "sha256": "sha256:d16828629e001a30a1705af90bcc12a214be18cf5805a9833e65b03d631c9e9b" - }, - { - "kind": "integration", - "path": "schemas/integration.schema.json", - "file_glob": "integrations/*/integration.yaml", - "sha256": "sha256:2e45452f63c3c610ed14b79244bc9aa086d79d811ad0899223b36c160d449a79" - }, - { - "kind": "fixture", - "path": "schemas/fixture.schema.json", - "file_glob": "integrations/*/fixtures/*.yaml", - "sha256": "sha256:98c0ab21e35cecd143b51e4b5dd487de07ceea24e505bbe351d212e0628c8c8c" - }, - { - "kind": "entity", - "path": "schemas/entity.schema.json", - "file_glob": "entities/*.yaml", - "sha256": "sha256:4cbb31c8addeb54f8b57aaaff8f30c39e651a21a2d741a1b775ecb0d7c248342" - } - ] -} diff --git a/projects/nia-population/.registry-stack-editor/schemas/entity.schema.json b/projects/nia-population/.registry-stack-editor/schemas/entity.schema.json deleted file mode 100644 index ffc57c8..0000000 --- a/projects/nia-population/.registry-stack-editor/schemas/entity.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/entity.v1.json", - "title": "Registry Stack project entity v1", - "description": "Defines a bounded, materialized entity that Relay can query without coupling the project to a specific source product.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "primary_key", "schema", "materialization"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Entity authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the entity." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this entity definition.", "examples": [1] }, - "primary_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Field whose value uniquely identifies each materialized record.", "examples": ["person_id"] }, - "schema": {"x-registry-field": "property", "$ref": "#/$defs/objectSchema", "description": "Closed JSON object schema for materialized records." }, - "materialization": { - "x-registry-field": "property", - "description": "Authored resource, refresh, and bounded recovery-set retention applied while building entity generations.", - "type": "object", - "additionalProperties": false, - "required": ["max_records", "max_bytes", "refresh", "retain_generations"], - "properties": { - "max_records": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 100000000, "description": "Maximum number of records allowed in one generation.", "examples": [10000] }, - "max_bytes": { - "x-registry-field": "property", - "description": "Maximum encoded size of one generation, up to 1 GiB, as bytes or a KiB/MiB quantity.", - "examples": ["16MiB"], - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1073741824 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,5}|10[0-3][0-9]{4}|104[0-7][0-9]{3}|1048[0-4][0-9]{2}|10485[0-6][0-9]|104857[0-6])KiB|(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])MiB)$" } - ] - }, - "refresh": { - "x-registry-field": "property", - "description": "Refresh cadence as a canonical positive duration no greater than 30 days, or manual when an operator initiates every refresh.", - "examples": ["1h"], - "oneOf": [ - {"x-registry-field": "branch", "const": "manual" }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-4][0-9]{8}|25[0-8][0-9]{7}|259[0-1][0-9]{6}|2592000000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-4][0-9]{5}|25[0-8][0-9]{4}|259[0-1][0-9]{3}|2592000)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-2][0-9]{3}|43[0-1][0-9]{2}|43200)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-1][0-9]{1}|720)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30)d)$" } - ] - }, - "retain_generations": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16, "description": "Number of completed cache generations, including the active generation, retained as a bounded recovery set after successful publication. This does not make arbitrary retained generations selectable for rollback.", "examples": [2] } - } - } - }, - "examples": [ - { - "version": 1, - "id": "people", - "revision": 1, - "primary_key": "person_id", - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["person_id"], - "properties": { "person_id": { "type": "string" } } - }, - "materialization": { - "max_records": 10000, - "max_bytes": "16MiB", - "refresh": "1h", - "retain_generations": 2 - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "propertyName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$", "description": "Portable lowercase record field name." }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for a nullable field.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "items": false, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "fieldSchema": { - "description": "Bounded schema keywords supported for one scalar materialized field.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "enum": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true }, - "const": {"x-registry-field": "property"}, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 65536 }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 } - } - }, - "objectSchema": { - "description": "Closed object schema whose declared required fields must appear in properties.", - "type": "object", - "additionalProperties": false, - "required": ["type", "additionalProperties", "required", "properties"], - "properties": { - "type": {"x-registry-field": "property", "const": "object" }, - "additionalProperties": {"x-registry-field": "property", "const": false }, - "required": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 256, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/propertyName" } - }, - "properties": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 256, - "propertyNames": {"x-registry-field": "property", "$ref": "#/$defs/propertyName" }, - "additionalProperties": {"x-registry-field": "property", "$ref": "#/$defs/fieldSchema" } - } - } - } - } -} diff --git a/projects/nia-population/.registry-stack-editor/schemas/environment.schema.json b/projects/nia-population/.registry-stack-editor/schemas/environment.schema.json deleted file mode 100644 index f04c70b..0000000 --- a/projects/nia-population/.registry-stack-editor/schemas/environment.schema.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", - "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", - "type": "object", - "additionalProperties": false, - "required": ["version", "deployment"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { - "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "callers": { - "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } - } - }, - "relay": { - "x-registry-field": "property", - "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_cel": { - "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], - "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } - } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] - } - }, - "allOf": [ - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, - "then": { - "x-registry-field": "branch", - "required": ["notary_state"], - "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } - } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { - "x-registry-field": "branch", - "properties": { - "relay": { - "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - }, - "oid4vci": { - "x-registry-field": "property", - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - } - } - } - } - } - } - ], - "examples": [ - { - "version": 1, - "deployment": { - "profile": "local", - "notary": { "service": "notary" } - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", - "type": "object", - "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } - }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } - } - } - }, - "privateCidrs": { - "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } - }, - "ca": { - "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "mtls": { - "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "endpoint": { - "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } - } - }, - "credential": { - "description": "One supported source credential shape, containing references rather than values.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } - ] - }, - "provider": { - "description": "Environment-specific physical provider for a materialized entity.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } - ] - }, - "entity": { - "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } - }, - "source": { - "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, - "timeout": { - "x-registry-field": "property", - "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - }, - "integration": { - "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } - }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } - } -} diff --git a/projects/nia-population/.registry-stack-editor/schemas/fixture.schema.json b/projects/nia-population/.registry-stack-editor/schemas/fixture.schema.json deleted file mode 100644 index b853192..0000000 --- a/projects/nia-population/.registry-stack-editor/schemas/fixture.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/fixture.v1.json", - "title": "Registry Stack project integration fixture v1", - "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", - "type": "object", - "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], - "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." - }, - "input": { - "x-registry-field": "redacted_fixture_property", - "description": "Typed consultation inputs supplied to the adapter under test.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] - } - }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Named date values available to deterministic fixture interpolation.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "interactions": { - "x-registry-field": "redacted_fixture_property", - "description": "Ordered upstream request and response exchanges expected during execution.", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } - }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } - }, - "examples": [ - { - "name": "existing household matches", - "classification": "synthetic", - "input": { "household_id": "HH-1001" }, - "interactions": [ - { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } - } - ], - "expect": { "outcome": "match", "outputs": { "active": true } } - } - ], - "$defs": { - "governedRequest": { - "description": "The same closed governed request shape accepted by project test --live.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the same governed request boundary as a live Notary evaluation.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } - }, - "attributes": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } - } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } - } - }, - "request": { - "description": "Exact HTTP request shape the adapter must produce.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "query": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, - { - "x-registry-field": "branch", - "type": "array", - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } - } - ] - } - }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - "response": { - "description": "Synthetic HTTP response or timeout returned to the adapter.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["timeout"], - "properties": { - "timeout": { - "x-registry-field": "redacted_fixture_property", - "description": "Reviewed synthetic timeout intent no greater than 20 seconds; offline execution returns a timeout outcome without sleeping.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - ] - }, - "fixtureBody": { - "description": "Inline JSON, or an exact closed reference to a strict JSON file below the fixture bodies directory. The top-level file key is reserved for file references.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": { - "x-registry-field": "redacted_fixture_property", - "type": "string", - "minLength": 8, - "maxLength": 4096, - "pattern": "^bodies/(?!\\.\\.?/)(?!.*(?:/)\\.\\.?/)(?!.*//).+$" - } - } - }, - { - "x-registry-field": "branch", - "not": { - "x-registry-field": "branch", - "type": "object", - "required": ["file"] - } - } - ] - }, - "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } - } - } - } -} diff --git a/projects/nia-population/.registry-stack-editor/schemas/integration.schema.json b/projects/nia-population/.registry-stack-editor/schemas/integration.schema.json deleted file mode 100644 index 9bb2ad0..0000000 --- a/projects/nia-population/.registry-stack-editor/schemas/integration.schema.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/integration.v1.json", - "title": "Registry Stack project integration v1", - "description": "Defines a product-neutral source adaptation contract using bounded HTTP, Rhai script, or exact snapshot capabilities.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "input", "capability", "outputs"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Integration authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the integration." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this integration contract.", "examples": [1] }, - "source": {"x-registry-field": "property", "$ref": "#/$defs/source", "description": "Optional source product metadata and transport policy; capabilities remain product-neutral." }, - "input": { - "x-registry-field": "property", - "description": "Typed selector and parameter inputs accepted by this integration.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/input" } - }, - "capability": {"x-registry-field": "property", "$ref": "#/$defs/capability", "description": "Exactly one bounded execution mechanism for the source adaptation." }, - "outputs": { - "x-registry-field": "property", - "description": "Named typed outputs, or a shorthand list of output names inferred by the authoring compiler.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": { - "x-registry-field": "map_key", - "allOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputName" }, - {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "enum": ["matched", "outcome"] } } - ] - }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/output" } - }, - { - "x-registry-field": "branch", - "type": "array", - "minItems": 1, - "maxItems": 64, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/inputName" } - } - ] - }, - "limits": {"x-registry-field": "property", "$ref": "#/$defs/limits", "description": "Optional tighter resource limits for one integration execution." }, - "not_applicable": {"x-registry-field": "property", "$ref": "#/$defs/notApplicable", "description": "Explicit rationale and request-fixture evidence for a normally required outcome that the source contract cannot produce." } - }, - "examples": [ - { - "version": 1, - "id": "household_lookup", - "revision": 1, - "input": { - "household_id": { "role": "selector", "type": "string" } - }, - "capability": { - "script": { "file": "adapter.rhai" } - }, - "outputs": { - "active": { "type": "boolean" } - } - } - ], - "$defs": { - "notApplicable": { - "description": "Closed declarations for outcomes that the source protocol, exact singleton endpoint, or local unique-key constraint proves impossible.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ambiguity": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed source contract cannot produce more than one matching record." }, - "subject_mismatch": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed response contract contains no identifier comparable with a requested selector." } - } - }, - "notApplicableReason": { - "description": "A reviewable reason that names a real request fixture proving the source access shape used by the rationale.", - "type": "object", - "additionalProperties": false, - "required": ["rationale", "request_fixture"], - "properties": { - "rationale": {"x-registry-field": "property", "type": "string", "minLength": 24, "maxLength": 512, "description": "Why the named normally required outcome cannot occur for this source contract." }, - "request_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Fixture name containing the supporting bounded source request." } - } - }, - "stableId": { - "description": "Lowercase stable identifier used in project references.", - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,95}$" - }, - "inputName": { - "description": "Portable lowercase name for an integration input or output.", - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "relativePath": { - "description": "Project-relative path without traversal or duplicate separators.", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" - }, - "integrationResponseByteSize": { - "description": "Maximum bytes accepted from one source response, defaulting to 512 KiB and capped at 8 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "512KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 8388608 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|[1-7][0-9]{3}|80[0-9]{2}|81[0-8][0-9]|819[0-2])KiB|[1-8]MiB)$" } - ] - }, - "integrationRequestByteSize": { - "description": "Maximum aggregate author-controlled request bytes for one execution, defaulting to 64 KiB and capped at 1 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "64KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1048576 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])KiB|1MiB)$" } - ] - }, - "integrationSourceByteSize": { - "description": "Maximum aggregate source-response bytes for one execution, defaulting to 2 MiB and capped at 16 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "2MiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 16777216 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-5][0-9]{3}|16[0-2][0-9]{2}|163[0-7][0-9]|1638[0-4])KiB|(?:[1-9]|1[0-6])MiB)$" } - ] - }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for nullable values.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "input": { - "description": "Type, validation, and canonicalization rules for one authored input.", - "type": "object", - "additionalProperties": false, - "required": ["role", "type"], - "properties": { - "role": {"x-registry-field": "property", "enum": ["selector", "parameter"] }, - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "description": "Maximum Unicode scalar-value length. The 1024-character ceiling derives a conservative 4096-byte UTF-8 ceiling for one canonical input.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 16384 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "canonicalization": {"x-registry-field": "property", "enum": ["identity", "ascii_lowercase"] } - } - }, - "versions": { - "description": "Source versions tested by the project and versions explicitly accepted as unverified.", - "type": "object", - "additionalProperties": false, - "properties": { - "tested": {"x-registry-field": "property", "$ref": "#/$defs/versionList" }, - "unverified": {"x-registry-field": "property", "$ref": "#/$defs/versionList" } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["tested"], "properties": { "tested": {"x-registry-field": "property", "minItems": 1 } } }, - {"x-registry-field": "branch", "required": ["unverified"], "properties": { "unverified": {"x-registry-field": "property", "minItems": 1 } } } - ] - }, - "versionList": { - "description": "Bounded list of source version labels.", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } - }, - "credential": { - "description": "Authentication mechanism expected by the source adapter, without environment secret values.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "enum": ["none", "basic", "static_bearer"] } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_header" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_query" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9._:~-]{0,95}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "request", "response_profile"], - "properties": { - "type": {"x-registry-field": "property", "const": "oauth2_client_credentials" }, - "request": {"x-registry-field": "property", "enum": ["form", "json"] }, - "response_profile": {"x-registry-field": "property", "const": "oauth2_bearer" }, - "scope": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 }, - "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "refresh_skew": { - "x-registry-field": "property", - "description": "Positive OAuth refresh interval below the generated token contract's 60-second minimum lifetime.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4})ms|(?:[1-9]|[1-5][0-9])s)$" - } - } - } - ] - }, - "allowRule": { - "description": "One read-only upstream method and path pattern the adapter may call.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": { - "x-registry-field": "sensitive_property", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^/[^?#]*$" - }, - "semantics": {"x-registry-field": "property", "const": "read_only" } - } - }, - "source": { - "description": "Optional source metadata, authentication shape, allowlist, and protocol profile.", - "type": "object", - "additionalProperties": false, - "required": ["auth"], - "properties": { - "product": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "versions": {"x-registry-field": "property", "$ref": "#/$defs/versions" }, - "auth": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "allow": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/allowRule" } - }, - "request_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "format": {"x-registry-field": "property", "enum": ["json", "text"] }, - "max_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationResponseByteSize" } - } - }, - "protocol": {"x-registry-field": "property", "$ref": "#/$defs/protocol" } - } - }, - "protocol": { - "description": "Optional interoperable protocol profiles layered over the source transport.", - "type": "object", - "additionalProperties": false, - "properties": { - "signed_dci": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["profile", "path", "jwks_profile", "sender", "receiver", "registry_type", "record_type", "locale", "selectors"], - "properties": { - "profile": {"x-registry-field": "property", "const": "dci-search-v1" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^/[^?#]*$" }, - "jwks_profile": {"x-registry-field": "property", "const": "rsa-signing-jwks-v1" }, - "sender": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "receiver": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "registry_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "record_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "locale": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" }, - "selectors": { - "x-registry-field": "property", - "type": "object", "minProperties": 1, "maxProperties": 8, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["field", "response_pointer"], - "properties": { - "field": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 160 }, - "response_pointer": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" } - } - } - } - } - } - }, - "minProperties": 1 - }, - "inputReference": { - "description": "Reference to a declared integration input.", - "type": "object", - "additionalProperties": false, - "required": ["input"], - "properties": { "input": {"x-registry-field": "property", "$ref": "#/$defs/inputName" } } - }, - "inputOrLiteral": { - "description": "Request value supplied from a declared input or an authored scalar literal.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputReference" }, - {"x-registry-field": "branch", "type": ["string", "boolean", "integer"] } - ] - }, - "httpRequest": { - "description": "Bounded read-only HTTP request template.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "semantics": {"x-registry-field": "property", "const": "read_only" }, - "query": {"x-registry-field": "property", "type": "object", "maxProperties": 64, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "headers": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "body": { "x-registry-field": "property" } - } - }, - "httpResponse": { - "description": "Rules for translating upstream status and body cardinality into normalized outcomes.", - "type": "object", - "additionalProperties": false, - "properties": { - "no_match": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "ambiguous": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "shape": { - "x-registry-field": "property", - "oneOf": [ - {"x-registry-field": "branch", "const": "singleton" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["records", "cardinality"], - "properties": { - "records": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" }, - "cardinality": {"x-registry-field": "property", "const": "probe_two" } - } - } - ] - } - } - }, - "capability": { - "description": "Closed choice among direct HTTP, Rhai script, and exact snapshot execution.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["http"], - "properties": { - "http": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["request"], - "properties": { - "request": {"x-registry-field": "property", "$ref": "#/$defs/httpRequest" }, - "response": {"x-registry-field": "property", "$ref": "#/$defs/httpResponse" } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "script": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, - "modules": { - "x-registry-field": "sensitive_property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/relativePath" } - } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["snapshot"], - "properties": { - "snapshot": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["entity", "exact", "freshness"], - "properties": { - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "exact": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 8, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputReference" } - }, - "freshness": { - "x-registry-field": "property", - "type": "string", - "description": "Maximum accepted snapshot age as a canonical positive duration no greater than 31 days.", - "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-5][0-9]{8}|26[0-6][0-9]{7}|267[0-7][0-9]{6}|2678[0-3][0-9]{5}|2678400000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-5][0-9]{5}|26[0-6][0-9]{4}|267[0-7][0-9]{3}|2678[0-3][0-9]{2}|2678400)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-3][0-9]{3}|44[0-5][0-9]{2}|446[0-3][0-9]{1}|44640)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-3][0-9]{1}|74[0-3]|744)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30|31)d)$" - } - } - } - } - } - ] - }, - "output": { - "description": "Type and optional source pointer for one normalized integration output.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "x-registry-source": { - "x-registry-field": "property", - "type": "string", - "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" - } - } - }, - "limits": { - "description": "Per-execution call, byte, and deadline budgets.", - "type": "object", - "additionalProperties": false, - "properties": { - "calls": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16 }, - "request_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationRequestByteSize" }, - "source_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationSourceByteSize" }, - "deadline": { - "x-registry-field": "property", - "description": "Positive total integration deadline no greater than 20 seconds.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - } -} diff --git a/projects/nia-population/.registry-stack-editor/schemas/project.schema.json b/projects/nia-population/.registry-stack-editor/schemas/project.schema.json deleted file mode 100644 index 91b7ea7..0000000 --- a/projects/nia-population/.registry-stack-editor/schemas/project.schema.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", - "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", - "type": "object", - "additionalProperties": false, - "required": ["version", "registry", "services"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, - "starter": { - "x-registry-field": "property", - "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", - "type": "object", - "additionalProperties": false, - "required": ["id", "release", "content_digest"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } - } - }, - "registry": { - "x-registry-field": "property", - "description": "Stable identity of the Registry Stack project.", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } - }, - "integrations": { - "x-registry-field": "property", - "description": "Source adaptation definitions keyed by project-local integration identifier.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "entities": { - "x-registry-field": "property", - "description": "Materialized entity definitions keyed by project-local entity identifier.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "services": { - "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } - } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } - ], - "examples": [ - { - "version": 1, - "registry": { "id": "benefits-registry" }, - "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } - }, - "services": {} - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, - "recordsApi": { - "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", - "type": "object", - "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], - "properties": { - "scopes": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["metadata", "rows"], - "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } - } - }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "pagination": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["default_limit", "max_limit"], - "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } - } - }, - "filters": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } - }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "relationships": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - } - }, - "aggregates": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } - }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "standards": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], - "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } - } - } - } - }, - "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } - }, - "recordAttributeReleaseProfile": { - "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", - "type": "object", - "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], - "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "subject": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["source_field", "id_type"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } - } - }, - "release_conditions": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } - }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["required", "sensitivity"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } - }, - "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } - ] - } - } - } - }, - "recordAttributeReleaseExpression": { - "description": "Bounded CEL evaluated only against the projected source object.", - "type": "object", - "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } - }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "recordAggregate": { - "description": "Governed aggregate definition with disclosure controls.", - "type": "object", - "additionalProperties": false, - "required": ["description", "disclosure_control"], - "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, - "disclosure_control": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } - } - } - }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] - }, - "recordAggregateAccess": { - "description": "Authorization scopes and execution policy for one governed aggregate.", - "type": "object", - "additionalProperties": false, - "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } - } - }, - "recordAggregateSpatial": { - "description": "Administrative-area geometry join used to spatially group aggregate results.", - "type": "object", - "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], - "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatial": { - "description": "OGC API Features collection metadata and bounded geometry mapping.", - "type": "object", - "additionalProperties": false, - "required": ["geometry"], - "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatialGeometry": { - "description": "Supported point or encoded-geometry field mapping for spatial records.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - } - ] - }, - "recordSpatialBbox": { - "description": "Record fields carrying the four coordinates of a bounding box.", - "type": "object", - "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], - "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordSpdci": { - "description": "SP-DCI registry identity and field mappings for a records service.", - "type": "object", - "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], - "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } - } - }, - "recordFieldMap": { - "description": "Bounded mapping from standard field names to entity field identifiers.", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } - }, - "recordAggregateDimension": { - "description": "Named grouping dimension backed by one entity field.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "field"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateIndicator": { - "description": "SDMX-style aggregate indicator with units and display metadata.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateMeasure": { - "description": "Named aggregate calculation over one entity column.", - "type": "object", - "additionalProperties": false, - "required": ["name", "function", "column"], - "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordAggregateFunction": { - "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] - }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } - } - ] - }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, - "recordsService": { - "description": "Relay records API backed by one authored materialized entity.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "entity", "api"], - "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Source-free claims remain evaluation-only and cannot be selected by a credential profile.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { "output": {"x-registry-field": "property", "type": "string" }, "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } - }, - "credential_profiles": { - "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation. Source-free claim evaluation cannot be exposed as credential capability.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } - } - } - } - }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, - "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } - }, - "consultations": { - "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/targetRequestMapping" } } } } - }, - "targetRequestMapping": { - "description": "Closed target request binding. Attribute values are bounded typed caller-supplied context, not authenticated identifiers.", - "type": "string", - "maxLength": 128, - "pattern": "^request\\.target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})$" - }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for source-free evaluation. A source-free claim cannot be selected by a credential profile.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } - } - } -} diff --git a/projects/nia-population/.vscode/extensions.json b/projects/nia-population/.vscode/extensions.json deleted file mode 100644 index 6098b6e..0000000 --- a/projects/nia-population/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "redhat.vscode-yaml" - ] -} diff --git a/projects/nia-population/.vscode/settings.json b/projects/nia-population/.vscode/settings.json deleted file mode 100644 index db92a93..0000000 --- a/projects/nia-population/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "yaml.schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } -} diff --git a/projects/nia-population/.zed/settings.json b/projects/nia-population/.zed/settings.json deleted file mode 100644 index fac07ac..0000000 --- a/projects/nia-population/.zed/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } - } - } - } - } -} diff --git a/projects/nia-population/entities/population.yaml b/projects/nia-population/entities/population.yaml deleted file mode 100644 index db0d1f6..0000000 --- a/projects/nia-population/entities/population.yaml +++ /dev/null @@ -1,48 +0,0 @@ -version: 1 -id: population -revision: 1 -primary_key: uin -schema: - type: object - additionalProperties: false - required: - - uin - - legacy_nid - - given_name - - family_name - - sex - - birth_date - - identity_status - - alive - properties: - uin: - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - legacy_nid: - type: [string, "null"] - maxLength: 32 - given_name: - type: string - maxLength: 128 - family_name: - type: string - maxLength: 128 - sex: - type: string - maxLength: 32 - birth_date: - type: string - format: date - maxLength: 10 - identity_status: - type: string - maxLength: 32 - alive: - type: boolean -materialization: - max_records: 10000000 - max_bytes: 512MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/nia-population/environments/hosted.yaml b/projects/nia-population/environments/hosted.yaml deleted file mode 100644 index d542939..0000000 --- a/projects/nia-population/environments/hosted.yaml +++ /dev/null @@ -1,51 +0,0 @@ -version: 1 -entities: - population: - provider: - type: postgres - connection: { secret: SOLMARA_NIA_DATABASE_URL } - schema: public - table: population_person - columns: - uin: uin - legacy_nid: legacy_nid - given_name: given_name - family_name: family_name - sex: sex - birth_date: birth_date - identity_status: identity_status - alive: alive - source_revision: nia-population-person-v1 - generation: 2026-07-14 -issuance: - issuer: did:web:id.registrystack.org:solmara:authority:nia - signing_kid: did:web:id.registrystack.org:solmara:authority:nia#issuer-key-1 - signing_key: { secret: NIA_NOTARY_ISSUER_JWK } - generation: 1 -callers: - child-benefit-federator: - api_key_fingerprint: { secret: NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["evidence:nia:child-benefit:read"] - citizen-portal: - api_key_fingerprint: { secret: NIA_CITIZEN_CLIENT_TOKEN_HASH } - scopes: ["evidence:nia:citizen:read"] -relay: - origin: https://nia-relay.solmara.registrystack.org - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json - audience: registry-relay - allowed_clients: [solmara-esignet] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: nia-notary - token_file: /run/secrets/nia-notary-relay-token -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -deployment: - profile: hosted_lab - relay: { service: nia-population-relay } - notary: { service: nia-notary } diff --git a/projects/nia-population/environments/local.yaml b/projects/nia-population/environments/local.yaml deleted file mode 100644 index 6df2457..0000000 --- a/projects/nia-population/environments/local.yaml +++ /dev/null @@ -1,56 +0,0 @@ -version: 1 -entities: - population: - provider: - type: postgres - connection: { secret: SOLMARA_NIA_DATABASE_URL } - schema: public - table: population_person - columns: - uin: uin - legacy_nid: legacy_nid - given_name: given_name - family_name: family_name - sex: sex - birth_date: birth_date - identity_status: identity_status - alive: alive - source_revision: nia-population-person-v1 - generation: 2026-07-14 -issuance: - issuer: did:web:id.registrystack.org:solmara:authority:nia - signing_kid: did:web:id.registrystack.org:solmara:authority:nia#issuer-key-1 - signing_key: { secret: NIA_NOTARY_ISSUER_JWK } - generation: 1 -callers: - child-benefit-federator: - api_key_fingerprint: { secret: NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["evidence:nia:child-benefit:read"] - citizen-portal: - api_key_fingerprint: { secret: NIA_CITIZEN_CLIENT_TOKEN_HASH } - scopes: ["evidence:nia:citizen:read"] -relay: - origin: http://127.0.0.1:8080 - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json - audience: registry-relay - allowed_clients: [solmara-esignet] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: nia-notary - token_file: /run/secrets/nia-notary-relay-token -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -# Emulated local images require Registry Stack's bounded 1 GiB CEL worker ceiling. -# Hosted deployments intentionally retain the 128 MiB default. -notary_cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local - relay: { service: nia-population-relay } - notary: { service: nia-notary } diff --git a/projects/nia-population/integrations/population-person-by-uin/fixtures/match.yaml b/projects/nia-population/integrations/population-person-by-uin/fixtures/match.yaml deleted file mode 100644 index 11438f3..0000000 --- a/projects/nia-population/integrations/population-person-by-uin/fixtures/match.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: nia-population-person-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300010248" }] - claims: [population-record-active] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review -input: { uin: "2300010248" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: { identity_status: active, alive: true } -expect: - outcome: match - outputs: { identity_status: active, alive: true } - claims: - population-record-active: true - citizen-population-record-active: true diff --git a/projects/nia-population/integrations/population-person-by-uin/fixtures/no-match.yaml b/projects/nia-population/integrations/population-person-by-uin/fixtures/no-match.yaml deleted file mode 100644 index 4bf76c4..0000000 --- a/projects/nia-population/integrations/population-person-by-uin/fixtures/no-match.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: nia-population-person-no-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "9999999999" }] - claims: [citizen-population-record-active] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service -input: { uin: "9999999999" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: - population-record-active: false - citizen-population-record-active: false diff --git a/projects/nia-population/integrations/population-person-by-uin/integration.yaml b/projects/nia-population/integrations/population-person-by-uin/integration.yaml deleted file mode 100644 index c08cc90..0000000 --- a/projects/nia-population/integrations/population-person-by-uin/integration.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -id: solmara-nia-population-person-by-uin -revision: 1 - -input: - uin: - role: selector - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - -capability: - snapshot: - entity: population - exact: - uin: { input: uin } - freshness: 5m - -outputs: [identity_status, alive] - -not_applicable: - ambiguity: - rationale: The exact UIN selector is the entity primary key, whose materialized unique-key constraint permits at most one population record. - request_fixture: nia-population-person-match - subject_mismatch: - rationale: The minimized output projection omits UIN, so it contains no identifier comparable with the requested UIN. - request_fixture: nia-population-person-match diff --git a/projects/nia-population/registry-stack.yaml b/projects/nia-population/registry-stack.yaml deleted file mode 100644 index 60e0cd3..0000000 --- a/projects/nia-population/registry-stack.yaml +++ /dev/null @@ -1,129 +0,0 @@ -version: 1 - -registry: - id: solmara-nia-population - -integrations: - population-person-by-uin: - file: integrations/population-person-by-uin/integration.yaml - -entities: - population: - file: entities/population.yaml - -services: - nia-population-records: - kind: records_api - entity: population - title: NIA population records - description: Governed population attributes used by NIA-owned services. - owner: National Identity Agency - sensitivity: personal - access_rights: restricted - update_frequency: continuous - api: - scopes: - metadata: population:metadata - rows: population:rows - evidence_verification: population:evidence_verification - purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - projection: - - uin - - legacy_nid - - given_name - - family_name - - sex - - birth_date - - identity_status - - alive - pagination: { default_limit: 25, max_limit: 100 } - filters: - uin: [eq] - legacy_nid: [eq] - attribute_release_profiles: - solmara-nia-userinfo: - version: v1 - title: SolmaraID UserInfo - description: Minimal identity attributes for the Solmara eSignet authenticator. - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_scope: population:identity_release - subject: - source_field: uin - id_type: national_id - release_conditions: - expression: - cel: "source.identity_status == 'active' && source.alive == true" - claims: - individual_id: - source_field: uin - required: true - sensitivity: direct_identifier - name: - expression: - cel: "source.given_name + ' ' + source.family_name" - required: true - sensitivity: direct_identifier - given_name: - source_field: given_name - required: true - sensitivity: direct_identifier - family_name: - source_field: family_name - required: true - sensitivity: direct_identifier - birthdate: - source_field: birth_date - required: true - sensitivity: personal - gender: - source_field: sex - required: false - sensitivity: personal - standards: { ogc_features: false, sp_dci: false } - - nia-child-benefit: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - legal_basis: child-benefit-act - consent: not_required - access: - scopes: ["evidence:nia:child-benefit:read"] - consultations: - population: - integration: population-person-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - population-record-active: - cel: >- - population.matched && population.identity_status == 'active' - && population.alive == true - disclosure: predicate - - nia-citizen-status: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service - legal_basis: citizen-self-service - consent: not_required - access: - scopes: ["evidence:nia:citizen:read"] - consultations: - population: - integration: population-person-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - citizen-population-record-active: - cel: >- - population.matched && population.identity_status == 'active' - && population.alive == true - disclosure: predicate - credential_profiles: - citizen-population-status: - format: dc+sd-jwt - type: https://id.registrystack.org/solmara/vct/citizen-population-status - validity: 10m - claims: [citizen-population-record-active] diff --git a/projects/sipf-pensions/.registry-stack-editor/manifest.json b/projects/sipf-pensions/.registry-stack-editor/manifest.json deleted file mode 100644 index 3603a8e..0000000 --- a/projects/sipf-pensions/.registry-stack-editor/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "format": "registry.stack.editor-manifest", - "version": 1, - "registryctl_version": "0.15.2", - "schemas": [ - { - "kind": "project", - "path": "schemas/project.schema.json", - "file_glob": "registry-stack.yaml", - "sha256": "sha256:9fd76663c866c52bb5a17a4cfea821049b7428c812f1666da9db0d43a00da144" - }, - { - "kind": "environment", - "path": "schemas/environment.schema.json", - "file_glob": "environments/*.yaml", - "sha256": "sha256:d16828629e001a30a1705af90bcc12a214be18cf5805a9833e65b03d631c9e9b" - }, - { - "kind": "integration", - "path": "schemas/integration.schema.json", - "file_glob": "integrations/*/integration.yaml", - "sha256": "sha256:2e45452f63c3c610ed14b79244bc9aa086d79d811ad0899223b36c160d449a79" - }, - { - "kind": "fixture", - "path": "schemas/fixture.schema.json", - "file_glob": "integrations/*/fixtures/*.yaml", - "sha256": "sha256:98c0ab21e35cecd143b51e4b5dd487de07ceea24e505bbe351d212e0628c8c8c" - }, - { - "kind": "entity", - "path": "schemas/entity.schema.json", - "file_glob": "entities/*.yaml", - "sha256": "sha256:4cbb31c8addeb54f8b57aaaff8f30c39e651a21a2d741a1b775ecb0d7c248342" - } - ] -} diff --git a/projects/sipf-pensions/.registry-stack-editor/schemas/entity.schema.json b/projects/sipf-pensions/.registry-stack-editor/schemas/entity.schema.json deleted file mode 100644 index ffc57c8..0000000 --- a/projects/sipf-pensions/.registry-stack-editor/schemas/entity.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/entity.v1.json", - "title": "Registry Stack project entity v1", - "description": "Defines a bounded, materialized entity that Relay can query without coupling the project to a specific source product.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "primary_key", "schema", "materialization"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Entity authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the entity." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this entity definition.", "examples": [1] }, - "primary_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Field whose value uniquely identifies each materialized record.", "examples": ["person_id"] }, - "schema": {"x-registry-field": "property", "$ref": "#/$defs/objectSchema", "description": "Closed JSON object schema for materialized records." }, - "materialization": { - "x-registry-field": "property", - "description": "Authored resource, refresh, and bounded recovery-set retention applied while building entity generations.", - "type": "object", - "additionalProperties": false, - "required": ["max_records", "max_bytes", "refresh", "retain_generations"], - "properties": { - "max_records": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 100000000, "description": "Maximum number of records allowed in one generation.", "examples": [10000] }, - "max_bytes": { - "x-registry-field": "property", - "description": "Maximum encoded size of one generation, up to 1 GiB, as bytes or a KiB/MiB quantity.", - "examples": ["16MiB"], - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1073741824 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,5}|10[0-3][0-9]{4}|104[0-7][0-9]{3}|1048[0-4][0-9]{2}|10485[0-6][0-9]|104857[0-6])KiB|(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])MiB)$" } - ] - }, - "refresh": { - "x-registry-field": "property", - "description": "Refresh cadence as a canonical positive duration no greater than 30 days, or manual when an operator initiates every refresh.", - "examples": ["1h"], - "oneOf": [ - {"x-registry-field": "branch", "const": "manual" }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-4][0-9]{8}|25[0-8][0-9]{7}|259[0-1][0-9]{6}|2592000000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-4][0-9]{5}|25[0-8][0-9]{4}|259[0-1][0-9]{3}|2592000)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-2][0-9]{3}|43[0-1][0-9]{2}|43200)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-1][0-9]{1}|720)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30)d)$" } - ] - }, - "retain_generations": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16, "description": "Number of completed cache generations, including the active generation, retained as a bounded recovery set after successful publication. This does not make arbitrary retained generations selectable for rollback.", "examples": [2] } - } - } - }, - "examples": [ - { - "version": 1, - "id": "people", - "revision": 1, - "primary_key": "person_id", - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["person_id"], - "properties": { "person_id": { "type": "string" } } - }, - "materialization": { - "max_records": 10000, - "max_bytes": "16MiB", - "refresh": "1h", - "retain_generations": 2 - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "propertyName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$", "description": "Portable lowercase record field name." }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for a nullable field.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "items": false, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "fieldSchema": { - "description": "Bounded schema keywords supported for one scalar materialized field.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "enum": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true }, - "const": {"x-registry-field": "property"}, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 65536 }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 } - } - }, - "objectSchema": { - "description": "Closed object schema whose declared required fields must appear in properties.", - "type": "object", - "additionalProperties": false, - "required": ["type", "additionalProperties", "required", "properties"], - "properties": { - "type": {"x-registry-field": "property", "const": "object" }, - "additionalProperties": {"x-registry-field": "property", "const": false }, - "required": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 256, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/propertyName" } - }, - "properties": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 256, - "propertyNames": {"x-registry-field": "property", "$ref": "#/$defs/propertyName" }, - "additionalProperties": {"x-registry-field": "property", "$ref": "#/$defs/fieldSchema" } - } - } - } - } -} diff --git a/projects/sipf-pensions/.registry-stack-editor/schemas/environment.schema.json b/projects/sipf-pensions/.registry-stack-editor/schemas/environment.schema.json deleted file mode 100644 index f04c70b..0000000 --- a/projects/sipf-pensions/.registry-stack-editor/schemas/environment.schema.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", - "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", - "type": "object", - "additionalProperties": false, - "required": ["version", "deployment"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { - "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "callers": { - "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } - } - }, - "relay": { - "x-registry-field": "property", - "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_cel": { - "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], - "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } - } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] - } - }, - "allOf": [ - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, - "then": { - "x-registry-field": "branch", - "required": ["notary_state"], - "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } - } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { - "x-registry-field": "branch", - "properties": { - "relay": { - "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - }, - "oid4vci": { - "x-registry-field": "property", - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - } - } - } - } - } - } - ], - "examples": [ - { - "version": 1, - "deployment": { - "profile": "local", - "notary": { "service": "notary" } - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", - "type": "object", - "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } - }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } - } - } - }, - "privateCidrs": { - "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } - }, - "ca": { - "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "mtls": { - "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "endpoint": { - "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } - } - }, - "credential": { - "description": "One supported source credential shape, containing references rather than values.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } - ] - }, - "provider": { - "description": "Environment-specific physical provider for a materialized entity.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } - ] - }, - "entity": { - "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } - }, - "source": { - "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, - "timeout": { - "x-registry-field": "property", - "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - }, - "integration": { - "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } - }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } - } -} diff --git a/projects/sipf-pensions/.registry-stack-editor/schemas/fixture.schema.json b/projects/sipf-pensions/.registry-stack-editor/schemas/fixture.schema.json deleted file mode 100644 index b853192..0000000 --- a/projects/sipf-pensions/.registry-stack-editor/schemas/fixture.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/fixture.v1.json", - "title": "Registry Stack project integration fixture v1", - "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", - "type": "object", - "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], - "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." - }, - "input": { - "x-registry-field": "redacted_fixture_property", - "description": "Typed consultation inputs supplied to the adapter under test.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] - } - }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Named date values available to deterministic fixture interpolation.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "interactions": { - "x-registry-field": "redacted_fixture_property", - "description": "Ordered upstream request and response exchanges expected during execution.", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } - }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } - }, - "examples": [ - { - "name": "existing household matches", - "classification": "synthetic", - "input": { "household_id": "HH-1001" }, - "interactions": [ - { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } - } - ], - "expect": { "outcome": "match", "outputs": { "active": true } } - } - ], - "$defs": { - "governedRequest": { - "description": "The same closed governed request shape accepted by project test --live.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the same governed request boundary as a live Notary evaluation.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } - }, - "attributes": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } - } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } - } - }, - "request": { - "description": "Exact HTTP request shape the adapter must produce.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "query": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, - { - "x-registry-field": "branch", - "type": "array", - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } - } - ] - } - }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - "response": { - "description": "Synthetic HTTP response or timeout returned to the adapter.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["timeout"], - "properties": { - "timeout": { - "x-registry-field": "redacted_fixture_property", - "description": "Reviewed synthetic timeout intent no greater than 20 seconds; offline execution returns a timeout outcome without sleeping.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - ] - }, - "fixtureBody": { - "description": "Inline JSON, or an exact closed reference to a strict JSON file below the fixture bodies directory. The top-level file key is reserved for file references.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": { - "x-registry-field": "redacted_fixture_property", - "type": "string", - "minLength": 8, - "maxLength": 4096, - "pattern": "^bodies/(?!\\.\\.?/)(?!.*(?:/)\\.\\.?/)(?!.*//).+$" - } - } - }, - { - "x-registry-field": "branch", - "not": { - "x-registry-field": "branch", - "type": "object", - "required": ["file"] - } - } - ] - }, - "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } - } - } - } -} diff --git a/projects/sipf-pensions/.registry-stack-editor/schemas/integration.schema.json b/projects/sipf-pensions/.registry-stack-editor/schemas/integration.schema.json deleted file mode 100644 index 9bb2ad0..0000000 --- a/projects/sipf-pensions/.registry-stack-editor/schemas/integration.schema.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/integration.v1.json", - "title": "Registry Stack project integration v1", - "description": "Defines a product-neutral source adaptation contract using bounded HTTP, Rhai script, or exact snapshot capabilities.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "input", "capability", "outputs"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Integration authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the integration." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this integration contract.", "examples": [1] }, - "source": {"x-registry-field": "property", "$ref": "#/$defs/source", "description": "Optional source product metadata and transport policy; capabilities remain product-neutral." }, - "input": { - "x-registry-field": "property", - "description": "Typed selector and parameter inputs accepted by this integration.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/input" } - }, - "capability": {"x-registry-field": "property", "$ref": "#/$defs/capability", "description": "Exactly one bounded execution mechanism for the source adaptation." }, - "outputs": { - "x-registry-field": "property", - "description": "Named typed outputs, or a shorthand list of output names inferred by the authoring compiler.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": { - "x-registry-field": "map_key", - "allOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputName" }, - {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "enum": ["matched", "outcome"] } } - ] - }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/output" } - }, - { - "x-registry-field": "branch", - "type": "array", - "minItems": 1, - "maxItems": 64, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/inputName" } - } - ] - }, - "limits": {"x-registry-field": "property", "$ref": "#/$defs/limits", "description": "Optional tighter resource limits for one integration execution." }, - "not_applicable": {"x-registry-field": "property", "$ref": "#/$defs/notApplicable", "description": "Explicit rationale and request-fixture evidence for a normally required outcome that the source contract cannot produce." } - }, - "examples": [ - { - "version": 1, - "id": "household_lookup", - "revision": 1, - "input": { - "household_id": { "role": "selector", "type": "string" } - }, - "capability": { - "script": { "file": "adapter.rhai" } - }, - "outputs": { - "active": { "type": "boolean" } - } - } - ], - "$defs": { - "notApplicable": { - "description": "Closed declarations for outcomes that the source protocol, exact singleton endpoint, or local unique-key constraint proves impossible.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ambiguity": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed source contract cannot produce more than one matching record." }, - "subject_mismatch": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed response contract contains no identifier comparable with a requested selector." } - } - }, - "notApplicableReason": { - "description": "A reviewable reason that names a real request fixture proving the source access shape used by the rationale.", - "type": "object", - "additionalProperties": false, - "required": ["rationale", "request_fixture"], - "properties": { - "rationale": {"x-registry-field": "property", "type": "string", "minLength": 24, "maxLength": 512, "description": "Why the named normally required outcome cannot occur for this source contract." }, - "request_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Fixture name containing the supporting bounded source request." } - } - }, - "stableId": { - "description": "Lowercase stable identifier used in project references.", - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,95}$" - }, - "inputName": { - "description": "Portable lowercase name for an integration input or output.", - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "relativePath": { - "description": "Project-relative path without traversal or duplicate separators.", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" - }, - "integrationResponseByteSize": { - "description": "Maximum bytes accepted from one source response, defaulting to 512 KiB and capped at 8 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "512KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 8388608 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|[1-7][0-9]{3}|80[0-9]{2}|81[0-8][0-9]|819[0-2])KiB|[1-8]MiB)$" } - ] - }, - "integrationRequestByteSize": { - "description": "Maximum aggregate author-controlled request bytes for one execution, defaulting to 64 KiB and capped at 1 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "64KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1048576 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])KiB|1MiB)$" } - ] - }, - "integrationSourceByteSize": { - "description": "Maximum aggregate source-response bytes for one execution, defaulting to 2 MiB and capped at 16 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "2MiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 16777216 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-5][0-9]{3}|16[0-2][0-9]{2}|163[0-7][0-9]|1638[0-4])KiB|(?:[1-9]|1[0-6])MiB)$" } - ] - }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for nullable values.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "input": { - "description": "Type, validation, and canonicalization rules for one authored input.", - "type": "object", - "additionalProperties": false, - "required": ["role", "type"], - "properties": { - "role": {"x-registry-field": "property", "enum": ["selector", "parameter"] }, - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "description": "Maximum Unicode scalar-value length. The 1024-character ceiling derives a conservative 4096-byte UTF-8 ceiling for one canonical input.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 16384 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "canonicalization": {"x-registry-field": "property", "enum": ["identity", "ascii_lowercase"] } - } - }, - "versions": { - "description": "Source versions tested by the project and versions explicitly accepted as unverified.", - "type": "object", - "additionalProperties": false, - "properties": { - "tested": {"x-registry-field": "property", "$ref": "#/$defs/versionList" }, - "unverified": {"x-registry-field": "property", "$ref": "#/$defs/versionList" } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["tested"], "properties": { "tested": {"x-registry-field": "property", "minItems": 1 } } }, - {"x-registry-field": "branch", "required": ["unverified"], "properties": { "unverified": {"x-registry-field": "property", "minItems": 1 } } } - ] - }, - "versionList": { - "description": "Bounded list of source version labels.", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } - }, - "credential": { - "description": "Authentication mechanism expected by the source adapter, without environment secret values.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "enum": ["none", "basic", "static_bearer"] } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_header" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_query" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9._:~-]{0,95}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "request", "response_profile"], - "properties": { - "type": {"x-registry-field": "property", "const": "oauth2_client_credentials" }, - "request": {"x-registry-field": "property", "enum": ["form", "json"] }, - "response_profile": {"x-registry-field": "property", "const": "oauth2_bearer" }, - "scope": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 }, - "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "refresh_skew": { - "x-registry-field": "property", - "description": "Positive OAuth refresh interval below the generated token contract's 60-second minimum lifetime.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4})ms|(?:[1-9]|[1-5][0-9])s)$" - } - } - } - ] - }, - "allowRule": { - "description": "One read-only upstream method and path pattern the adapter may call.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": { - "x-registry-field": "sensitive_property", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^/[^?#]*$" - }, - "semantics": {"x-registry-field": "property", "const": "read_only" } - } - }, - "source": { - "description": "Optional source metadata, authentication shape, allowlist, and protocol profile.", - "type": "object", - "additionalProperties": false, - "required": ["auth"], - "properties": { - "product": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "versions": {"x-registry-field": "property", "$ref": "#/$defs/versions" }, - "auth": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "allow": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/allowRule" } - }, - "request_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "format": {"x-registry-field": "property", "enum": ["json", "text"] }, - "max_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationResponseByteSize" } - } - }, - "protocol": {"x-registry-field": "property", "$ref": "#/$defs/protocol" } - } - }, - "protocol": { - "description": "Optional interoperable protocol profiles layered over the source transport.", - "type": "object", - "additionalProperties": false, - "properties": { - "signed_dci": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["profile", "path", "jwks_profile", "sender", "receiver", "registry_type", "record_type", "locale", "selectors"], - "properties": { - "profile": {"x-registry-field": "property", "const": "dci-search-v1" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^/[^?#]*$" }, - "jwks_profile": {"x-registry-field": "property", "const": "rsa-signing-jwks-v1" }, - "sender": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "receiver": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "registry_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "record_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "locale": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" }, - "selectors": { - "x-registry-field": "property", - "type": "object", "minProperties": 1, "maxProperties": 8, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["field", "response_pointer"], - "properties": { - "field": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 160 }, - "response_pointer": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" } - } - } - } - } - } - }, - "minProperties": 1 - }, - "inputReference": { - "description": "Reference to a declared integration input.", - "type": "object", - "additionalProperties": false, - "required": ["input"], - "properties": { "input": {"x-registry-field": "property", "$ref": "#/$defs/inputName" } } - }, - "inputOrLiteral": { - "description": "Request value supplied from a declared input or an authored scalar literal.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputReference" }, - {"x-registry-field": "branch", "type": ["string", "boolean", "integer"] } - ] - }, - "httpRequest": { - "description": "Bounded read-only HTTP request template.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "semantics": {"x-registry-field": "property", "const": "read_only" }, - "query": {"x-registry-field": "property", "type": "object", "maxProperties": 64, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "headers": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "body": { "x-registry-field": "property" } - } - }, - "httpResponse": { - "description": "Rules for translating upstream status and body cardinality into normalized outcomes.", - "type": "object", - "additionalProperties": false, - "properties": { - "no_match": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "ambiguous": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "shape": { - "x-registry-field": "property", - "oneOf": [ - {"x-registry-field": "branch", "const": "singleton" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["records", "cardinality"], - "properties": { - "records": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" }, - "cardinality": {"x-registry-field": "property", "const": "probe_two" } - } - } - ] - } - } - }, - "capability": { - "description": "Closed choice among direct HTTP, Rhai script, and exact snapshot execution.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["http"], - "properties": { - "http": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["request"], - "properties": { - "request": {"x-registry-field": "property", "$ref": "#/$defs/httpRequest" }, - "response": {"x-registry-field": "property", "$ref": "#/$defs/httpResponse" } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "script": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, - "modules": { - "x-registry-field": "sensitive_property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/relativePath" } - } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["snapshot"], - "properties": { - "snapshot": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["entity", "exact", "freshness"], - "properties": { - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "exact": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 8, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputReference" } - }, - "freshness": { - "x-registry-field": "property", - "type": "string", - "description": "Maximum accepted snapshot age as a canonical positive duration no greater than 31 days.", - "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-5][0-9]{8}|26[0-6][0-9]{7}|267[0-7][0-9]{6}|2678[0-3][0-9]{5}|2678400000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-5][0-9]{5}|26[0-6][0-9]{4}|267[0-7][0-9]{3}|2678[0-3][0-9]{2}|2678400)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-3][0-9]{3}|44[0-5][0-9]{2}|446[0-3][0-9]{1}|44640)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-3][0-9]{1}|74[0-3]|744)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30|31)d)$" - } - } - } - } - } - ] - }, - "output": { - "description": "Type and optional source pointer for one normalized integration output.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "x-registry-source": { - "x-registry-field": "property", - "type": "string", - "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" - } - } - }, - "limits": { - "description": "Per-execution call, byte, and deadline budgets.", - "type": "object", - "additionalProperties": false, - "properties": { - "calls": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16 }, - "request_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationRequestByteSize" }, - "source_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationSourceByteSize" }, - "deadline": { - "x-registry-field": "property", - "description": "Positive total integration deadline no greater than 20 seconds.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - } -} diff --git a/projects/sipf-pensions/.registry-stack-editor/schemas/project.schema.json b/projects/sipf-pensions/.registry-stack-editor/schemas/project.schema.json deleted file mode 100644 index 91b7ea7..0000000 --- a/projects/sipf-pensions/.registry-stack-editor/schemas/project.schema.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", - "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", - "type": "object", - "additionalProperties": false, - "required": ["version", "registry", "services"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, - "starter": { - "x-registry-field": "property", - "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", - "type": "object", - "additionalProperties": false, - "required": ["id", "release", "content_digest"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } - } - }, - "registry": { - "x-registry-field": "property", - "description": "Stable identity of the Registry Stack project.", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } - }, - "integrations": { - "x-registry-field": "property", - "description": "Source adaptation definitions keyed by project-local integration identifier.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "entities": { - "x-registry-field": "property", - "description": "Materialized entity definitions keyed by project-local entity identifier.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "services": { - "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } - } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } - ], - "examples": [ - { - "version": 1, - "registry": { "id": "benefits-registry" }, - "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } - }, - "services": {} - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, - "recordsApi": { - "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", - "type": "object", - "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], - "properties": { - "scopes": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["metadata", "rows"], - "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } - } - }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "pagination": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["default_limit", "max_limit"], - "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } - } - }, - "filters": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } - }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "relationships": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - } - }, - "aggregates": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } - }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "standards": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], - "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } - } - } - } - }, - "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } - }, - "recordAttributeReleaseProfile": { - "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", - "type": "object", - "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], - "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "subject": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["source_field", "id_type"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } - } - }, - "release_conditions": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } - }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["required", "sensitivity"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } - }, - "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } - ] - } - } - } - }, - "recordAttributeReleaseExpression": { - "description": "Bounded CEL evaluated only against the projected source object.", - "type": "object", - "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } - }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "recordAggregate": { - "description": "Governed aggregate definition with disclosure controls.", - "type": "object", - "additionalProperties": false, - "required": ["description", "disclosure_control"], - "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, - "disclosure_control": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } - } - } - }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] - }, - "recordAggregateAccess": { - "description": "Authorization scopes and execution policy for one governed aggregate.", - "type": "object", - "additionalProperties": false, - "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } - } - }, - "recordAggregateSpatial": { - "description": "Administrative-area geometry join used to spatially group aggregate results.", - "type": "object", - "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], - "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatial": { - "description": "OGC API Features collection metadata and bounded geometry mapping.", - "type": "object", - "additionalProperties": false, - "required": ["geometry"], - "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatialGeometry": { - "description": "Supported point or encoded-geometry field mapping for spatial records.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - } - ] - }, - "recordSpatialBbox": { - "description": "Record fields carrying the four coordinates of a bounding box.", - "type": "object", - "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], - "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordSpdci": { - "description": "SP-DCI registry identity and field mappings for a records service.", - "type": "object", - "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], - "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } - } - }, - "recordFieldMap": { - "description": "Bounded mapping from standard field names to entity field identifiers.", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } - }, - "recordAggregateDimension": { - "description": "Named grouping dimension backed by one entity field.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "field"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateIndicator": { - "description": "SDMX-style aggregate indicator with units and display metadata.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateMeasure": { - "description": "Named aggregate calculation over one entity column.", - "type": "object", - "additionalProperties": false, - "required": ["name", "function", "column"], - "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordAggregateFunction": { - "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] - }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } - } - ] - }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, - "recordsService": { - "description": "Relay records API backed by one authored materialized entity.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "entity", "api"], - "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Source-free claims remain evaluation-only and cannot be selected by a credential profile.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { "output": {"x-registry-field": "property", "type": "string" }, "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } - }, - "credential_profiles": { - "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation. Source-free claim evaluation cannot be exposed as credential capability.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } - } - } - } - }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, - "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } - }, - "consultations": { - "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/targetRequestMapping" } } } } - }, - "targetRequestMapping": { - "description": "Closed target request binding. Attribute values are bounded typed caller-supplied context, not authenticated identifiers.", - "type": "string", - "maxLength": 128, - "pattern": "^request\\.target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})$" - }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for source-free evaluation. A source-free claim cannot be selected by a credential profile.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } - } - } -} diff --git a/projects/sipf-pensions/.vscode/extensions.json b/projects/sipf-pensions/.vscode/extensions.json deleted file mode 100644 index 6098b6e..0000000 --- a/projects/sipf-pensions/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "redhat.vscode-yaml" - ] -} diff --git a/projects/sipf-pensions/.vscode/settings.json b/projects/sipf-pensions/.vscode/settings.json deleted file mode 100644 index db92a93..0000000 --- a/projects/sipf-pensions/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "yaml.schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } -} diff --git a/projects/sipf-pensions/.zed/settings.json b/projects/sipf-pensions/.zed/settings.json deleted file mode 100644 index fac07ac..0000000 --- a/projects/sipf-pensions/.zed/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } - } - } - } - } -} diff --git a/projects/sipf-pensions/entities/pension.yaml b/projects/sipf-pensions/entities/pension.yaml deleted file mode 100644 index 475f824..0000000 --- a/projects/sipf-pensions/entities/pension.yaml +++ /dev/null @@ -1,22 +0,0 @@ -version: 1 -id: pension -revision: 1 -primary_key: pensioner_uin -schema: - type: object - additionalProperties: false - required: [pensioner_uin, payment_status] - properties: - pensioner_uin: - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - payment_status: - type: string - maxLength: 32 -materialization: - max_records: 1000000 - max_bytes: 256MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/sipf-pensions/entities/survivor.yaml b/projects/sipf-pensions/entities/survivor.yaml deleted file mode 100644 index 804f4de..0000000 --- a/projects/sipf-pensions/entities/survivor.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: 1 -id: survivor -revision: 1 -primary_key: spouse_uin -schema: - type: object - additionalProperties: false - required: [spouse_uin, survivor_eligible] - properties: - spouse_uin: - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - survivor_eligible: - type: boolean -materialization: - max_records: 1000000 - max_bytes: 256MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/sipf-pensions/environments/hosted.yaml b/projects/sipf-pensions/environments/hosted.yaml deleted file mode 100644 index 9977b87..0000000 --- a/projects/sipf-pensions/environments/hosted.yaml +++ /dev/null @@ -1,55 +0,0 @@ -version: 1 -entities: - pension: - provider: - type: postgres - connection: { secret: SOLMARA_SIPF_DATABASE_URL } - schema: public - table: sipf_pension_payment - columns: - pensioner_uin: pensioner_uin - payment_status: payment_status - source_revision: sipf-pension-payment-projection-v1 - generation: 2026-07-14 - survivor: - provider: - type: postgres - connection: { secret: SOLMARA_SIPF_DATABASE_URL } - schema: public - table: sipf_survivor_benefit - columns: - spouse_uin: spouse_uin - survivor_eligible: survivor_eligible - source_revision: sipf-survivor-benefit-projection-v1 - generation: 2026-07-14 -issuance: - issuer: did:web:id.registrystack.org:solmara:authority:sipf - signing_kid: did:web:id.registrystack.org:solmara:authority:sipf#issuer-key-1 - signing_key: { secret: SIPF_NOTARY_ISSUER_JWK } - generation: 1 -callers: - pension-review-desk: - api_key_fingerprint: { secret: SIPF_PENSION_CLIENT_TOKEN_HASH } - scopes: - - evidence:sipf:pension-payment:read - - evidence:sipf:survivor-benefit:read -relay: - origin: https://sipf-relay.solmara.registrystack.org - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json - audience: registry-relay - allowed_clients: [] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: sipf-notary - token_file: /run/secrets/sipf-notary-relay-token -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -deployment: - profile: hosted_lab - relay: { service: sipf-pensions-relay } - notary: { service: sipf-notary } diff --git a/projects/sipf-pensions/environments/local.yaml b/projects/sipf-pensions/environments/local.yaml deleted file mode 100644 index ccebf5f..0000000 --- a/projects/sipf-pensions/environments/local.yaml +++ /dev/null @@ -1,60 +0,0 @@ -version: 1 -entities: - pension: - provider: - type: postgres - connection: { secret: SOLMARA_SIPF_DATABASE_URL } - schema: public - table: sipf_pension_payment - columns: - pensioner_uin: pensioner_uin - payment_status: payment_status - source_revision: sipf-pension-payment-projection-v1 - generation: 2026-07-14 - survivor: - provider: - type: postgres - connection: { secret: SOLMARA_SIPF_DATABASE_URL } - schema: public - table: sipf_survivor_benefit - columns: - spouse_uin: spouse_uin - survivor_eligible: survivor_eligible - source_revision: sipf-survivor-benefit-projection-v1 - generation: 2026-07-14 -issuance: - issuer: did:web:id.registrystack.org:solmara:authority:sipf - signing_kid: did:web:id.registrystack.org:solmara:authority:sipf#issuer-key-1 - signing_key: { secret: SIPF_NOTARY_ISSUER_JWK } - generation: 1 -callers: - pension-review-desk: - api_key_fingerprint: { secret: SIPF_PENSION_CLIENT_TOKEN_HASH } - scopes: - - evidence:sipf:pension-payment:read - - evidence:sipf:survivor-benefit:read -relay: - origin: http://127.0.0.1:8080 - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json - audience: registry-relay - allowed_clients: [] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: sipf-notary - token_file: /run/secrets/sipf-notary-relay-token -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -# Emulated local images require Registry Stack's bounded 1 GiB CEL worker ceiling. -# Hosted deployments intentionally retain the 128 MiB default. -notary_cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local - relay: { service: sipf-pensions-relay } - notary: { service: sipf-notary } diff --git a/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/ambiguous.yaml b/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/ambiguous.yaml deleted file mode 100644 index 7ae1bef..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/ambiguous.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: sipf-pension-case-ambiguous -classification: synthetic -input: { uin: "2300109568" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - - { payment_status: active } - - { payment_status: suspended } -expect: - outcome: ambiguous - outputs: {} - claims: {} diff --git a/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/match.yaml b/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/match.yaml deleted file mode 100644 index 9c7695a..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/match.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: sipf-pension-case-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300109568" }] - claims: [pension-payment-active] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review -input: { uin: "2300109568" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: { payment_status: active } } -expect: - outcome: match - outputs: { payment_status: active } - claims: { pension-payment-active: true } diff --git a/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/no-match.yaml b/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/no-match.yaml deleted file mode 100644 index 8dc5416..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-pensioner/fixtures/no-match.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: sipf-pension-case-no-match -classification: synthetic -input: { uin: "9999999999" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: { pension-payment-active: false } diff --git a/projects/sipf-pensions/integrations/pension-case-by-pensioner/integration.yaml b/projects/sipf-pensions/integrations/pension-case-by-pensioner/integration.yaml deleted file mode 100644 index e6b4256..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-pensioner/integration.yaml +++ /dev/null @@ -1,25 +0,0 @@ -version: 1 -id: solmara-sipf-pension-case-by-pensioner -revision: 1 - -input: - uin: - role: selector - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - -capability: - snapshot: - entity: pension - exact: - pensioner_uin: { input: uin } - freshness: 24h - -outputs: [payment_status] - -not_applicable: - subject_mismatch: - rationale: The minimized output projection omits pensioner_uin, so it contains no identifier comparable with the requested pensioner UIN. - request_fixture: sipf-pension-case-match diff --git a/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/ambiguous.yaml b/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/ambiguous.yaml deleted file mode 100644 index b364d2b..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/ambiguous.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: sipf-survivor-case-ambiguous -classification: synthetic -input: { uin: "2300118698" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: - status: 200 - body: - - { survivor_eligible: true } - - { survivor_eligible: false } -expect: - outcome: ambiguous - outputs: {} - claims: {} diff --git a/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/match.yaml b/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/match.yaml deleted file mode 100644 index 1bdd88b..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/match.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: sipf-survivor-case-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300118698" }] - claims: [survivor-is-eligible] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/survivor-benefit-determination -input: { uin: "2300118698" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: { survivor_eligible: true } } -expect: - outcome: match - outputs: { survivor_eligible: true } - claims: { survivor-is-eligible: true } diff --git a/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/no-match.yaml b/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/no-match.yaml deleted file mode 100644 index ae04744..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-survivor/fixtures/no-match.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: sipf-survivor-case-no-match -classification: synthetic -input: { uin: "9999999999" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: { survivor-is-eligible: false } diff --git a/projects/sipf-pensions/integrations/pension-case-by-survivor/integration.yaml b/projects/sipf-pensions/integrations/pension-case-by-survivor/integration.yaml deleted file mode 100644 index fc575d7..0000000 --- a/projects/sipf-pensions/integrations/pension-case-by-survivor/integration.yaml +++ /dev/null @@ -1,25 +0,0 @@ -version: 1 -id: solmara-sipf-pension-case-by-survivor -revision: 1 - -input: - uin: - role: selector - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - -capability: - snapshot: - entity: survivor - exact: - spouse_uin: { input: uin } - freshness: 24h - -outputs: [survivor_eligible] - -not_applicable: - subject_mismatch: - rationale: The minimized output projection omits spouse_uin, so it contains no identifier comparable with the requested survivor UIN. - request_fixture: sipf-survivor-case-match diff --git a/projects/sipf-pensions/registry-stack.yaml b/projects/sipf-pensions/registry-stack.yaml deleted file mode 100644 index ed64b25..0000000 --- a/projects/sipf-pensions/registry-stack.yaml +++ /dev/null @@ -1,59 +0,0 @@ -version: 1 - -registry: - id: solmara-sipf-pensions - -integrations: - pension-case-by-pensioner: - file: integrations/pension-case-by-pensioner/integration.yaml - pension-case-by-survivor: - file: integrations/pension-case-by-survivor/integration.yaml - -entities: - pension: - file: entities/pension.yaml - survivor: - file: entities/survivor.yaml - -services: - sipf-pension-payment-review: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - legal_basis: social-insurance-act - consent: not_required - access: - scopes: ["evidence:sipf:pension-payment:read"] - consultations: - pension: - integration: pension-case-by-pensioner - input: - uin: request.target.identifiers.solmara_uin - claims: - pension-payment-active: - cel: "pension.matched && pension.payment_status == 'active'" - disclosure: predicate - - sipf-survivor-benefit: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - legal_basis: social-insurance-act - consent: not_required - access: - scopes: ["evidence:sipf:survivor-benefit:read"] - consultations: - survivor: - integration: pension-case-by-survivor - input: - uin: request.target.identifiers.solmara_uin - claims: - survivor-is-eligible: - cel: "survivor.matched && survivor.survivor_eligible == true" - disclosure: predicate - credential_profiles: - survivor-benefit-status: - format: dc+sd-jwt - type: https://id.registrystack.org/solmara/vct/survivor-benefit-status - validity: 10m - claims: [survivor-is-eligible] diff --git a/projects/sro-social/.registry-stack-editor/manifest.json b/projects/sro-social/.registry-stack-editor/manifest.json deleted file mode 100644 index 3603a8e..0000000 --- a/projects/sro-social/.registry-stack-editor/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "format": "registry.stack.editor-manifest", - "version": 1, - "registryctl_version": "0.15.2", - "schemas": [ - { - "kind": "project", - "path": "schemas/project.schema.json", - "file_glob": "registry-stack.yaml", - "sha256": "sha256:9fd76663c866c52bb5a17a4cfea821049b7428c812f1666da9db0d43a00da144" - }, - { - "kind": "environment", - "path": "schemas/environment.schema.json", - "file_glob": "environments/*.yaml", - "sha256": "sha256:d16828629e001a30a1705af90bcc12a214be18cf5805a9833e65b03d631c9e9b" - }, - { - "kind": "integration", - "path": "schemas/integration.schema.json", - "file_glob": "integrations/*/integration.yaml", - "sha256": "sha256:2e45452f63c3c610ed14b79244bc9aa086d79d811ad0899223b36c160d449a79" - }, - { - "kind": "fixture", - "path": "schemas/fixture.schema.json", - "file_glob": "integrations/*/fixtures/*.yaml", - "sha256": "sha256:98c0ab21e35cecd143b51e4b5dd487de07ceea24e505bbe351d212e0628c8c8c" - }, - { - "kind": "entity", - "path": "schemas/entity.schema.json", - "file_glob": "entities/*.yaml", - "sha256": "sha256:4cbb31c8addeb54f8b57aaaff8f30c39e651a21a2d741a1b775ecb0d7c248342" - } - ] -} diff --git a/projects/sro-social/.registry-stack-editor/schemas/entity.schema.json b/projects/sro-social/.registry-stack-editor/schemas/entity.schema.json deleted file mode 100644 index ffc57c8..0000000 --- a/projects/sro-social/.registry-stack-editor/schemas/entity.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/entity.v1.json", - "title": "Registry Stack project entity v1", - "description": "Defines a bounded, materialized entity that Relay can query without coupling the project to a specific source product.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "primary_key", "schema", "materialization"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Entity authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the entity." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this entity definition.", "examples": [1] }, - "primary_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Field whose value uniquely identifies each materialized record.", "examples": ["person_id"] }, - "schema": {"x-registry-field": "property", "$ref": "#/$defs/objectSchema", "description": "Closed JSON object schema for materialized records." }, - "materialization": { - "x-registry-field": "property", - "description": "Authored resource, refresh, and bounded recovery-set retention applied while building entity generations.", - "type": "object", - "additionalProperties": false, - "required": ["max_records", "max_bytes", "refresh", "retain_generations"], - "properties": { - "max_records": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 100000000, "description": "Maximum number of records allowed in one generation.", "examples": [10000] }, - "max_bytes": { - "x-registry-field": "property", - "description": "Maximum encoded size of one generation, up to 1 GiB, as bytes or a KiB/MiB quantity.", - "examples": ["16MiB"], - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1073741824 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,5}|10[0-3][0-9]{4}|104[0-7][0-9]{3}|1048[0-4][0-9]{2}|10485[0-6][0-9]|104857[0-6])KiB|(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])MiB)$" } - ] - }, - "refresh": { - "x-registry-field": "property", - "description": "Refresh cadence as a canonical positive duration no greater than 30 days, or manual when an operator initiates every refresh.", - "examples": ["1h"], - "oneOf": [ - {"x-registry-field": "branch", "const": "manual" }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-4][0-9]{8}|25[0-8][0-9]{7}|259[0-1][0-9]{6}|2592000000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-4][0-9]{5}|25[0-8][0-9]{4}|259[0-1][0-9]{3}|2592000)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-2][0-9]{3}|43[0-1][0-9]{2}|43200)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-1][0-9]{1}|720)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30)d)$" } - ] - }, - "retain_generations": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16, "description": "Number of completed cache generations, including the active generation, retained as a bounded recovery set after successful publication. This does not make arbitrary retained generations selectable for rollback.", "examples": [2] } - } - } - }, - "examples": [ - { - "version": 1, - "id": "people", - "revision": 1, - "primary_key": "person_id", - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["person_id"], - "properties": { "person_id": { "type": "string" } } - }, - "materialization": { - "max_records": 10000, - "max_bytes": "16MiB", - "refresh": "1h", - "retain_generations": 2 - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "propertyName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$", "description": "Portable lowercase record field name." }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for a nullable field.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "items": false, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "fieldSchema": { - "description": "Bounded schema keywords supported for one scalar materialized field.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "enum": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true }, - "const": {"x-registry-field": "property"}, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 65536 }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 } - } - }, - "objectSchema": { - "description": "Closed object schema whose declared required fields must appear in properties.", - "type": "object", - "additionalProperties": false, - "required": ["type", "additionalProperties", "required", "properties"], - "properties": { - "type": {"x-registry-field": "property", "const": "object" }, - "additionalProperties": {"x-registry-field": "property", "const": false }, - "required": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 256, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/propertyName" } - }, - "properties": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 256, - "propertyNames": {"x-registry-field": "property", "$ref": "#/$defs/propertyName" }, - "additionalProperties": {"x-registry-field": "property", "$ref": "#/$defs/fieldSchema" } - } - } - } - } -} diff --git a/projects/sro-social/.registry-stack-editor/schemas/environment.schema.json b/projects/sro-social/.registry-stack-editor/schemas/environment.schema.json deleted file mode 100644 index f04c70b..0000000 --- a/projects/sro-social/.registry-stack-editor/schemas/environment.schema.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", - "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", - "type": "object", - "additionalProperties": false, - "required": ["version", "deployment"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { - "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "callers": { - "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } - } - }, - "relay": { - "x-registry-field": "property", - "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], - "properties": { - "postgresql": { - "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } - } - } - }, - "notary_cel": { - "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], - "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } - } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] - } - }, - "allOf": [ - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, - "then": { - "x-registry-field": "branch", - "required": ["notary_state"], - "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } - } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { - "x-registry-field": "branch", - "properties": { - "relay": { - "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - }, - "oid4vci": { - "x-registry-field": "property", - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } - } - } - } - } - } - } - ], - "examples": [ - { - "version": 1, - "deployment": { - "profile": "local", - "notary": { "service": "notary" } - } - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", - "type": "object", - "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], - "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } - }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } - } - } - }, - "privateCidrs": { - "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } - }, - "ca": { - "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "mtls": { - "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } - }, - "endpoint": { - "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } - } - }, - "credential": { - "description": "One supported source credential shape, containing references rather than values.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } - ] - }, - "provider": { - "description": "Environment-specific physical provider for a materialized entity.", - "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } - ] - }, - "entity": { - "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } - }, - "source": { - "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], - "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, - "timeout": { - "x-registry-field": "property", - "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - }, - "integration": { - "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } - }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } - } -} diff --git a/projects/sro-social/.registry-stack-editor/schemas/fixture.schema.json b/projects/sro-social/.registry-stack-editor/schemas/fixture.schema.json deleted file mode 100644 index b853192..0000000 --- a/projects/sro-social/.registry-stack-editor/schemas/fixture.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/fixture.v1.json", - "title": "Registry Stack project integration fixture v1", - "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", - "type": "object", - "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], - "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." - }, - "input": { - "x-registry-field": "redacted_fixture_property", - "description": "Typed consultation inputs supplied to the adapter under test.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] - } - }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Named date values available to deterministic fixture interpolation.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "interactions": { - "x-registry-field": "redacted_fixture_property", - "description": "Ordered upstream request and response exchanges expected during execution.", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } - }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } - }, - "examples": [ - { - "name": "existing household matches", - "classification": "synthetic", - "input": { "household_id": "HH-1001" }, - "interactions": [ - { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } - } - ], - "expect": { "outcome": "match", "outputs": { "active": true } } - } - ], - "$defs": { - "governedRequest": { - "description": "The same closed governed request shape accepted by project test --live.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the same governed request boundary as a live Notary evaluation.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } - }, - "attributes": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } - } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } - } - }, - "request": { - "description": "Exact HTTP request shape the adapter must produce.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "query": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 64, - "additionalProperties": { - "x-registry-field": "redacted_fixture_map_value", - "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, - { - "x-registry-field": "branch", - "type": "array", - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } - } - ] - } - }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - "response": { - "description": "Synthetic HTTP response or timeout returned to the adapter.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, - "headers": { - "x-registry-field": "redacted_fixture_property", - "type": "object", - "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } - }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["timeout"], - "properties": { - "timeout": { - "x-registry-field": "redacted_fixture_property", - "description": "Reviewed synthetic timeout intent no greater than 20 seconds; offline execution returns a timeout outcome without sleeping.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - ] - }, - "fixtureBody": { - "description": "Inline JSON, or an exact closed reference to a strict JSON file below the fixture bodies directory. The top-level file key is reserved for file references.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": { - "x-registry-field": "redacted_fixture_property", - "type": "string", - "minLength": 8, - "maxLength": 4096, - "pattern": "^bodies/(?!\\.\\.?/)(?!.*(?:/)\\.\\.?/)(?!.*//).+$" - } - } - }, - { - "x-registry-field": "branch", - "not": { - "x-registry-field": "branch", - "type": "object", - "required": ["file"] - } - } - ] - }, - "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } - } - } - } -} diff --git a/projects/sro-social/.registry-stack-editor/schemas/integration.schema.json b/projects/sro-social/.registry-stack-editor/schemas/integration.schema.json deleted file mode 100644 index 9bb2ad0..0000000 --- a/projects/sro-social/.registry-stack-editor/schemas/integration.schema.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/integration.v1.json", - "title": "Registry Stack project integration v1", - "description": "Defines a product-neutral source adaptation contract using bounded HTTP, Rhai script, or exact snapshot capabilities.", - "type": "object", - "additionalProperties": false, - "required": ["version", "id", "revision", "input", "capability", "outputs"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Integration authoring format version." }, - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Stable project-local identifier for the integration." }, - "revision": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295, "description": "Monotonically increasing revision of this integration contract.", "examples": [1] }, - "source": {"x-registry-field": "property", "$ref": "#/$defs/source", "description": "Optional source product metadata and transport policy; capabilities remain product-neutral." }, - "input": { - "x-registry-field": "property", - "description": "Typed selector and parameter inputs accepted by this integration.", - "type": "object", - "minProperties": 1, - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/input" } - }, - "capability": {"x-registry-field": "property", "$ref": "#/$defs/capability", "description": "Exactly one bounded execution mechanism for the source adaptation." }, - "outputs": { - "x-registry-field": "property", - "description": "Named typed outputs, or a shorthand list of output names inferred by the authoring compiler.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": { - "x-registry-field": "map_key", - "allOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputName" }, - {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "enum": ["matched", "outcome"] } } - ] - }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/output" } - }, - { - "x-registry-field": "branch", - "type": "array", - "minItems": 1, - "maxItems": 64, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/inputName" } - } - ] - }, - "limits": {"x-registry-field": "property", "$ref": "#/$defs/limits", "description": "Optional tighter resource limits for one integration execution." }, - "not_applicable": {"x-registry-field": "property", "$ref": "#/$defs/notApplicable", "description": "Explicit rationale and request-fixture evidence for a normally required outcome that the source contract cannot produce." } - }, - "examples": [ - { - "version": 1, - "id": "household_lookup", - "revision": 1, - "input": { - "household_id": { "role": "selector", "type": "string" } - }, - "capability": { - "script": { "file": "adapter.rhai" } - }, - "outputs": { - "active": { "type": "boolean" } - } - } - ], - "$defs": { - "notApplicable": { - "description": "Closed declarations for outcomes that the source protocol, exact singleton endpoint, or local unique-key constraint proves impossible.", - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ambiguity": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed source contract cannot produce more than one matching record." }, - "subject_mismatch": {"x-registry-field": "property", "$ref": "#/$defs/notApplicableReason", "description": "Why the reviewed response contract contains no identifier comparable with a requested selector." } - } - }, - "notApplicableReason": { - "description": "A reviewable reason that names a real request fixture proving the source access shape used by the rationale.", - "type": "object", - "additionalProperties": false, - "required": ["rationale", "request_fixture"], - "properties": { - "rationale": {"x-registry-field": "property", "type": "string", "minLength": 24, "maxLength": 512, "description": "Why the named normally required outcome cannot occur for this source contract." }, - "request_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Fixture name containing the supporting bounded source request." } - } - }, - "stableId": { - "description": "Lowercase stable identifier used in project references.", - "type": "string", - "pattern": "^[a-z][a-z0-9._-]{0,95}$" - }, - "inputName": { - "description": "Portable lowercase name for an integration input or output.", - "type": "string", - "pattern": "^[a-z][a-z0-9_]{0,63}$" - }, - "relativePath": { - "description": "Project-relative path without traversal or duplicate separators.", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" - }, - "integrationResponseByteSize": { - "description": "Maximum bytes accepted from one source response, defaulting to 512 KiB and capped at 8 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "512KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 8388608 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|[1-7][0-9]{3}|80[0-9]{2}|81[0-8][0-9]|819[0-2])KiB|[1-8]MiB)$" } - ] - }, - "integrationRequestByteSize": { - "description": "Maximum aggregate author-controlled request bytes for one execution, defaulting to 64 KiB and capped at 1 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "64KiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 1048576 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-4])KiB|1MiB)$" } - ] - }, - "integrationSourceByteSize": { - "description": "Maximum aggregate source-response bytes for one execution, defaulting to 2 MiB and capped at 16 MiB. Use a positive byte integer or canonical KiB/MiB value.", - "default": "2MiB", - "oneOf": [ - {"x-registry-field": "branch", "type": "integer", "minimum": 1, "maximum": 16777216 }, - {"x-registry-field": "branch", "type": "string", "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-5][0-9]{3}|16[0-2][0-9]{2}|163[0-7][0-9]|1638[0-4])KiB|(?:[1-9]|1[0-6])MiB)$" } - ] - }, - "scalarType": { - "description": "Supported scalar type, optionally paired with null for nullable values.", - "oneOf": [ - {"x-registry-field": "branch", "enum": ["string", "boolean", "integer"] }, - { - "x-registry-field": "branch", - "type": "array", - "prefixItems": [ - {"x-registry-field": "array_item", "enum": ["string", "boolean", "integer"] }, - {"x-registry-field": "array_item", "const": "null" } - ], - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - } - ] - }, - "input": { - "description": "Type, validation, and canonicalization rules for one authored input.", - "type": "object", - "additionalProperties": false, - "required": ["role", "type"], - "properties": { - "role": {"x-registry-field": "property", "enum": ["selector", "parameter"] }, - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "description": "Maximum Unicode scalar-value length. The 1024-character ceiling derives a conservative 4096-byte UTF-8 ceiling for one canonical input.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "minLength": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 16384 }, - "pattern": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "canonicalization": {"x-registry-field": "property", "enum": ["identity", "ascii_lowercase"] } - } - }, - "versions": { - "description": "Source versions tested by the project and versions explicitly accepted as unverified.", - "type": "object", - "additionalProperties": false, - "properties": { - "tested": {"x-registry-field": "property", "$ref": "#/$defs/versionList" }, - "unverified": {"x-registry-field": "property", "$ref": "#/$defs/versionList" } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["tested"], "properties": { "tested": {"x-registry-field": "property", "minItems": 1 } } }, - {"x-registry-field": "branch", "required": ["unverified"], "properties": { "unverified": {"x-registry-field": "property", "minItems": 1 } } } - ] - }, - "versionList": { - "description": "Bounded list of source version labels.", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } - }, - "credential": { - "description": "Authentication mechanism expected by the source adapter, without environment secret values.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "enum": ["none", "basic", "static_bearer"] } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_header" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "name", "max_value_bytes"], - "properties": { - "type": {"x-registry-field": "property", "const": "api_key_query" }, - "name": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9._:~-]{0,95}$" }, - "max_value_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4096 } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["type", "request", "response_profile"], - "properties": { - "type": {"x-registry-field": "property", "const": "oauth2_client_credentials" }, - "request": {"x-registry-field": "property", "enum": ["form", "json"] }, - "response_profile": {"x-registry-field": "property", "const": "oauth2_bearer" }, - "scope": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 1024 }, - "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "refresh_skew": { - "x-registry-field": "property", - "description": "Positive OAuth refresh interval below the generated token contract's 60-second minimum lifetime.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4})ms|(?:[1-9]|[1-5][0-9])s)$" - } - } - } - ] - }, - "allowRule": { - "description": "One read-only upstream method and path pattern the adapter may call.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": { - "x-registry-field": "sensitive_property", - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^/[^?#]*$" - }, - "semantics": {"x-registry-field": "property", "const": "read_only" } - } - }, - "source": { - "description": "Optional source metadata, authentication shape, allowlist, and protocol profile.", - "type": "object", - "additionalProperties": false, - "required": ["auth"], - "properties": { - "product": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "versions": {"x-registry-field": "property", "$ref": "#/$defs/versions" }, - "auth": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "allow": { - "x-registry-field": "property", - "type": "array", - "minItems": 1, - "maxItems": 16, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/allowRule" } - }, - "request_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response_headers": { - "x-registry-field": "property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" } - }, - "response": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "format": {"x-registry-field": "property", "enum": ["json", "text"] }, - "max_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationResponseByteSize" } - } - }, - "protocol": {"x-registry-field": "property", "$ref": "#/$defs/protocol" } - } - }, - "protocol": { - "description": "Optional interoperable protocol profiles layered over the source transport.", - "type": "object", - "additionalProperties": false, - "properties": { - "signed_dci": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["profile", "path", "jwks_profile", "sender", "receiver", "registry_type", "record_type", "locale", "selectors"], - "properties": { - "profile": {"x-registry-field": "property", "const": "dci-search-v1" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^/[^?#]*$" }, - "jwks_profile": {"x-registry-field": "property", "const": "rsa-signing-jwks-v1" }, - "sender": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "receiver": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, - "registry_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "record_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 128 }, - "locale": {"x-registry-field": "property", "type": "string", "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" }, - "selectors": { - "x-registry-field": "property", - "type": "object", "minProperties": 1, "maxProperties": 8, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/inputName" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["field", "response_pointer"], - "properties": { - "field": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 160 }, - "response_pointer": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" } - } - } - } - } - } - }, - "minProperties": 1 - }, - "inputReference": { - "description": "Reference to a declared integration input.", - "type": "object", - "additionalProperties": false, - "required": ["input"], - "properties": { "input": {"x-registry-field": "property", "$ref": "#/$defs/inputName" } } - }, - "inputOrLiteral": { - "description": "Request value supplied from a declared input or an authored scalar literal.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/inputReference" }, - {"x-registry-field": "branch", "type": ["string", "boolean", "integer"] } - ] - }, - "httpRequest": { - "description": "Bounded read-only HTTP request template.", - "type": "object", - "additionalProperties": false, - "required": ["method", "path"], - "properties": { - "method": {"x-registry-field": "property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, - "semantics": {"x-registry-field": "property", "const": "read_only" }, - "query": {"x-registry-field": "property", "type": "object", "maxProperties": 64, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "headers": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputOrLiteral" } }, - "body": { "x-registry-field": "property" } - } - }, - "httpResponse": { - "description": "Rules for translating upstream status and body cardinality into normalized outcomes.", - "type": "object", - "additionalProperties": false, - "properties": { - "no_match": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "ambiguous": { - "x-registry-field": "property", - "type": "array", - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "type": "integer", "minimum": 100, "maximum": 599 } - }, - "shape": { - "x-registry-field": "property", - "oneOf": [ - {"x-registry-field": "branch", "const": "singleton" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["records", "cardinality"], - "properties": { - "records": {"x-registry-field": "property", "type": "string", "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" }, - "cardinality": {"x-registry-field": "property", "const": "probe_two" } - } - } - ] - } - } - }, - "capability": { - "description": "Closed choice among direct HTTP, Rhai script, and exact snapshot execution.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["http"], - "properties": { - "http": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["request"], - "properties": { - "request": {"x-registry-field": "property", "$ref": "#/$defs/httpRequest" }, - "response": {"x-registry-field": "property", "$ref": "#/$defs/httpResponse" } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["script"], - "properties": { - "script": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { - "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, - "modules": { - "x-registry-field": "sensitive_property", - "type": "array", - "maxItems": 32, - "uniqueItems": true, - "items": {"x-registry-field": "array_item", "$ref": "#/$defs/relativePath" } - } - } - } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["snapshot"], - "properties": { - "snapshot": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["entity", "exact", "freshness"], - "properties": { - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "exact": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 8, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/inputReference" } - }, - "freshness": { - "x-registry-field": "property", - "type": "string", - "description": "Maximum accepted snapshot age as a canonical positive duration no greater than 31 days.", - "pattern": "^(?:(?:[1-9][0-9]{0,8}|1[0-9]{9}|2[0-5][0-9]{8}|26[0-6][0-9]{7}|267[0-7][0-9]{6}|2678[0-3][0-9]{5}|2678400000)ms|(?:[1-9][0-9]{0,5}|1[0-9]{6}|2[0-5][0-9]{5}|26[0-6][0-9]{4}|267[0-7][0-9]{3}|2678[0-3][0-9]{2}|2678400)s|(?:[1-9][0-9]{0,3}|[1-3][0-9]{4}|4[0-3][0-9]{3}|44[0-5][0-9]{2}|446[0-3][0-9]{1}|44640)m|(?:[1-9][0-9]{0,1}|[1-6][0-9]{2}|7[0-3][0-9]{1}|74[0-3]|744)h|(?:[1-9][0-9]{0,0}|[1-2][0-9]{1}|30|31)d)$" - } - } - } - } - } - ] - }, - "output": { - "description": "Type and optional source pointer for one normalized integration output.", - "type": "object", - "additionalProperties": false, - "required": ["type"], - "properties": { - "type": {"x-registry-field": "property", "$ref": "#/$defs/scalarType" }, - "format": {"x-registry-field": "property", "const": "date" }, - "maxLength": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16384 }, - "minimum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "maximum": {"x-registry-field": "property", "type": "integer", "minimum": -9223372036854775808, "maximum": 9223372036854775807 }, - "x-registry-source": { - "x-registry-field": "property", - "type": "string", - "pattern": "^/(?:[^/~]|~[01])+(?:/(?:[^/~]|~[01])+)*$" - } - } - }, - "limits": { - "description": "Per-execution call, byte, and deadline budgets.", - "type": "object", - "additionalProperties": false, - "properties": { - "calls": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 16 }, - "request_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationRequestByteSize" }, - "source_bytes": {"x-registry-field": "property", "$ref": "#/$defs/integrationSourceByteSize" }, - "deadline": { - "x-registry-field": "property", - "description": "Positive total integration deadline no greater than 20 seconds.", - "type": "string", - "pattern": "^(?:(?:[1-9][0-9]{0,3}|1[0-9]{4}|20000)ms|(?:[1-9]|1[0-9]|20)s)$" - } - } - } - } -} diff --git a/projects/sro-social/.registry-stack-editor/schemas/project.schema.json b/projects/sro-social/.registry-stack-editor/schemas/project.schema.json deleted file mode 100644 index 91b7ea7..0000000 --- a/projects/sro-social/.registry-stack-editor/schemas/project.schema.json +++ /dev/null @@ -1,537 +0,0 @@ -{ - "x-registry-field": "root", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", - "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", - "type": "object", - "additionalProperties": false, - "required": ["version", "registry", "services"], - "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, - "starter": { - "x-registry-field": "property", - "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", - "type": "object", - "additionalProperties": false, - "required": ["id", "release", "content_digest"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } - } - }, - "registry": { - "x-registry-field": "property", - "description": "Stable identity of the Registry Stack project.", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } - }, - "integrations": { - "x-registry-field": "property", - "description": "Source adaptation definitions keyed by project-local integration identifier.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "entities": { - "x-registry-field": "property", - "description": "Materialized entity definitions keyed by project-local entity identifier.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } - } - }, - "services": { - "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } - } - }, - "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } - ], - "examples": [ - { - "version": 1, - "registry": { "id": "benefits-registry" }, - "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } - }, - "services": {} - } - ], - "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, - "recordsApi": { - "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", - "type": "object", - "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], - "properties": { - "scopes": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["metadata", "rows"], - "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } - } - }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "pagination": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["default_limit", "max_limit"], - "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } - } - }, - "filters": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } - }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "relationships": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - } - }, - "aggregates": { - "x-registry-field": "property", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } - }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "standards": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], - "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } - } - } - } - }, - "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } - }, - "recordAttributeReleaseProfile": { - "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", - "type": "object", - "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], - "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "subject": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["source_field", "id_type"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } - } - }, - "release_conditions": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } - }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["required", "sensitivity"], - "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } - }, - "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } - ] - } - } - } - }, - "recordAttributeReleaseExpression": { - "description": "Bounded CEL evaluated only against the projected source object.", - "type": "object", - "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } - }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, - "recordAggregate": { - "description": "Governed aggregate definition with disclosure controls.", - "type": "object", - "additionalProperties": false, - "required": ["description", "disclosure_control"], - "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, - "disclosure_control": { - "x-registry-field": "property", - "type": "object", - "additionalProperties": false, - "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } - } - } - }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] - }, - "recordAggregateAccess": { - "description": "Authorization scopes and execution policy for one governed aggregate.", - "type": "object", - "additionalProperties": false, - "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } - } - }, - "recordAggregateSpatial": { - "description": "Administrative-area geometry join used to spatially group aggregate results.", - "type": "object", - "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], - "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatial": { - "description": "OGC API Features collection metadata and bounded geometry mapping.", - "type": "object", - "additionalProperties": false, - "required": ["geometry"], - "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } - } - }, - "recordSpatialGeometry": { - "description": "Supported point or encoded-geometry field mapping for spatial records.", - "oneOf": [ - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["kind", "field", "crs"], - "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } - } - } - ] - }, - "recordSpatialBbox": { - "description": "Record fields carrying the four coordinates of a bounding box.", - "type": "object", - "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], - "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordSpdci": { - "description": "SP-DCI registry identity and field mappings for a records service.", - "type": "object", - "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], - "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } - } - }, - "recordFieldMap": { - "description": "Bounded mapping from standard field names to entity field identifiers.", - "type": "object", - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } - }, - "recordAggregateDimension": { - "description": "Named grouping dimension backed by one entity field.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "field"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateIndicator": { - "description": "SDMX-style aggregate indicator with units and display metadata.", - "type": "object", - "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], - "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } - } - }, - "recordAggregateMeasure": { - "description": "Named aggregate calculation over one entity column.", - "type": "object", - "additionalProperties": false, - "required": ["name", "function", "column"], - "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "recordAggregateFunction": { - "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] - }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } - } - ] - }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, - "recordsService": { - "description": "Relay records API backed by one authored materialized entity.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "entity", "api"], - "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Source-free claims remain evaluation-only and cannot be selected by a credential profile.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { - "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { "output": {"x-registry-field": "property", "type": "string" }, "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } - }, - "credential_profiles": { - "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation. Source-free claim evaluation cannot be exposed as credential capability.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } - } - } - } - }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, - "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } - }, - "consultations": { - "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/targetRequestMapping" } } } } - }, - "targetRequestMapping": { - "description": "Closed target request binding. Attribute values are bounded typed caller-supplied context, not authenticated identifiers.", - "type": "string", - "maxLength": 128, - "pattern": "^request\\.target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})$" - }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for source-free evaluation. A source-free claim cannot be selected by a credential profile.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } - } - } -} diff --git a/projects/sro-social/.vscode/extensions.json b/projects/sro-social/.vscode/extensions.json deleted file mode 100644 index 6098b6e..0000000 --- a/projects/sro-social/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "redhat.vscode-yaml" - ] -} diff --git a/projects/sro-social/.vscode/settings.json b/projects/sro-social/.vscode/settings.json deleted file mode 100644 index db92a93..0000000 --- a/projects/sro-social/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "yaml.schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } -} diff --git a/projects/sro-social/.zed/settings.json b/projects/sro-social/.zed/settings.json deleted file mode 100644 index fac07ac..0000000 --- a/projects/sro-social/.zed/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "./.registry-stack-editor/schemas/entity.schema.json": "entities/*.yaml", - "./.registry-stack-editor/schemas/environment.schema.json": "environments/*.yaml", - "./.registry-stack-editor/schemas/fixture.schema.json": "integrations/*/fixtures/*.yaml", - "./.registry-stack-editor/schemas/integration.schema.json": "integrations/*/integration.yaml", - "./.registry-stack-editor/schemas/project.schema.json": "registry-stack.yaml" - } - } - } - } - } -} diff --git a/projects/sro-social/entities/sro_child.yaml b/projects/sro-social/entities/sro_child.yaml deleted file mode 100644 index bd5effa..0000000 --- a/projects/sro-social/entities/sro_child.yaml +++ /dev/null @@ -1,23 +0,0 @@ -version: 1 -id: sro_child -revision: 1 -primary_key: uin -schema: - type: object - additionalProperties: false - required: [uin, poverty_band] - properties: - uin: - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - poverty_band: - type: string - enum: [priority, standard, not_eligible] - maxLength: 16 -materialization: - max_records: 1000000 - max_bytes: 64MiB - refresh: 1m - retain_generations: 2 diff --git a/projects/sro-social/environments/hosted.yaml b/projects/sro-social/environments/hosted.yaml deleted file mode 100644 index b2888ea..0000000 --- a/projects/sro-social/environments/hosted.yaml +++ /dev/null @@ -1,42 +0,0 @@ -version: 1 - -entities: - sro_child: - provider: - type: csv - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - header_row: 1 - columns: - uin: uin - poverty_band: poverty_band - source_revision: solmara-sro-child-benefit-projection-v1 - generation: "2026-07-14" - -callers: - child-benefit-federator: - api_key_fingerprint: { secret: SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["sro_social:evidence_verification"] - -relay: - origin: https://sro-relay.solmara.registrystack.org - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json - audience: registry-relay - allowed_clients: [sro-notary] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: sro-notary - token_file: /run/secrets/sro-notary-relay-token - -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt -deployment: - profile: hosted_lab - relay: { service: sro-social-relay } - notary: { service: sro-notary } diff --git a/projects/sro-social/environments/local.yaml b/projects/sro-social/environments/local.yaml deleted file mode 100644 index 2e494bd..0000000 --- a/projects/sro-social/environments/local.yaml +++ /dev/null @@ -1,48 +0,0 @@ -version: 1 - -entities: - sro_child: - provider: - type: csv - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - header_row: 1 - columns: - uin: uin - poverty_band: poverty_band - source_revision: solmara-sro-child-benefit-projection-v1 - generation: "2026-07-14" - -callers: - child-benefit-federator: - api_key_fingerprint: { secret: SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH } - scopes: ["sro_social:evidence_verification"] - -relay: - origin: http://127.0.0.1:8080 - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json - audience: registry-relay - allowed_clients: [sro-notary] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: sro-notary - token_file: /run/secrets/sro-notary-relay-token - -relay_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -notary_state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - -# Emulated local images require Registry Stack's bounded 1 GiB CEL worker ceiling. -# Hosted deployments intentionally retain the 128 MiB default. -notary_cel: - worker_memory_bytes: 1073741824 - -deployment: - profile: local - relay: { service: sro-social-relay } - notary: { service: sro-notary } diff --git a/projects/sro-social/integrations/child-benefit-household-by-uin/fixtures/match.yaml b/projects/sro-social/integrations/child-benefit-household-by-uin/fixtures/match.yaml deleted file mode 100644 index ad3f15f..0000000 --- a/projects/sro-social/integrations/child-benefit-household-by-uin/fixtures/match.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: child-benefit-household-match -classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: solmara_uin, value: "2300010248" }] - claims: [household-below-poverty-threshold] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review -input: { uin: "2300010248" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: { poverty_band: priority } } -expect: - outcome: match - outputs: { poverty_band: priority } - claims: { household-below-poverty-threshold: true } diff --git a/projects/sro-social/integrations/child-benefit-household-by-uin/fixtures/no-match.yaml b/projects/sro-social/integrations/child-benefit-household-by-uin/fixtures/no-match.yaml deleted file mode 100644 index 6092d49..0000000 --- a/projects/sro-social/integrations/child-benefit-household-by-uin/fixtures/no-match.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: child-benefit-household-no-match -classification: synthetic -input: { uin: "2300999997" } -interactions: - - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: [] } -expect: - outcome: no_match - outputs: {} - claims: { household-below-poverty-threshold: false } diff --git a/projects/sro-social/integrations/child-benefit-household-by-uin/integration.yaml b/projects/sro-social/integrations/child-benefit-household-by-uin/integration.yaml deleted file mode 100644 index 60a9c0e..0000000 --- a/projects/sro-social/integrations/child-benefit-household-by-uin/integration.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -id: child-benefit-household-by-uin -revision: 1 - -input: - uin: - role: selector - type: string - minLength: 10 - maxLength: 10 - pattern: "^[0-9]{10}$" - -capability: - snapshot: - entity: sro_child - exact: - uin: { input: uin } - freshness: 31d - -outputs: [poverty_band] - -not_applicable: - ambiguity: - rationale: The exact UIN selector is the entity primary key, whose materialized unique-key constraint permits at most one active household projection. - request_fixture: child-benefit-household-match - subject_mismatch: - rationale: The minimized output projection omits UIN, so it contains no identifier comparable with the requested UIN. - request_fixture: child-benefit-household-match diff --git a/projects/sro-social/registry-stack.yaml b/projects/sro-social/registry-stack.yaml deleted file mode 100644 index 7e7974b..0000000 --- a/projects/sro-social/registry-stack.yaml +++ /dev/null @@ -1,33 +0,0 @@ -version: 1 - -registry: - id: solmara-sro-social - -integrations: - child-benefit-household-by-uin: - file: integrations/child-benefit-household-by-uin/integration.yaml - -entities: - sro_child: - file: entities/sro_child.yaml - -services: - child-benefit: - kind: evidence - version: 1 - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - legal_basis: child-benefit-act - consent: not_required - access: - scopes: ["sro_social:evidence_verification"] - consultations: - household: - integration: child-benefit-household-by-uin - input: - uin: request.target.identifiers.solmara_uin - claims: - household-below-poverty-threshold: - cel: 'household.matched && household.poverty_band == "priority"' - disclosure: - default: predicate - allowed: [predicate, redacted] diff --git a/relays/cra/codelists/record-lifecycle.yaml b/relays/cra/codelists/record-lifecycle.yaml new file mode 100644 index 0000000..cb66469 --- /dev/null +++ b/relays/cra/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: cra-record-lifecycle +version: "1" +status: reviewed +values: [active, deceased] diff --git a/relays/cra/expected-http.yaml b/relays/cra/expected-http.yaml new file mode 100644 index 0000000..ec3391b --- /dev/null +++ b/relays/cra/expected-http.yaml @@ -0,0 +1,42 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: https://id.registrystack.org/solmara/registry/cra +authorizations: + death-ok: {principal: cra-pension-evidence, scopes: [solmara:relay:cra:death-by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review"}} + death-wrong-scope: {principal: cra-pension-evidence, scopes: [solmara:relay:cra:citizen-link-by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review"}} + death-wrong-purpose: {principal: cra-pension-evidence, scopes: [solmara:relay:cra:death-by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/citizen-self-service"}} + citizen-ok: {principal: cra-citizen-evidence, scopes: [solmara:relay:cra:citizen-link-by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/citizen-self-service"}} + citizen-wrong-scope: {principal: cra-citizen-evidence, scopes: [solmara:relay:cra:death-by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/citizen-self-service"}} + citizen-wrong-purpose: {principal: cra-citizen-evidence, scopes: [solmara:relay:cra:citizen-link-by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review"}} +steps: + - {id: ready, request: {method: GET, path: /ready}, expect: {status: 200}} + - id: death-success + authorizationFixture: death-ok + request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: "2300109568"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [deceased], domainDataValues: {deceased: true}, absentEverywhere: [birthDateInternal, birthRegistrationNumberInternal, birth_date, birth_brn, linkedUin]} + - id: death-living-success + authorizationFixture: death-ok + request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [deceased], domainDataValues: {deceased: false}, absentEverywhere: [birthDateInternal, birthRegistrationNumberInternal, birth_date, birth_brn, linkedUin]} + - id: death-fields-minimum + authorizationFixture: death-ok + request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, query: {fields: deceased}, body: {uin: "2300109568"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [deceased], absentEverywhere: [linkedUin, birthDateInternal, birthRegistrationNumberInternal]} + - {id: death-wrong-scope, authorizationFixture: death-wrong-scope, request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: "2300109568"}}, expect: {status: 404, code: resource.not_found}} + - {id: death-wrong-purpose, authorizationFixture: death-wrong-purpose, request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: "2300109568"}}, expect: {status: 403, code: consultation.denied}} + - {id: death-malformed-selector, authorizationFixture: death-ok, request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: short}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: death-no-match, authorizationFixture: death-ok, request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: "2399999999"}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: death-invalid-row, authorizationFixture: death-ok, request: {method: POST, path: /v2/resources/civil-person/lookups/death-by-uin, body: {uin: "2300999999"}}, expect: {status: 503, code: source.unavailable}} + - id: citizen-success + authorizationFixture: citizen-ok + request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [linkedUin], domainDataValues: {linkedUin: "2300010248"}, absentEverywhere: [deceased, birthDateInternal, birthRegistrationNumberInternal, birth_date, birth_brn]} + - id: citizen-fields-minimum + authorizationFixture: citizen-ok + request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, query: {fields: linkedUin}, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [linkedUin], absentEverywhere: [deceased, birthDateInternal, birthRegistrationNumberInternal]} + - {id: citizen-wrong-scope, authorizationFixture: citizen-wrong-scope, request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, body: {uin: "2300010248"}}, expect: {status: 404, code: resource.not_found}} + - {id: citizen-wrong-purpose, authorizationFixture: citizen-wrong-purpose, request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, body: {uin: "2300010248"}}, expect: {status: 403, code: consultation.denied}} + - {id: citizen-malformed-selector, authorizationFixture: citizen-ok, request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, body: {uin: short}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: citizen-no-match, authorizationFixture: citizen-ok, request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, body: {uin: "2399999999"}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: citizen-invalid-row, authorizationFixture: citizen-ok, request: {method: POST, path: /v2/resources/civil-person/lookups/citizen-link-by-uin, body: {uin: "2300999999"}}, expect: {status: 503, code: source.unavailable}} + - {id: no-list-route, authorizationFixture: citizen-ok, request: {method: GET, path: /v2/resources/civil-person/records}, expect: {status: 404, routeAbsent: true}} diff --git a/relays/cra/fixture.sql b/relays/cra/fixture.sql new file mode 100644 index 0000000..a5f608c --- /dev/null +++ b/relays/cra/fixture.sql @@ -0,0 +1,25 @@ +PRAGMA user_version = 1; +CREATE TABLE civil_person_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + birth_date TEXT NOT NULL, + birth_brn TEXT, + deceased INTEGER NOT NULL CHECK (deceased IN (0, 1)) + ) STRICT; +CREATE VIEW relay_civil_person AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, birth_date, birth_brn, deceased + FROM civil_person_source; +INSERT INTO civil_person_source VALUES +('CP-DEAD','rev-1','deceased','2026-07-04T09:00:00Z','2300109568','1944-02-01','BRN-1944-0301-00012',1), +('CP-LIVE','rev-2','active','2026-07-04T09:00:00Z','2300010248','2022-03-14','BRN-2022-0101-00001',0), +('CP-BAD','rev-3','deceased','invalid-date-time','2300999999','1950-01-01',NULL,1); +PRAGMA optimize; +-- relayctl's bundled SQLite enables STAT4 while the live Python publication does not. +-- Remove only that fixture-only schema entry so the fixture proves the published fingerprint. +PRAGMA writable_schema = ON; +DELETE FROM sqlite_schema WHERE name = 'sqlite_stat4'; +PRAGMA writable_schema = OFF; diff --git a/relays/cra/governance/classification-review-rationale.md b/relays/cra/governance/classification-review-rationale.md new file mode 100644 index 0000000..a6e562b --- /dev/null +++ b/relays/cra/governance/classification-review-rationale.md @@ -0,0 +1 @@ +CRA reviewed the inventory against the two exact-lookup disclosures. Death release contains only the current boolean deceased status for the exact queried UIN, including `false` where the person is living. Citizen link contains only the queried identifier as an existence-safe linked fact. Birth date, birth registration number, and unrelated civil data are not properties in either disclosure. diff --git a/relays/cra/governance/classification-review.yaml b/relays/cra/governance/classification-review.yaml new file mode 100644 index 0000000..3e66359 --- /dev/null +++ b/relays/cra/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: https://id.registrystack.org/solmara/registry/cra +classificationInventoryDigest: sha256:e58edad27369a9f4607a73275a02dda59b090ad3df72ff97ff7ec7237b1b7adc +method: manual +reviewer: did:web:id.registrystack.org:solmara:authority:cra +reviewDate: 2026-08-12 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/relays/cra/governance/identifier-lifecycle.yaml b/relays/cra/governance/identifier-lifecycle.yaml new file mode 100644 index 0000000..66a2f3f --- /dev/null +++ b/relays/cra/governance/identifier-lifecycle.yaml @@ -0,0 +1,2 @@ +status: reviewed +policy: CRA record identifiers are stable across revisions and never reassigned after retirement. diff --git a/relays/cra/governance/legal-basis.yaml b/relays/cra/governance/legal-basis.yaml new file mode 100644 index 0000000..849c7e9 --- /dev/null +++ b/relays/cra/governance/legal-basis.yaml @@ -0,0 +1,2 @@ +status: reviewed +legalBasis: Civil registration and pension administration laws authorize the two purpose-bound consultations. diff --git a/relays/cra/governance/processing.dpv.yaml b/relays/cra/governance/processing.dpv.yaml new file mode 100644 index 0000000..a7d3fa0 --- /dev/null +++ b/relays/cra/governance/processing.dpv.yaml @@ -0,0 +1,2 @@ +status: reviewed +profile: https://w3id.org/dpv/2.3 diff --git a/relays/cra/registry.yaml b/relays/cra/registry.yaml new file mode 100644 index 0000000..e71e266 --- /dev/null +++ b/relays/cra/registry.yaml @@ -0,0 +1,86 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: solmara-cra-relay, version: "2026-08-12", title: CRA governed consultations} +registry: + registryIdentifier: https://id.registrystack.org/solmara/registry/cra + name: Civil Registration Authority Relay + authority: {identifier: "did:web:id.registrystack.org:solmara:authority:cra", name: Civil Registration Authority} + authoritativeScope: Current civil person link and death-registration facts held by CRA + baseUri: https://cra.gov.solmara.example/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: [{name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional}] +governance: + controller: "did:web:id.registrystack.org:solmara:authority:cra" + publisher: "did:web:id.registrystack.org:solmara:authority:cra" + auditOwner: "did:web:id.registrystack.org:solmara:authority:cra" +semantics: {localVocabulary: https://id.registrystack.org/solmara/vocabulary/cra/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://id.registrystack.org/solmara/classification/cra, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + cra: {kind: sqlite, profile: live-read-only, expectedSchemaFingerprint: "sha256:4aea7963a3de909459ac186f0f0630b7f7f2a6c8a53a6be67666e3422ed563fb"} +resources: + - id: civil-person + title: Civil person + description: Minimum CRA facts for exact, purpose-bound consultations. + semanticClass: local:CivilPerson + source: {source: cra, view: relay_civil_person} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: + record_id: {privacy: identifying} + record_revision: {privacy: non-personal} + lifecycle_state: {privacy: personal} + recorded_at: {privacy: personal-context} + uin: {privacy: identifying} + deceased: {privacy: sensitive-personal} + birth_date: {privacy: sensitive-personal} + birth_brn: {privacy: identifying} + recordContext: + recordIdentifier: {sourceColumn: record_id} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + deceased: + {sourceColumn: deceased, type: boolean, sourceRequired: true, semanticTerm: local:deceased, label: Deceased, description: Whether CRA records the person as deceased, classification: {privacy: sensitive-personal}} + linkedUin: + {sourceColumn: uin, type: string, sourceRequired: true, semanticTerm: local:linkedUin, label: Linked UIN, description: The queried UIN exists in the CRA civil-person register, classification: {privacy: identifying}} + birthDateInternal: + {sourceColumn: birth_date, type: date, sourceRequired: true, semanticTerm: local:birthDateInternal, label: Internal birth date, description: Accounted source column excluded from every disclosure profile, classification: {privacy: sensitive-personal}} + birthRegistrationNumberInternal: + {sourceColumn: birth_brn, type: string, sourceRequired: false, semanticTerm: local:birthRegistrationNumberInternal, label: Internal birth registration number, description: Accounted source column excluded from every disclosure profile, classification: {privacy: identifying}} + disclosureProfiles: + death-fact: {properties: [deceased]} + citizen-link: {properties: [linkedUin]} + operations: + lookups: + - id: death-by-uin + requestBody: + maximumBytes: 128 + selectors: + uin: {sourceColumn: uin, type: string, minimumBytes: 10, maximumBytes: 10} + defaultAccessProfile: death-evidence + accessProfiles: + death-evidence: + access: + scope: solmara:relay:cra:death-by-uin + purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/pension-payment-review"]} + disclosureProfile: death-fact + - id: citizen-link-by-uin + requestBody: + maximumBytes: 96 + selectors: + uin: {sourceColumn: uin, type: string, minimumBytes: 10, maximumBytes: 10} + defaultAccessProfile: citizen-evidence + accessProfiles: + citizen-evidence: + access: + scope: solmara:relay:cra:citizen-link-by-uin + purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/citizen-self-service"]} + disclosureProfile: citizen-link + processingDescriptions: + - {id: death-consultation, operationRefs: [lookup:death-by-uin], purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, boolean-status-disclosure, minimum-disclosure, value-free-audit]} + - {id: citizen-link-consultation, operationRefs: [lookup:citizen-link-by-uin], purpose: "https://id.registrystack.org/solmara/purpose/citizen-self-service", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, minimum-disclosure, value-free-audit]} +metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} diff --git a/relays/cra/runtime.yaml b/relays/cra/runtime.yaml new file mode 100644 index 0000000..1424629 --- /dev/null +++ b/relays/cra/runtime.yaml @@ -0,0 +1,15 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "0.0.0.0:8080"} +packagePath: /etc/relay/cra/package +sources: {cra: {path: /var/lib/relay/source/cra.sqlite}} +authentication: + issuer: + id: solmara-mint + discoveryUrl: https://mint.solmara.registrystack.org/.well-known/openid-configuration + audience: solmara-runtime + tokenTypes: [at+jwt] + algorithms: [ES256] +audit: {sink: /var/lib/relay/audit/audit.jsonl, integrityKeyRef: secret:env/SOLMARA_RELAY_AUDIT_KEY} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} +quotas: {requestsPerMinute: 120, burst: 20} diff --git a/relays/mosd/codelists/record-lifecycle.yaml b/relays/mosd/codelists/record-lifecycle.yaml new file mode 100644 index 0000000..f7c9343 --- /dev/null +++ b/relays/mosd/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: mosd-record-lifecycle +version: "1" +status: reviewed +values: [active] diff --git a/relays/mosd/expected-http.yaml b/relays/mosd/expected-http.yaml new file mode 100644 index 0000000..c65d8ac --- /dev/null +++ b/relays/mosd/expected-http.yaml @@ -0,0 +1,21 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: https://id.registrystack.org/solmara/registry/mosd-programme-mis +authorizations: + ok: {principal: mosd-child-benefit-evidence, scopes: [solmara:relay:mosd:by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/child-benefit-review"}} + wrong-scope: {principal: mosd-child-benefit-evidence, scopes: [solmara:relay:mosd:other], claims: {purpose: "https://id.registrystack.org/solmara/purpose/child-benefit-review"}} + wrong-purpose: {principal: mosd-child-benefit-evidence, scopes: [solmara:relay:mosd:by-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/citizen-self-service"}} +steps: + - id: success + authorizationFixture: ok + request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [duplicateEnrolment], domainDataValues: {duplicateEnrolment: false}, absentEverywhere: [duplicate_flag]} + - id: fields-minimum + authorizationFixture: ok + request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, query: {fields: duplicateEnrolment}, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [duplicateEnrolment]} + - {id: wrong-scope, authorizationFixture: wrong-scope, request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, body: {uin: "2300010248"}}, expect: {status: 404, code: resource.not_found}} + - {id: wrong-purpose, authorizationFixture: wrong-purpose, request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, body: {uin: "2300010248"}}, expect: {status: 403, code: consultation.denied}} + - {id: malformed-selector, authorizationFixture: ok, request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, body: {uin: short}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: no-match, authorizationFixture: ok, request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, body: {uin: "2399999999"}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: invalid-row, authorizationFixture: ok, request: {method: POST, path: /v2/resources/beneficiary-enrolment/lookups/by-uin, body: {uin: "2300999999"}}, expect: {status: 503, code: source.unavailable}} + - {id: no-list-route, authorizationFixture: ok, request: {method: GET, path: /v2/resources/beneficiary-enrolment/records}, expect: {status: 404, routeAbsent: true}} diff --git a/relays/mosd/fixture.sql b/relays/mosd/fixture.sql new file mode 100644 index 0000000..6ef33de --- /dev/null +++ b/relays/mosd/fixture.sql @@ -0,0 +1,21 @@ +PRAGMA user_version = 1; +CREATE TABLE beneficiary_enrolment_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + duplicate_flag INTEGER NOT NULL CHECK (duplicate_flag IN (0, 1)) + ) STRICT; +CREATE VIEW relay_beneficiary_enrolment AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, duplicate_flag + FROM beneficiary_enrolment_source; +INSERT INTO beneficiary_enrolment_source VALUES +('MOSD-1','rev-1','active','2026-07-04T09:00:00Z','2300010248',0), +('MOSD-BAD','rev-2','active','invalid-date-time','2300999999',1); +PRAGMA optimize; +-- Keep the fixture schema identical to the non-STAT4 live Python publication. +PRAGMA writable_schema = ON; +DELETE FROM sqlite_schema WHERE name = 'sqlite_stat4'; +PRAGMA writable_schema = OFF; diff --git a/relays/mosd/governance/classification-review-rationale.md b/relays/mosd/governance/classification-review-rationale.md new file mode 100644 index 0000000..4b21a77 --- /dev/null +++ b/relays/mosd/governance/classification-review-rationale.md @@ -0,0 +1 @@ +MoSD reviewed the inventory. The exact lookup releases only the duplicate-enrolment predicate required by child-benefit evidence and never publishes the selector or unrelated programme information. diff --git a/relays/mosd/governance/classification-review.yaml b/relays/mosd/governance/classification-review.yaml new file mode 100644 index 0000000..5df9fd2 --- /dev/null +++ b/relays/mosd/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: https://id.registrystack.org/solmara/registry/mosd-programme-mis +classificationInventoryDigest: sha256:d29a17afa121721d11c569168dba9e4561c0ee802a10c2435076a1edba97b2e2 +method: manual +reviewer: did:web:id.registrystack.org:solmara:authority:mosd-programme-mis +reviewDate: 2026-08-12 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/relays/mosd/governance/identifier-lifecycle.yaml b/relays/mosd/governance/identifier-lifecycle.yaml new file mode 100644 index 0000000..8df1d6b --- /dev/null +++ b/relays/mosd/governance/identifier-lifecycle.yaml @@ -0,0 +1,2 @@ +status: reviewed +policy: MoSD enrolment identifiers are stable across revisions and never reassigned after retirement. diff --git a/relays/mosd/governance/legal-basis.yaml b/relays/mosd/governance/legal-basis.yaml new file mode 100644 index 0000000..13c3378 --- /dev/null +++ b/relays/mosd/governance/legal-basis.yaml @@ -0,0 +1,2 @@ +status: reviewed +legalBasis: Child-benefit administration law authorizes the duplicate-enrolment consultation. diff --git a/relays/mosd/governance/processing.dpv.yaml b/relays/mosd/governance/processing.dpv.yaml new file mode 100644 index 0000000..a7d3fa0 --- /dev/null +++ b/relays/mosd/governance/processing.dpv.yaml @@ -0,0 +1,2 @@ +status: reviewed +profile: https://w3id.org/dpv/2.3 diff --git a/relays/mosd/registry.yaml b/relays/mosd/registry.yaml new file mode 100644 index 0000000..5b30418 --- /dev/null +++ b/relays/mosd/registry.yaml @@ -0,0 +1,50 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: solmara-mosd-relay, version: "2026-08-12", title: MoSD beneficiary enrolment consultation} +registry: + registryIdentifier: https://id.registrystack.org/solmara/registry/mosd-programme-mis + name: Ministry of Social Development Programme MIS Relay + authority: {identifier: "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", name: Ministry of Social Development Programme MIS} + authoritativeScope: Current beneficiary enrolment facts held by the MoSD Programme MIS + baseUri: https://mosd.gov.solmara.example/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: [{name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional}] +governance: {controller: "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", publisher: "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis", auditOwner: "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis"} +semantics: {localVocabulary: https://id.registrystack.org/solmara/vocabulary/mosd/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://id.registrystack.org/solmara/classification/mosd, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + mosd: {kind: sqlite, profile: live-read-only, expectedSchemaFingerprint: "sha256:c7d2a7b176222fe8784fcb74adca48ffb3260afbcd0489f227621b446873e833"} +resources: + - id: beneficiary-enrolment + title: Beneficiary enrolment + description: Minimum duplicate-enrolment fact for child-benefit review. + semanticClass: local:BeneficiaryEnrolment + source: {source: mosd, view: relay_beneficiary_enrolment} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: {record_id: {privacy: identifying}, record_revision: {privacy: non-personal}, lifecycle_state: {privacy: personal}, recorded_at: {privacy: personal-context}, uin: {privacy: identifying}, duplicate_flag: {privacy: sensitive-personal}} + recordContext: + recordIdentifier: {sourceColumn: record_id} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + duplicateEnrolment: {sourceColumn: duplicate_flag, type: boolean, sourceRequired: true, semanticTerm: local:duplicateEnrolment, label: Duplicate enrolment, description: Whether the person has a duplicate programme enrolment, classification: {privacy: sensitive-personal}} + disclosureProfiles: {child-benefit-evidence: {properties: [duplicateEnrolment]}} + operations: + lookups: + - id: by-uin + requestBody: {maximumBytes: 96, selectors: {uin: {sourceColumn: uin, type: string, minimumBytes: 10, maximumBytes: 10}}} + defaultAccessProfile: child-benefit-evidence + accessProfiles: + child-benefit-evidence: + access: + scope: solmara:relay:mosd:by-uin + purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/child-benefit-review"]} + disclosureProfile: child-benefit-evidence + processingDescriptions: + - {id: child-benefit-review, operationRefs: [lookup:by-uin], purpose: "https://id.registrystack.org/solmara/purpose/child-benefit-review", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, minimum-disclosure, value-free-audit]} +metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} diff --git a/relays/mosd/runtime.yaml b/relays/mosd/runtime.yaml new file mode 100644 index 0000000..0ed85bd --- /dev/null +++ b/relays/mosd/runtime.yaml @@ -0,0 +1,10 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "0.0.0.0:8080"} +packagePath: /etc/relay/mosd/package +sources: {mosd: {path: /var/lib/relay/source/mosd.sqlite}} +authentication: + issuer: {id: solmara-mint, discoveryUrl: https://mint.solmara.registrystack.org/.well-known/openid-configuration, audience: solmara-runtime, tokenTypes: [at+jwt], algorithms: [ES256]} +audit: {sink: /var/lib/relay/audit/audit.jsonl, integrityKeyRef: secret:env/SOLMARA_RELAY_AUDIT_KEY} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} +quotas: {requestsPerMinute: 120, burst: 20} diff --git a/relays/nagdi/codelists/record-lifecycle.yaml b/relays/nagdi/codelists/record-lifecycle.yaml new file mode 100644 index 0000000..0f3540e --- /dev/null +++ b/relays/nagdi/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: nagdi-record-lifecycle +version: "1" +status: reviewed +values: [active] diff --git a/relays/nagdi/expected-http.yaml b/relays/nagdi/expected-http.yaml new file mode 100644 index 0000000..42bb1fc --- /dev/null +++ b/relays/nagdi/expected-http.yaml @@ -0,0 +1,33 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: https://id.registrystack.org/solmara/registry/nagdi +authorizations: + voucher-ok: {principal: nagdi-voucher-evidence, scopes: [solmara:relay:nagdi:voucher-by-farmer-id], claims: {purpose: "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"}} + voucher-wrong-scope: {principal: nagdi-voucher-evidence, scopes: [solmara:relay:nagdi:movement-by-farmer-id], claims: {purpose: "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"}} + voucher-wrong-purpose: {principal: nagdi-voucher-evidence, scopes: [solmara:relay:nagdi:voucher-by-farmer-id], claims: {purpose: "https://id.registrystack.org/solmara/purpose/livestock-movement-control"}} + livestock-ok: {principal: nagdi-livestock-evidence, scopes: [solmara:relay:nagdi:movement-by-farmer-id], claims: {purpose: "https://id.registrystack.org/solmara/purpose/livestock-movement-control"}} + livestock-wrong-scope: {principal: nagdi-livestock-evidence, scopes: [solmara:relay:nagdi:voucher-by-farmer-id], claims: {purpose: "https://id.registrystack.org/solmara/purpose/livestock-movement-control"}} + livestock-wrong-purpose: {principal: nagdi-livestock-evidence, scopes: [solmara:relay:nagdi:movement-by-farmer-id], claims: {purpose: "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"}} +steps: + - id: voucher-success + authorizationFixture: voucher-ok + request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, body: {farmerId: FR-1001}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [farmerRegistered, dataUseAuthorized, activeSmallholderFarmer, activeFarmParcel, cropDeclaredForSeason, districtClimateRiskActive, voucherEntitlementCurrent, voucherNotRedeemed], domainDataValues: {farmerRegistered: true, voucherNotRedeemed: true}, absentEverywhere: [farmerId, farmer_id]} + - {id: voucher-fields-minimum, authorizationFixture: voucher-ok, request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, query: {fields: "farmerRegistered,voucherNotRedeemed"}, body: {farmerId: FR-1001}}, expect: {status: 200, registryCoreRequired: true, domainDataKeys: [farmerRegistered, voucherNotRedeemed], absentEverywhere: [dataUseAuthorized, activeSmallholderFarmer, activeFarmParcel, cropDeclaredForSeason, districtClimateRiskActive, voucherEntitlementCurrent]}} + - {id: voucher-wrong-scope, authorizationFixture: voucher-wrong-scope, request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, body: {farmerId: FR-1001}}, expect: {status: 404, code: resource.not_found}} + - {id: voucher-wrong-purpose, authorizationFixture: voucher-wrong-purpose, request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, body: {farmerId: FR-1001}}, expect: {status: 403, code: consultation.denied}} + - {id: voucher-malformed-selector, authorizationFixture: voucher-ok, request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, body: {farmerId: bad}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: voucher-no-match, authorizationFixture: voucher-ok, request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, body: {farmerId: FR-NONE}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: voucher-invalid-row, authorizationFixture: voucher-ok, request: {method: POST, path: /v2/resources/farmer/lookups/voucher-by-farmer-id, body: {farmerId: FR-BAD01}}, expect: {status: 503, code: source.unavailable}} + - {id: voucher-no-list, authorizationFixture: voucher-ok, request: {method: GET, path: /v2/resources/farmer/records}, expect: {status: 404, routeAbsent: true}} + - id: livestock-success + authorizationFixture: livestock-ok + request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: FR-1001}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [herdId, registeredHerd, herdVaccinationCurrent, originDistrictNotQuarantinedForSpecies, destinationDistrictOpen, noConflictingOpenMovementPermit], domainDataValues: {herdId: HERD-000001, registeredHerd: true}, absentEverywhere: [farmerId, farmer_id]} + - {id: livestock-fields-minimum, authorizationFixture: livestock-ok, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, query: {fields: "herdId,registeredHerd"}, body: {farmerId: FR-1001}}, expect: {status: 200, registryCoreRequired: true, domainDataKeys: [herdId, registeredHerd], absentEverywhere: [herdVaccinationCurrent, originDistrictNotQuarantinedForSpecies, destinationDistrictOpen, noConflictingOpenMovementPermit]}} + - {id: livestock-wrong-scope, authorizationFixture: livestock-wrong-scope, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: FR-1001}}, expect: {status: 404, code: resource.not_found}} + - {id: livestock-wrong-purpose, authorizationFixture: livestock-wrong-purpose, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: FR-1001}}, expect: {status: 403, code: consultation.denied}} + - {id: livestock-malformed-selector, authorizationFixture: livestock-ok, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: bad}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: livestock-no-match, authorizationFixture: livestock-ok, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: FR-NONE}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: livestock-ambiguous, authorizationFixture: livestock-ok, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: FR-AMB1}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: livestock-invalid-row, authorizationFixture: livestock-ok, request: {method: POST, path: /v2/resources/livestock-herd/lookups/movement-by-farmer-id, body: {farmerId: FR-BAD01}}, expect: {status: 503, code: source.unavailable}} + - {id: livestock-no-list, authorizationFixture: livestock-ok, request: {method: GET, path: /v2/resources/livestock-herd/records}, expect: {status: 404, routeAbsent: true}} diff --git a/relays/nagdi/fixture.sql b/relays/nagdi/fixture.sql new file mode 100644 index 0000000..c2b2476 --- /dev/null +++ b/relays/nagdi/fixture.sql @@ -0,0 +1,57 @@ +PRAGMA user_version = 1; +CREATE TABLE farmer_voucher_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + farmer_id TEXT NOT NULL UNIQUE, + farmer_registered INTEGER NOT NULL CHECK (farmer_registered IN (0, 1)), + data_use_authorized INTEGER NOT NULL CHECK (data_use_authorized IN (0, 1)), + active_smallholder_farmer INTEGER NOT NULL CHECK (active_smallholder_farmer IN (0, 1)), + active_farm_parcel INTEGER NOT NULL CHECK (active_farm_parcel IN (0, 1)), + crop_declared_for_season INTEGER NOT NULL CHECK (crop_declared_for_season IN (0, 1)), + district_climate_risk_active INTEGER NOT NULL CHECK (district_climate_risk_active IN (0, 1)), + voucher_entitlement_current INTEGER NOT NULL CHECK (voucher_entitlement_current IN (0, 1)), + voucher_not_redeemed INTEGER NOT NULL CHECK (voucher_not_redeemed IN (0, 1)) + ) STRICT; +CREATE TABLE livestock_movement_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + herd_id TEXT NOT NULL UNIQUE, + farmer_id TEXT NOT NULL, + registered_herd INTEGER NOT NULL CHECK (registered_herd IN (0, 1)), + herd_vaccination_current INTEGER NOT NULL CHECK (herd_vaccination_current IN (0, 1)), + origin_district_not_quarantined_for_species INTEGER NOT NULL CHECK (origin_district_not_quarantined_for_species IN (0, 1)), + destination_district_open INTEGER NOT NULL CHECK (destination_district_open IN (0, 1)), + no_conflicting_open_movement_permit INTEGER NOT NULL CHECK (no_conflicting_open_movement_permit IN (0, 1)) + ) STRICT; +CREATE VIEW relay_farmer_voucher AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + farmer_id, farmer_registered, data_use_authorized, + active_smallholder_farmer, active_farm_parcel, + crop_declared_for_season, district_climate_risk_active, + voucher_entitlement_current, voucher_not_redeemed + FROM farmer_voucher_source; +CREATE VIEW relay_livestock_movement AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + herd_id, farmer_id, registered_herd, + herd_vaccination_current, + origin_district_not_quarantined_for_species, + destination_district_open, + no_conflicting_open_movement_permit + FROM livestock_movement_source; +INSERT INTO farmer_voucher_source VALUES +('NAGDI-V-1','rev-1','active','2026-07-04T09:00:00Z','FR-1001',1,1,1,1,1,1,1,1), +('NAGDI-V-BAD','rev-2','active','invalid-date-time','FR-BAD01',1,1,1,1,1,1,1,1); +INSERT INTO livestock_movement_source VALUES +('NAGDI-M-1','rev-1','active','2026-07-04T09:00:00Z','HERD-000001','FR-1001',1,1,1,1,1), +('NAGDI-M-A1','rev-2','active','2026-07-04T09:00:00Z','HERD-AMB-001','FR-AMB1',1,1,1,1,1), +('NAGDI-M-A2','rev-3','active','2026-07-04T09:00:00Z','HERD-AMB-002','FR-AMB1',1,1,1,1,1), +('NAGDI-M-BAD','rev-4','active','invalid-date-time','HERD-BAD-001','FR-BAD01',1,1,1,1,1); +PRAGMA optimize; +-- Keep the fixture schema identical to the non-STAT4 live Python publication. +PRAGMA writable_schema = ON; +DELETE FROM sqlite_schema WHERE name = 'sqlite_stat4'; +PRAGMA writable_schema = OFF; diff --git a/relays/nagdi/governance/classification-review-rationale.md b/relays/nagdi/governance/classification-review-rationale.md new file mode 100644 index 0000000..8dc8a03 --- /dev/null +++ b/relays/nagdi/governance/classification-review-rationale.md @@ -0,0 +1 @@ +NAgDI reviewed separate voucher and livestock movement disclosures. Each operation releases only the closed predicate set required for its evidence decision. The farmer selector is not a property, and neither operation can select the other operation's disclosure profile. diff --git a/relays/nagdi/governance/classification-review.yaml b/relays/nagdi/governance/classification-review.yaml new file mode 100644 index 0000000..5577d29 --- /dev/null +++ b/relays/nagdi/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: https://id.registrystack.org/solmara/registry/nagdi +classificationInventoryDigest: sha256:533adfdf56fec71ce8805cc3ee4b2e4337352888e8f674941c02b5200a682d14 +method: manual +reviewer: did:web:id.registrystack.org:solmara:authority:nagdi +reviewDate: 2026-08-12 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/relays/nagdi/governance/identifier-lifecycle.yaml b/relays/nagdi/governance/identifier-lifecycle.yaml new file mode 100644 index 0000000..3dc5292 --- /dev/null +++ b/relays/nagdi/governance/identifier-lifecycle.yaml @@ -0,0 +1,2 @@ +status: reviewed +policy: NAgDI voucher and herd identifiers are stable across revisions and never reassigned after retirement. diff --git a/relays/nagdi/governance/legal-basis.yaml b/relays/nagdi/governance/legal-basis.yaml new file mode 100644 index 0000000..af23037 --- /dev/null +++ b/relays/nagdi/governance/legal-basis.yaml @@ -0,0 +1,2 @@ +status: reviewed +legalBasis: Agricultural data law authorizes the two purpose-bound evidence consultations. diff --git a/relays/nagdi/governance/processing.dpv.yaml b/relays/nagdi/governance/processing.dpv.yaml new file mode 100644 index 0000000..a7d3fa0 --- /dev/null +++ b/relays/nagdi/governance/processing.dpv.yaml @@ -0,0 +1,2 @@ +status: reviewed +profile: https://w3id.org/dpv/2.3 diff --git a/relays/nagdi/registry.yaml b/relays/nagdi/registry.yaml new file mode 100644 index 0000000..47cfc2a --- /dev/null +++ b/relays/nagdi/registry.yaml @@ -0,0 +1,104 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: solmara-nagdi-relay, version: "2026-08-12", title: NAgDI agriculture consultations} +registry: + registryIdentifier: https://id.registrystack.org/solmara/registry/nagdi + name: National Agricultural Data Institute Relay + authority: {identifier: "did:web:id.registrystack.org:solmara:authority:nagdi", name: National Agricultural Data Institute} + authoritativeScope: Current farmer voucher and livestock movement facts held by NAgDI + baseUri: https://nagdi.gov.solmara.example/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: [{name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional}] +governance: {controller: "did:web:id.registrystack.org:solmara:authority:nagdi", publisher: "did:web:id.registrystack.org:solmara:authority:nagdi", auditOwner: "did:web:id.registrystack.org:solmara:authority:nagdi"} +semantics: {localVocabulary: https://id.registrystack.org/solmara/vocabulary/nagdi/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://id.registrystack.org/solmara/classification/nagdi, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + nagdi: {kind: sqlite, profile: live-read-only, expectedSchemaFingerprint: "sha256:f97ad1dbc22fbf9911228ea361c90babfaf1f2b46865e09a756a52fcf3e2d538"} +resources: + - id: farmer + title: Farmer voucher eligibility + description: Minimum governed farmer voucher predicate facts. + semanticClass: local:FarmerVoucher + source: {source: nagdi, view: relay_farmer_voucher} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: + record_id: {privacy: identifying} + record_revision: {privacy: non-personal} + lifecycle_state: {privacy: personal} + recorded_at: {privacy: personal-context} + farmer_id: {privacy: identifying} + farmer_registered: {privacy: sensitive-personal} + data_use_authorized: {privacy: sensitive-personal} + active_smallholder_farmer: {privacy: sensitive-personal} + active_farm_parcel: {privacy: sensitive-personal} + crop_declared_for_season: {privacy: sensitive-personal} + district_climate_risk_active: {privacy: sensitive-personal} + voucher_entitlement_current: {privacy: sensitive-personal} + voucher_not_redeemed: {privacy: sensitive-personal} + recordContext: {recordIdentifier: {sourceColumn: record_id}, revisionIdentifier: {sourceColumn: record_revision}, lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml}, recordedAt: {sourceColumn: recorded_at}} + properties: + farmerRegistered: {sourceColumn: farmer_registered, type: boolean, sourceRequired: true, semanticTerm: local:farmerRegistered, label: Farmer registered, description: Farmer registration predicate, classification: {privacy: sensitive-personal}} + dataUseAuthorized: {sourceColumn: data_use_authorized, type: boolean, sourceRequired: true, semanticTerm: local:dataUseAuthorized, label: Data use authorized, description: Data-use authorization predicate, classification: {privacy: sensitive-personal}} + activeSmallholderFarmer: {sourceColumn: active_smallholder_farmer, type: boolean, sourceRequired: true, semanticTerm: local:activeSmallholderFarmer, label: Active smallholder farmer, description: Active smallholder predicate, classification: {privacy: sensitive-personal}} + activeFarmParcel: {sourceColumn: active_farm_parcel, type: boolean, sourceRequired: true, semanticTerm: local:activeFarmParcel, label: Active farm parcel, description: Active parcel predicate, classification: {privacy: sensitive-personal}} + cropDeclaredForSeason: {sourceColumn: crop_declared_for_season, type: boolean, sourceRequired: true, semanticTerm: local:cropDeclaredForSeason, label: Crop declared for season, description: Seasonal crop declaration predicate, classification: {privacy: sensitive-personal}} + districtClimateRiskActive: {sourceColumn: district_climate_risk_active, type: boolean, sourceRequired: true, semanticTerm: local:districtClimateRiskActive, label: District climate risk active, description: Climate-risk predicate, classification: {privacy: sensitive-personal}} + voucherEntitlementCurrent: {sourceColumn: voucher_entitlement_current, type: boolean, sourceRequired: true, semanticTerm: local:voucherEntitlementCurrent, label: Voucher entitlement current, description: Current entitlement predicate, classification: {privacy: sensitive-personal}} + voucherNotRedeemed: {sourceColumn: voucher_not_redeemed, type: boolean, sourceRequired: true, semanticTerm: local:voucherNotRedeemed, label: Voucher not redeemed, description: Voucher redemption predicate, classification: {privacy: sensitive-personal}} + disclosureProfiles: + voucher-evidence: {properties: [farmerRegistered, dataUseAuthorized, activeSmallholderFarmer, activeFarmParcel, cropDeclaredForSeason, districtClimateRiskActive, voucherEntitlementCurrent, voucherNotRedeemed]} + operations: + lookups: + - id: voucher-by-farmer-id + requestBody: {maximumBytes: 96, selectors: {farmerId: {sourceColumn: farmer_id, type: string, minimumBytes: 7, maximumBytes: 32}}} + defaultAccessProfile: voucher-evidence + accessProfiles: + voucher-evidence: + access: {scope: solmara:relay:nagdi:voucher-by-farmer-id, purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"]}} + disclosureProfile: voucher-evidence + processingDescriptions: + - {id: voucher-review, operationRefs: [lookup:voucher-by-farmer-id], purpose: "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, minimum-disclosure, value-free-audit]} + - id: livestock-herd + title: Livestock herd movement + description: Minimum governed livestock movement predicate facts. + semanticClass: local:LivestockHerd + source: {source: nagdi, view: relay_livestock_movement} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: + record_id: {privacy: identifying} + record_revision: {privacy: non-personal} + lifecycle_state: {privacy: personal} + recorded_at: {privacy: personal-context} + herd_id: {privacy: identifying} + farmer_id: {privacy: identifying} + registered_herd: {privacy: sensitive-personal} + herd_vaccination_current: {privacy: sensitive-personal} + origin_district_not_quarantined_for_species: {privacy: sensitive-personal} + destination_district_open: {privacy: sensitive-personal} + no_conflicting_open_movement_permit: {privacy: sensitive-personal} + recordContext: {recordIdentifier: {sourceColumn: record_id}, revisionIdentifier: {sourceColumn: record_revision}, lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml}, recordedAt: {sourceColumn: recorded_at}} + properties: + herdId: {sourceColumn: herd_id, type: string, sourceRequired: true, semanticTerm: local:herdId, label: Herd ID, description: Registered herd identifier, classification: {privacy: identifying}} + registeredHerd: {sourceColumn: registered_herd, type: boolean, sourceRequired: true, semanticTerm: local:registeredHerd, label: Registered herd, description: Herd registration predicate, classification: {privacy: sensitive-personal}} + herdVaccinationCurrent: {sourceColumn: herd_vaccination_current, type: boolean, sourceRequired: true, semanticTerm: local:herdVaccinationCurrent, label: Herd vaccination current, description: Vaccination predicate, classification: {privacy: sensitive-personal}} + originDistrictNotQuarantinedForSpecies: {sourceColumn: origin_district_not_quarantined_for_species, type: boolean, sourceRequired: true, semanticTerm: local:originDistrictNotQuarantinedForSpecies, label: Origin not quarantined, description: Species quarantine predicate, classification: {privacy: sensitive-personal}} + destinationDistrictOpen: {sourceColumn: destination_district_open, type: boolean, sourceRequired: true, semanticTerm: local:destinationDistrictOpen, label: Destination district open, description: Destination predicate, classification: {privacy: sensitive-personal}} + noConflictingOpenMovementPermit: {sourceColumn: no_conflicting_open_movement_permit, type: boolean, sourceRequired: true, semanticTerm: local:noConflictingOpenMovementPermit, label: No conflicting permit, description: Conflicting permit predicate, classification: {privacy: sensitive-personal}} + disclosureProfiles: + livestock-evidence: {properties: [herdId, registeredHerd, herdVaccinationCurrent, originDistrictNotQuarantinedForSpecies, destinationDistrictOpen, noConflictingOpenMovementPermit]} + operations: + lookups: + - id: movement-by-farmer-id + requestBody: {maximumBytes: 96, selectors: {farmerId: {sourceColumn: farmer_id, type: string, minimumBytes: 7, maximumBytes: 32}}} + defaultAccessProfile: livestock-evidence + accessProfiles: + livestock-evidence: + access: {scope: solmara:relay:nagdi:movement-by-farmer-id, purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/livestock-movement-control"]}} + disclosureProfile: livestock-evidence + processingDescriptions: + - {id: livestock-movement-review, operationRefs: [lookup:movement-by-farmer-id], purpose: "https://id.registrystack.org/solmara/purpose/livestock-movement-control", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, minimum-disclosure, value-free-audit]} +metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} diff --git a/relays/nagdi/runtime.yaml b/relays/nagdi/runtime.yaml new file mode 100644 index 0000000..ab83f0d --- /dev/null +++ b/relays/nagdi/runtime.yaml @@ -0,0 +1,11 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "0.0.0.0:8080"} +packagePath: /etc/relay/nagdi/package +sources: {nagdi: {path: /var/lib/relay/source/nagdi.sqlite}} +authentication: + issuer: {id: solmara-mint, discoveryUrl: https://mint.solmara.registrystack.org/.well-known/openid-configuration, audience: solmara-runtime, tokenTypes: [at+jwt], algorithms: [ES256]} +audit: {sink: /var/lib/relay/audit/audit.jsonl, integrityKeyRef: secret:env/SOLMARA_RELAY_AUDIT_KEY} +cursor: {integrityKeyRef: secret:env/SOLMARA_RELAY_CURSOR_KEY, maximumAgeSeconds: 300} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} +quotas: {requestsPerMinute: 120, burst: 20} diff --git a/relays/nia/codelists/identity-status.yaml b/relays/nia/codelists/identity-status.yaml new file mode 100644 index 0000000..679cafd --- /dev/null +++ b/relays/nia/codelists/identity-status.yaml @@ -0,0 +1,4 @@ +id: nia-identity-status +version: "1" +status: reviewed +values: [active, deceased] diff --git a/relays/nia/codelists/record-lifecycle.yaml b/relays/nia/codelists/record-lifecycle.yaml new file mode 100644 index 0000000..1234470 --- /dev/null +++ b/relays/nia/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: nia-record-lifecycle +version: "1" +status: reviewed +values: [active, deceased] diff --git a/relays/nia/codelists/sex.yaml b/relays/nia/codelists/sex.yaml new file mode 100644 index 0000000..4a272e6 --- /dev/null +++ b/relays/nia/codelists/sex.yaml @@ -0,0 +1,4 @@ +id: nia-sex +version: "1" +status: reviewed +values: [female, male] diff --git a/relays/nia/expected-http.yaml b/relays/nia/expected-http.yaml new file mode 100644 index 0000000..bb01a29 --- /dev/null +++ b/relays/nia/expected-http.yaml @@ -0,0 +1,21 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: https://id.registrystack.org/solmara/registry/nia +authorizations: + ok: {principal: nia-esignet, scopes: [solmara:relay:nia:esignet-userinfo], claims: {purpose: "https://id.registrystack.org/solmara/purpose/esignet-identity-verification"}} + wrong-scope: {principal: nia-esignet, scopes: [solmara:relay:nia:other], claims: {purpose: "https://id.registrystack.org/solmara/purpose/esignet-identity-verification"}} + wrong-purpose: {principal: nia-esignet, scopes: [solmara:relay:nia:esignet-userinfo], claims: {purpose: "https://id.registrystack.org/solmara/purpose/citizen-self-service"}} +steps: + - id: userinfo-success + authorizationFixture: ok + request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: "individualId,givenName,familyName"}, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [individualId, givenName, familyName], domainDataValues: {individualId: "2300010248", givenName: Mateo, familyName: Santos}, absentEverywhere: [gender, birthdate, legacyIdentifierInternal, identityStatusInternal, legacy_nid, identity_status]} + - id: userinfo-fields-minimum + authorizationFixture: ok + request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: individualId}, body: {uin: "2300010248"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [individualId], absentEverywhere: [givenName, familyName, gender, birthdate]} + - {id: wrong-scope, authorizationFixture: wrong-scope, request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: individualId}, body: {uin: "2300010248"}}, expect: {status: 404, code: resource.not_found}} + - {id: wrong-purpose, authorizationFixture: wrong-purpose, request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: individualId}, body: {uin: "2300010248"}}, expect: {status: 403, code: consultation.denied}} + - {id: malformed-selector, authorizationFixture: ok, request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: individualId}, body: {uin: short}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: no-match, authorizationFixture: ok, request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: individualId}, body: {uin: "2399999999"}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: invalid-row, authorizationFixture: ok, request: {method: POST, path: /v2/resources/population-person/lookups/esignet-userinfo, query: {fields: "individualId,birthdate"}, body: {uin: "2300999999"}}, expect: {status: 503, code: source.unavailable}} + - {id: no-list-route, authorizationFixture: ok, request: {method: GET, path: /v2/resources/population-person/records}, expect: {status: 404, routeAbsent: true}} diff --git a/relays/nia/fixture.sql b/relays/nia/fixture.sql new file mode 100644 index 0000000..6dd40bf --- /dev/null +++ b/relays/nia/fixture.sql @@ -0,0 +1,28 @@ +PRAGMA user_version = 1; +CREATE TABLE population_person_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + legacy_nid TEXT, + given_name TEXT NOT NULL, + family_name TEXT NOT NULL, + sex TEXT NOT NULL, + birth_date TEXT NOT NULL, + identity_status TEXT NOT NULL, + alive INTEGER NOT NULL CHECK (alive IN (0, 1)) + ) STRICT; +CREATE VIEW relay_population_person AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, legacy_nid, given_name, family_name, sex, birth_date, + identity_status, alive + FROM population_person_source; +INSERT INTO population_person_source VALUES +('CP-1','rev-1','active','2026-07-04T09:00:00Z','2300010248','NID-1001','Mateo','Santos','male','2022-03-14','active',1), -- legacy_nid migration fixture +('CP-BAD','rev-2','active','2026-07-04T09:00:00Z','2300999999',NULL,'Bad','Row','unknown','not-a-date','active',1); +PRAGMA optimize; +-- Keep the fixture schema identical to the non-STAT4 live Python publication. +PRAGMA writable_schema = ON; +DELETE FROM sqlite_schema WHERE name = 'sqlite_stat4'; +PRAGMA writable_schema = OFF; diff --git a/relays/nia/governance/classification-review-rationale.md b/relays/nia/governance/classification-review-rationale.md new file mode 100644 index 0000000..03fb505 --- /dev/null +++ b/relays/nia/governance/classification-review-rationale.md @@ -0,0 +1 @@ +NIA reviewed the eSignet disclosure inventory. Every UserInfo property is restricted and identifying or sensitive personal data. The eSignet caller must project the user-consented subset through `fields`; legacy identifiers and identity-status source columns are not disclosable properties. diff --git a/relays/nia/governance/classification-review.yaml b/relays/nia/governance/classification-review.yaml new file mode 100644 index 0000000..6c208ea --- /dev/null +++ b/relays/nia/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: https://id.registrystack.org/solmara/registry/nia +classificationInventoryDigest: sha256:4337e9e1d8a3deed0bd3c30d8a4a79867a77d0c820beba0ffecf92225f6b460f +method: manual +reviewer: did:web:id.registrystack.org:solmara:authority:nia +reviewDate: 2026-08-12 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/relays/nia/governance/identifier-lifecycle.yaml b/relays/nia/governance/identifier-lifecycle.yaml new file mode 100644 index 0000000..76e64dc --- /dev/null +++ b/relays/nia/governance/identifier-lifecycle.yaml @@ -0,0 +1,2 @@ +status: reviewed +policy: NIA person record identifiers are stable across revisions and never reassigned after retirement. diff --git a/relays/nia/governance/legal-basis.yaml b/relays/nia/governance/legal-basis.yaml new file mode 100644 index 0000000..1057599 --- /dev/null +++ b/relays/nia/governance/legal-basis.yaml @@ -0,0 +1,2 @@ +status: reviewed +legalBasis: The identity law and explicit user consent authorize the eSignet UserInfo consultation. diff --git a/relays/nia/governance/processing.dpv.yaml b/relays/nia/governance/processing.dpv.yaml new file mode 100644 index 0000000..a7d3fa0 --- /dev/null +++ b/relays/nia/governance/processing.dpv.yaml @@ -0,0 +1,2 @@ +status: reviewed +profile: https://w3id.org/dpv/2.3 diff --git a/relays/nia/registry.yaml b/relays/nia/registry.yaml new file mode 100644 index 0000000..10fef48 --- /dev/null +++ b/relays/nia/registry.yaml @@ -0,0 +1,72 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: solmara-nia-relay, version: "2026-08-12", title: NIA eSignet UserInfo consultation} +registry: + registryIdentifier: https://id.registrystack.org/solmara/registry/nia + name: National Identity Agency Relay + authority: {identifier: "did:web:id.registrystack.org:solmara:authority:nia", name: National Identity Agency} + authoritativeScope: Current population identity attributes held by NIA for consented eSignet UserInfo release + baseUri: https://nia.gov.solmara.example/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: [{name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional}] +governance: {controller: "did:web:id.registrystack.org:solmara:authority:nia", publisher: "did:web:id.registrystack.org:solmara:authority:nia", auditOwner: "did:web:id.registrystack.org:solmara:authority:nia"} +semantics: {localVocabulary: https://id.registrystack.org/solmara/vocabulary/nia/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://id.registrystack.org/solmara/classification/nia, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + nia: {kind: sqlite, profile: live-read-only, expectedSchemaFingerprint: "sha256:d91bc765476032de74670b1c4fce0c3d4f6c56f2b0116b36457fe5feb6027758"} +resources: + - id: population-person + title: Population person + description: Consent-projected eSignet UserInfo properties from NIA. + semanticClass: local:PopulationPerson + source: {source: nia, view: relay_population_person} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: + record_id: {privacy: identifying} + record_revision: {privacy: non-personal} + lifecycle_state: {privacy: personal} + recorded_at: {privacy: personal-context} + uin: {privacy: identifying} + legacy_nid: {privacy: identifying} + given_name: {privacy: identifying} + family_name: {privacy: identifying} + sex: {privacy: sensitive-personal} + birth_date: {privacy: sensitive-personal} + identity_status: {privacy: sensitive-personal} + alive: {privacy: sensitive-personal} + recordContext: + recordIdentifier: {sourceColumn: record_id} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + individualId: {sourceColumn: uin, type: string, sourceRequired: true, semanticTerm: local:individualId, label: Individual ID, description: NIA subject identifier, classification: {privacy: identifying}} + givenName: {sourceColumn: given_name, type: string, sourceRequired: true, semanticTerm: local:givenName, label: Given name, description: Given name, classification: {privacy: identifying}} + familyName: {sourceColumn: family_name, type: string, sourceRequired: true, semanticTerm: local:familyName, label: Family name, description: Family name, classification: {privacy: identifying}} + gender: {sourceColumn: sex, type: controlled-code, codelist: codelists/sex.yaml, sourceRequired: true, semanticTerm: local:gender, label: Gender, description: Consented gender claim, classification: {privacy: sensitive-personal}} + birthdate: {sourceColumn: birth_date, type: date, sourceRequired: true, semanticTerm: local:birthdate, label: Birthdate, description: Consented date of birth claim, classification: {privacy: sensitive-personal}} + active: {sourceColumn: alive, type: boolean, sourceRequired: true, semanticTerm: local:active, label: Active, description: Whether the identity belongs to a living person, classification: {privacy: sensitive-personal}} + legacyIdentifierInternal: {sourceColumn: legacy_nid, type: string, sourceRequired: false, semanticTerm: local:legacyIdentifierInternal, label: Internal legacy identifier, description: Accounted source column excluded from the eSignet disclosure, classification: {privacy: identifying}} + identityStatusInternal: {sourceColumn: identity_status, type: controlled-code, codelist: codelists/identity-status.yaml, sourceRequired: true, semanticTerm: local:identityStatusInternal, label: Internal identity status, description: Accounted source column excluded from the eSignet disclosure, classification: {privacy: sensitive-personal}} + disclosureProfiles: + esignet-userinfo: {properties: [individualId, givenName, familyName, gender, birthdate, active]} + operations: + lookups: + - id: esignet-userinfo + requestBody: + maximumBytes: 96 + selectors: {uin: {sourceColumn: uin, type: string, minimumBytes: 10, maximumBytes: 10}} + defaultAccessProfile: esignet + accessProfiles: + esignet: + access: + scope: solmara:relay:nia:esignet-userinfo + purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/esignet-identity-verification"]} + disclosureProfile: esignet-userinfo + processingDescriptions: + - {id: esignet-userinfo, operationRefs: [lookup:esignet-userinfo], purpose: "https://id.registrystack.org/solmara/purpose/esignet-identity-verification", recipientClass: esignet, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [explicit-fields-projection, exact-lookup, minimum-disclosure, value-free-audit]} +metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} diff --git a/relays/nia/runtime.yaml b/relays/nia/runtime.yaml new file mode 100644 index 0000000..99b9257 --- /dev/null +++ b/relays/nia/runtime.yaml @@ -0,0 +1,10 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "0.0.0.0:8080"} +packagePath: /etc/relay/nia/package +sources: {nia: {path: /var/lib/relay/source/nia.sqlite}} +authentication: + issuer: {id: solmara-mint, discoveryUrl: https://mint.solmara.registrystack.org/.well-known/openid-configuration, audience: solmara-runtime, tokenTypes: [at+jwt], algorithms: [ES256]} +audit: {sink: /var/lib/relay/audit/audit.jsonl, integrityKeyRef: secret:env/SOLMARA_RELAY_AUDIT_KEY} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} +quotas: {requestsPerMinute: 120, burst: 20} diff --git a/relays/sipf/codelists/payment-status.yaml b/relays/sipf/codelists/payment-status.yaml new file mode 100644 index 0000000..a4d1d83 --- /dev/null +++ b/relays/sipf/codelists/payment-status.yaml @@ -0,0 +1,4 @@ +id: sipf-payment-status +version: "1" +status: reviewed +values: [active, held] diff --git a/relays/sipf/codelists/record-lifecycle.yaml b/relays/sipf/codelists/record-lifecycle.yaml new file mode 100644 index 0000000..7482ec3 --- /dev/null +++ b/relays/sipf/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: sipf-record-lifecycle +version: "1" +status: reviewed +values: [active, in_payment, pending_proof_of_life] diff --git a/relays/sipf/expected-http.yaml b/relays/sipf/expected-http.yaml new file mode 100644 index 0000000..78ebc9e --- /dev/null +++ b/relays/sipf/expected-http.yaml @@ -0,0 +1,32 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: https://id.registrystack.org/solmara/registry/sipf +authorizations: + pension-ok: {principal: sipf-pension-evidence, scopes: [solmara:relay:sipf:by-pensioner-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review"}} + pension-wrong-scope: {principal: sipf-pension-evidence, scopes: [solmara:relay:sipf:by-spouse-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review"}} + pension-wrong-purpose: {principal: sipf-pension-evidence, scopes: [solmara:relay:sipf:by-pensioner-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"}} + survivor-ok: {principal: sipf-survivor-evidence, scopes: [solmara:relay:sipf:by-spouse-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"}} + survivor-wrong-scope: {principal: sipf-survivor-evidence, scopes: [solmara:relay:sipf:by-pensioner-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"}} + survivor-wrong-purpose: {principal: sipf-survivor-evidence, scopes: [solmara:relay:sipf:by-spouse-uin], claims: {purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review"}} +steps: + - id: pension-success + authorizationFixture: pension-ok + request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, body: {pensionerUin: "2300109568"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [paymentStatus], domainDataValues: {paymentStatus: active}, absentEverywhere: [pensionerUin, pensioner_uin]} + - {id: pension-fields-minimum, authorizationFixture: pension-ok, request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, query: {fields: paymentStatus}, body: {pensionerUin: "2300109568"}}, expect: {status: 200, registryCoreRequired: true, domainDataKeys: [paymentStatus]}} + - {id: pension-wrong-scope, authorizationFixture: pension-wrong-scope, request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, body: {pensionerUin: "2300109568"}}, expect: {status: 404, code: resource.not_found}} + - {id: pension-wrong-purpose, authorizationFixture: pension-wrong-purpose, request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, body: {pensionerUin: "2300109568"}}, expect: {status: 403, code: consultation.denied}} + - {id: pension-malformed-selector, authorizationFixture: pension-ok, request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, body: {pensionerUin: short}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: pension-no-match, authorizationFixture: pension-ok, request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, body: {pensionerUin: "2399999999"}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: pension-invalid-row, authorizationFixture: pension-ok, request: {method: POST, path: /v2/resources/pension-payment/lookups/by-pensioner-uin, body: {pensionerUin: "2300999999"}}, expect: {status: 503, code: source.unavailable}} + - {id: pension-no-list, authorizationFixture: pension-ok, request: {method: GET, path: /v2/resources/pension-payment/records}, expect: {status: 404, routeAbsent: true}} + - id: survivor-success + authorizationFixture: survivor-ok + request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, body: {spouseUin: "2300118698"}} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [survivorEligible], domainDataValues: {survivorEligible: true}, absentEverywhere: [spouseUin, spouse_uin]} + - {id: survivor-fields-minimum, authorizationFixture: survivor-ok, request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, query: {fields: survivorEligible}, body: {spouseUin: "2300118698"}}, expect: {status: 200, registryCoreRequired: true, domainDataKeys: [survivorEligible]}} + - {id: survivor-wrong-scope, authorizationFixture: survivor-wrong-scope, request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, body: {spouseUin: "2300118698"}}, expect: {status: 404, code: resource.not_found}} + - {id: survivor-wrong-purpose, authorizationFixture: survivor-wrong-purpose, request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, body: {spouseUin: "2300118698"}}, expect: {status: 403, code: consultation.denied}} + - {id: survivor-malformed-selector, authorizationFixture: survivor-ok, request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, body: {spouseUin: short}}, expect: {status: 400, code: consultation.invalid_request}} + - {id: survivor-no-match, authorizationFixture: survivor-ok, request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, body: {spouseUin: "2399999999"}}, expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved}} + - {id: survivor-invalid-row, authorizationFixture: survivor-ok, request: {method: POST, path: /v2/resources/survivor-case/lookups/by-spouse-uin, body: {spouseUin: "2300888888"}}, expect: {status: 503, code: source.unavailable}} + - {id: survivor-no-list, authorizationFixture: survivor-ok, request: {method: GET, path: /v2/resources/survivor-case/records}, expect: {status: 404, routeAbsent: true}} diff --git a/relays/sipf/fixture.sql b/relays/sipf/fixture.sql new file mode 100644 index 0000000..b66097f --- /dev/null +++ b/relays/sipf/fixture.sql @@ -0,0 +1,36 @@ +PRAGMA user_version = 1; +CREATE TABLE pension_case_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + pensioner_uin TEXT NOT NULL UNIQUE, + payment_status TEXT NOT NULL + ) STRICT; +CREATE TABLE survivor_case_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + spouse_uin TEXT NOT NULL UNIQUE, + survivor_eligible INTEGER NOT NULL CHECK (survivor_eligible IN (0, 1)) + ) STRICT; +CREATE VIEW relay_pension_payment AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + pensioner_uin, payment_status + FROM pension_case_source; +CREATE VIEW relay_survivor_case AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + spouse_uin, survivor_eligible + FROM survivor_case_source; +INSERT INTO pension_case_source VALUES +('SIPF-P-1','rev-1','in_payment','2026-07-04T09:00:00Z','2300109568','active'), +('SIPF-P-BAD','rev-2','in_payment','invalid-date-time','2300999999','active'); +INSERT INTO survivor_case_source VALUES +('SIPF-S-1','rev-1','active','2026-07-04T09:00:00Z','2300118698',1), +('SIPF-S-BAD','rev-2','active','invalid-date-time','2300888888',0); +PRAGMA optimize; +-- Keep the fixture schema identical to the non-STAT4 live Python publication. +PRAGMA writable_schema = ON; +DELETE FROM sqlite_schema WHERE name = 'sqlite_stat4'; +PRAGMA writable_schema = OFF; diff --git a/relays/sipf/governance/classification-review-rationale.md b/relays/sipf/governance/classification-review-rationale.md new file mode 100644 index 0000000..ffc70a8 --- /dev/null +++ b/relays/sipf/governance/classification-review-rationale.md @@ -0,0 +1 @@ +SIPF reviewed the two independent inventories. Pension consultation releases only current payment status; survivor consultation releases only the survivor-eligibility predicate. The UIN selectors and unrelated pension or family data are not properties. diff --git a/relays/sipf/governance/classification-review.yaml b/relays/sipf/governance/classification-review.yaml new file mode 100644 index 0000000..a6a088d --- /dev/null +++ b/relays/sipf/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: https://id.registrystack.org/solmara/registry/sipf +classificationInventoryDigest: sha256:08882398c5f65f3ccea706759a246e4eee5d4639f984af416a4f2a1b4eeaccd2 +method: manual +reviewer: did:web:id.registrystack.org:solmara:authority:sipf +reviewDate: 2026-08-12 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/relays/sipf/governance/identifier-lifecycle.yaml b/relays/sipf/governance/identifier-lifecycle.yaml new file mode 100644 index 0000000..70eadf4 --- /dev/null +++ b/relays/sipf/governance/identifier-lifecycle.yaml @@ -0,0 +1,2 @@ +status: reviewed +policy: SIPF case identifiers are stable across revisions and never reassigned after retirement. diff --git a/relays/sipf/governance/legal-basis.yaml b/relays/sipf/governance/legal-basis.yaml new file mode 100644 index 0000000..3d36c66 --- /dev/null +++ b/relays/sipf/governance/legal-basis.yaml @@ -0,0 +1,2 @@ +status: reviewed +legalBasis: Social insurance law authorizes purpose-bound pension and survivor consultations. diff --git a/relays/sipf/governance/processing.dpv.yaml b/relays/sipf/governance/processing.dpv.yaml new file mode 100644 index 0000000..a7d3fa0 --- /dev/null +++ b/relays/sipf/governance/processing.dpv.yaml @@ -0,0 +1,2 @@ +status: reviewed +profile: https://w3id.org/dpv/2.3 diff --git a/relays/sipf/registry.yaml b/relays/sipf/registry.yaml new file mode 100644 index 0000000..705e28c --- /dev/null +++ b/relays/sipf/registry.yaml @@ -0,0 +1,66 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: solmara-sipf-relay, version: "2026-08-12", title: SIPF pension consultations} +registry: + registryIdentifier: https://id.registrystack.org/solmara/registry/sipf + name: Social Insurance and Pensions Fund Relay + authority: {identifier: "did:web:id.registrystack.org:solmara:authority:sipf", name: Social Insurance and Pensions Fund} + authoritativeScope: Current pension payment and survivor eligibility facts held by SIPF + baseUri: https://sipf.gov.solmara.example/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: [{name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional}] +governance: {controller: "did:web:id.registrystack.org:solmara:authority:sipf", publisher: "did:web:id.registrystack.org:solmara:authority:sipf", auditOwner: "did:web:id.registrystack.org:solmara:authority:sipf"} +semantics: {localVocabulary: https://id.registrystack.org/solmara/vocabulary/sipf/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://id.registrystack.org/solmara/classification/sipf, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + sipf: {kind: sqlite, profile: live-read-only, expectedSchemaFingerprint: "sha256:4298f8a9efcca5c1a2f58942f4a947afa3dd51e8767f54d1c557c60fb466a350"} +resources: + - id: pension-payment + title: Pension payment + description: Minimum pension payment status fact. + semanticClass: local:PensionPayment + source: {source: sipf, view: relay_pension_payment} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: {record_id: {privacy: identifying}, record_revision: {privacy: non-personal}, lifecycle_state: {privacy: personal}, recorded_at: {privacy: personal-context}, pensioner_uin: {privacy: identifying}, payment_status: {privacy: sensitive-personal}} + recordContext: {recordIdentifier: {sourceColumn: record_id}, revisionIdentifier: {sourceColumn: record_revision}, lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml}, recordedAt: {sourceColumn: recorded_at}} + properties: + paymentStatus: {sourceColumn: payment_status, type: controlled-code, codelist: codelists/payment-status.yaml, sourceRequired: true, semanticTerm: local:paymentStatus, label: Payment status, description: Current pension payment status, classification: {privacy: sensitive-personal}} + disclosureProfiles: {pension-evidence: {properties: [paymentStatus]}} + operations: + lookups: + - id: by-pensioner-uin + requestBody: {maximumBytes: 96, selectors: {pensionerUin: {sourceColumn: pensioner_uin, type: string, minimumBytes: 10, maximumBytes: 10}}} + defaultAccessProfile: pension-evidence + accessProfiles: + pension-evidence: + access: {scope: solmara:relay:sipf:by-pensioner-uin, purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/pension-payment-review"]}} + disclosureProfile: pension-evidence + processingDescriptions: + - {id: pension-payment-review, operationRefs: [lookup:by-pensioner-uin], purpose: "https://id.registrystack.org/solmara/purpose/pension-payment-review", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, minimum-disclosure, value-free-audit]} + - id: survivor-case + title: Survivor case + description: Minimum survivor eligibility fact. + semanticClass: local:SurvivorCase + source: {source: sipf, view: relay_survivor_case} + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: {record_id: {privacy: identifying}, record_revision: {privacy: non-personal}, lifecycle_state: {privacy: personal}, recorded_at: {privacy: personal-context}, spouse_uin: {privacy: identifying}, survivor_eligible: {privacy: sensitive-personal}} + recordContext: {recordIdentifier: {sourceColumn: record_id}, revisionIdentifier: {sourceColumn: record_revision}, lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml}, recordedAt: {sourceColumn: recorded_at}} + properties: + survivorEligible: {sourceColumn: survivor_eligible, type: boolean, sourceRequired: true, semanticTerm: local:survivorEligible, label: Survivor eligible, description: Current survivor benefit eligibility, classification: {privacy: sensitive-personal}} + disclosureProfiles: {survivor-evidence: {properties: [survivorEligible]}} + operations: + lookups: + - id: by-spouse-uin + requestBody: {maximumBytes: 96, selectors: {spouseUin: {sourceColumn: spouse_uin, type: string, minimumBytes: 10, maximumBytes: 10}}} + defaultAccessProfile: survivor-evidence + accessProfiles: + survivor-evidence: + access: {scope: solmara:relay:sipf:by-spouse-uin, purpose: {claim: purpose, allowed: ["https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"]}} + disclosureProfile: survivor-evidence + processingDescriptions: + - {id: survivor-benefit-review, operationRefs: [lookup:by-spouse-uin], purpose: "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination", recipientClass: authorized-evidence-service, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [exact-lookup, minimum-disclosure, value-free-audit]} +metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} diff --git a/relays/sipf/runtime.yaml b/relays/sipf/runtime.yaml new file mode 100644 index 0000000..d0cd8d3 --- /dev/null +++ b/relays/sipf/runtime.yaml @@ -0,0 +1,11 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "0.0.0.0:8080"} +packagePath: /etc/relay/sipf/package +sources: {sipf: {path: /var/lib/relay/source/sipf.sqlite}} +authentication: + issuer: {id: solmara-mint, discoveryUrl: https://mint.solmara.registrystack.org/.well-known/openid-configuration, audience: solmara-runtime, tokenTypes: [at+jwt], algorithms: [ES256]} +audit: {sink: /var/lib/relay/audit/audit.jsonl, integrityKeyRef: secret:env/SOLMARA_RELAY_AUDIT_KEY} +cursor: {integrityKeyRef: secret:env/SOLMARA_RELAY_CURSOR_KEY, maximumAgeSeconds: 300} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} +quotas: {requestsPerMinute: 120, burst: 20} diff --git a/relays/test_relay_projects.py b/relays/test_relay_projects.py new file mode 100644 index 0000000..af92a97 --- /dev/null +++ b/relays/test_relay_projects.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parent + +EXPECTED = { + "cra": { + "civil-person": { + "death-by-uin": ("cra-pension-evidence", "solmara:relay:cra:death-by-uin"), + "citizen-link-by-uin": ("cra-citizen-evidence", "solmara:relay:cra:citizen-link-by-uin"), + } + }, + "nia": {"population-person": {"esignet-userinfo": ("nia-esignet", "solmara:relay:nia:esignet-userinfo")}}, + "mosd": {"beneficiary-enrolment": {"by-uin": ("mosd-child-benefit-evidence", "solmara:relay:mosd:by-uin")}}, + "sipf": { + "pension-payment": {"by-pensioner-uin": ("sipf-pension-evidence", "solmara:relay:sipf:by-pensioner-uin")}, + "survivor-case": {"by-spouse-uin": ("sipf-survivor-evidence", "solmara:relay:sipf:by-spouse-uin")}, + }, + "nagdi": { + "farmer": {"voucher-by-farmer-id": ("nagdi-voucher-evidence", "solmara:relay:nagdi:voucher-by-farmer-id")}, + "livestock-herd": {"movement-by-farmer-id": ("nagdi-livestock-evidence", "solmara:relay:nagdi:movement-by-farmer-id")}, + }, +} + + +def load(path: Path) -> dict: + return yaml.safe_load(path.read_text()) + + +def lookup_map(contract: dict) -> dict[tuple[str, str], dict]: + result = {} + for resource in contract["resources"]: + operations = resource["operations"] + if operations.get("list") or operations.get("read") or operations.get("searches"): + raise AssertionError(f"{resource['id']} declares a forbidden enumerating operation") + for lookup in operations.get("lookups", []): + result[(resource["id"], lookup["id"])] = lookup + return result + + +class RelayProjectContracts(unittest.TestCase): + def test_exact_authority_owned_topology_and_operation_inventory(self) -> None: + self.assertEqual(set(EXPECTED), {path.name for path in ROOT.iterdir() if path.is_dir() and not path.name.startswith("__")}) + for authority, resources in EXPECTED.items(): + contract = load(ROOT / authority / "registry.yaml") + actual = lookup_map(contract) + expected = {(resource, lookup) for resource, lookups in resources.items() for lookup in lookups} + self.assertEqual(set(actual), expected) + for resource in contract["resources"]: + lookups = resource["operations"]["lookups"] + self.assertEqual(len(resource["disclosureProfiles"]), len(lookups)) + for lookup in lookups: + profiles = lookup["accessProfiles"] + self.assertEqual(len(profiles), 1) + self.assertIn(lookup["defaultAccessProfile"], profiles) + + def test_scopes_and_authorization_principals_are_operation_specific(self) -> None: + for authority, resources in EXPECTED.items(): + contract = load(ROOT / authority / "registry.yaml") + journey = load(ROOT / authority / "expected-http.yaml") + actual = lookup_map(contract) + for resource, lookups in resources.items(): + for lookup_id, (principal, scope) in lookups.items(): + lookup = actual[(resource, lookup_id)] + profile = next(iter(lookup["accessProfiles"].values())) + self.assertEqual(profile["access"]["scope"], scope) + matching = [fixture for fixture in journey["authorizations"].values() if fixture["principal"] == principal and scope in fixture["scopes"]] + self.assertTrue(matching, f"{authority}/{lookup_id} lacks its dedicated principal fixture") + + def test_runtime_is_container_bound_shared_mint_and_fail_closed_audit(self) -> None: + for authority in EXPECTED: + runtime = load(ROOT / authority / "runtime.yaml") + self.assertEqual(runtime["server"]["bind"], "0.0.0.0:8080") + self.assertEqual(runtime["packagePath"], f"/etc/relay/{authority}/package") + self.assertEqual(runtime["sources"][authority]["path"], f"/var/lib/relay/source/{authority}.sqlite") + issuer = runtime["authentication"]["issuer"] + self.assertEqual( + issuer["discoveryUrl"], + "https://mint.solmara.registrystack.org/.well-known/openid-configuration", + ) + self.assertEqual(issuer["audience"], "solmara-runtime") + self.assertEqual(issuer["algorithms"], ["ES256"]) + self.assertEqual(runtime["audit"]["sink"], "/var/lib/relay/audit/audit.jsonl") + self.assertTrue(runtime["audit"]["integrityKeyRef"].startswith("secret:env/")) + + def test_fixture_shorthand_maps_to_real_nested_selector_body(self) -> None: + for authority in EXPECTED: + journey = load(ROOT / authority / "expected-http.yaml") + for step in journey["steps"]: + request = step["request"] + if request["method"] != "POST": + continue + shorthand = request["body"] + self.assertNotIn("selectors", shorthand) + wire = json.loads(json.dumps({"selectors": shorthand}, separators=(",", ":"))) + self.assertEqual(wire, {"selectors": shorthand}) + + def test_each_lookup_has_behavioral_boundary_coverage(self) -> None: + for authority, resources in EXPECTED.items(): + steps = [step["id"] for step in load(ROOT / authority / "expected-http.yaml")["steps"]] + fixture_sql = (ROOT / authority / "fixture.sql").read_text() + lookup_count = sum(len(lookups) for lookups in resources.values()) + for lookups in resources.values(): + for lookup_id in lookups: + prefix = { + "death-by-uin": "death-", + "citizen-link-by-uin": "citizen-", + "esignet-userinfo": "userinfo-", + "by-uin": "", + "by-pensioner-uin": "pension-", + "by-spouse-uin": "survivor-", + "voucher-by-farmer-id": "voucher-", + "movement-by-farmer-id": "livestock-", + }[lookup_id] + for suffix in ("success", "fields-minimum", "wrong-scope", "wrong-purpose", "malformed-selector", "no-match", "invalid-row"): + self.assertTrue( + f"{prefix}{suffix}" in steps or (lookup_count == 1 and suffix in steps), + f"{authority}/{lookup_id} lacks {suffix}", + ) + self.assertTrue(any("no-list" in step for step in steps)) + # The publisher's UNIQUE selectors make ambiguity structurally impossible for + # every lookup except NAgDI livestock, whose two-row case is exercised over HTTP. + if authority == "nagdi": + self.assertIn("livestock-ambiguous", steps) + self.assertIn("UNIQUE", fixture_sql) + + def test_classification_reviews_bind_real_inventory_digests(self) -> None: + zero = "sha256:" + "0" * 64 + for authority in EXPECTED: + review = load(ROOT / authority / "governance" / "classification-review.yaml") + self.assertEqual(review["method"], "manual") + self.assertEqual(review["status"], "reviewed") + self.assertNotEqual(review["classificationInventoryDigest"], zero) + + def test_cra_birth_fields_are_never_disclosed(self) -> None: + contract = load(ROOT / "cra" / "registry.yaml") + resource = contract["resources"][0] + disclosed = {name for profile in resource["disclosureProfiles"].values() for name in profile["properties"]} + self.assertTrue({"birthDateInternal", "birthRegistrationNumberInternal"}.isdisjoint(disclosed)) + self.assertEqual(resource["disclosureProfiles"]["death-fact"]["properties"], ["deceased"]) + death = next(item for item in resource["operations"]["lookups"] if item["id"] == "death-by-uin") + self.assertEqual(set(death["requestBody"]["selectors"]), {"uin"}) + steps = {step["id"]: step for step in load(ROOT / "cra" / "expected-http.yaml")["steps"]} + self.assertEqual(steps["death-success"]["expect"]["domainDataValues"], {"deceased": True}) + self.assertEqual(steps["death-living-success"]["expect"]["domainDataValues"], {"deceased": False}) + + +if __name__ == "__main__": + unittest.main() diff --git a/requests/registry-lab/20 - Child Benefit/01 - Collect source predicates.bru b/requests/registry-lab/20 - Child Benefit/01 - Collect source predicates.bru index 70c4c35..5dfb415 100644 --- a/requests/registry-lab/20 - Child Benefit/01 - Collect source predicates.bru +++ b/requests/registry-lab/20 - Child Benefit/01 - Collect source predicates.bru @@ -12,12 +12,12 @@ post { headers { x-api-key: {{CHILD_BENEFIT_FEDERATOR_TOKEN}} - Data-Purpose: {{child_benefit_purpose}} Accept: application/json } body:json { { + "purpose": "child-benefit-review", "target": { "type": "Person", "identifiers": [ diff --git a/requests/registry-lab/30 - Pension Survivor/01 - Evaluate pension stop.bru b/requests/registry-lab/30 - Pension Survivor/01 - Evaluate pension stop.bru index 3b905b8..86ab2d1 100644 --- a/requests/registry-lab/30 - Pension Survivor/01 - Evaluate pension stop.bru +++ b/requests/registry-lab/30 - Pension Survivor/01 - Evaluate pension stop.bru @@ -1,36 +1,31 @@ meta { - name: 01 - Read CRA death predicate + name: 01 - Request CRA death assertion type: http seq: 1 } post { - url: {{cra_notary_url}}/v1/evaluations + url: {{cra_evidence_url}}/v1/evidence body: json auth: none } headers { - x-api-key: {{CRA_PENSION_CLIENT_TOKEN}} - Data-Purpose: {{pension_payment_purpose}} - Accept: application/vnd.registry-notary.claim-result+json + Authorization: Bearer {{EVIDENCE_ACCESS_TOKEN}} + Accept: application/jose+json } body:json { { - "target": { - "type": "Person", - "identifiers": [ - { - "scheme": "solmara_uin", - "value": "2300109568" - } - ] - }, - "claims": [ - "person-is-deceased" - ], - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json" + "requestNonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "requirement": "https://id.registrystack.org/solmara/requirement/cra-pension-death/v1", + "purpose": "pension-payment-review", + "subjects": [{ + "role": "subject", + "selector": { + "profile": "solmara-uin-v1", + "values": {"uin": "2300109568"} + } + }] } } diff --git a/requests/registry-lab/30 - Pension Survivor/02 - Read active pension payment.bru b/requests/registry-lab/30 - Pension Survivor/02 - Read active pension payment.bru index d6299fa..08484ef 100644 --- a/requests/registry-lab/30 - Pension Survivor/02 - Read active pension payment.bru +++ b/requests/registry-lab/30 - Pension Survivor/02 - Read active pension payment.bru @@ -1,36 +1,31 @@ meta { - name: 02 - Read SIPF payment predicate + name: 02 - Request SIPF payment assertion type: http seq: 2 } post { - url: {{sipf_notary_url}}/v1/evaluations + url: {{sipf_evidence_url}}/v1/evidence body: json auth: none } headers { - x-api-key: {{SIPF_PENSION_CLIENT_TOKEN}} - Data-Purpose: {{pension_payment_purpose}} - Accept: application/vnd.registry-notary.claim-result+json + Authorization: Bearer {{EVIDENCE_ACCESS_TOKEN}} + Accept: application/jose+json } body:json { { - "target": { - "type": "Person", - "identifiers": [ - { - "scheme": "solmara_uin", - "value": "2300109568" - } - ] - }, - "claims": [ - "pension-payment-active" - ], - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json" + "requestNonce": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "requirement": "https://id.registrystack.org/solmara/requirement/sipf-pension-payment/v1", + "purpose": "pension-payment-review", + "subjects": [{ + "role": "subject", + "selector": { + "profile": "solmara-uin-v1", + "values": {"uin": "2300109568"} + } + }] } } diff --git a/requests/registry-lab/30 - Pension Survivor/03 - Read survivor eligibility.bru b/requests/registry-lab/30 - Pension Survivor/03 - Read survivor eligibility.bru index af8a11a..7bc7c0e 100644 --- a/requests/registry-lab/30 - Pension Survivor/03 - Read survivor eligibility.bru +++ b/requests/registry-lab/30 - Pension Survivor/03 - Read survivor eligibility.bru @@ -1,36 +1,31 @@ meta { - name: 03 - Read SIPF survivor predicate + name: 03 - Request SIPF survivor assertion type: http seq: 3 } post { - url: {{sipf_notary_url}}/v1/evaluations + url: {{sipf_evidence_url}}/v1/evidence body: json auth: none } headers { - x-api-key: {{SIPF_PENSION_CLIENT_TOKEN}} - Data-Purpose: {{survivor_benefit_purpose}} - Accept: application/vnd.registry-notary.claim-result+json + Authorization: Bearer {{EVIDENCE_ACCESS_TOKEN}} + Accept: application/jose+json } body:json { { - "target": { - "type": "Person", - "identifiers": [ - { - "scheme": "solmara_uin", - "value": "2300118698" - } - ] - }, - "claims": [ - "survivor-is-eligible" - ], - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json" + "requestNonce": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "requirement": "https://id.registrystack.org/solmara/requirement/sipf-survivor-benefit/v1", + "purpose": "survivor-benefit-determination", + "subjects": [{ + "role": "subject", + "selector": { + "profile": "solmara-uin-v1", + "values": {"uin": "2300118698"} + } + }] } } diff --git a/requests/registry-lab/40 - NAgDI Voucher/01 - Voucher eligibility.bru b/requests/registry-lab/40 - NAgDI Voucher/01 - Voucher eligibility.bru index 667a514..b4937ae 100644 --- a/requests/registry-lab/40 - NAgDI Voucher/01 - Voucher eligibility.bru +++ b/requests/registry-lab/40 - NAgDI Voucher/01 - Voucher eligibility.bru @@ -5,32 +5,27 @@ meta { } post { - url: {{nagdi_notary_url}}/v1/evaluations + url: {{nagdi_evidence_url}}/v1/evidence body: json auth: none } headers { - x-api-key: {{NAGDI_NOTARY_TOKEN}} - Data-Purpose: {{voucher_purpose}} - Accept: application/vnd.registry-notary.claim-result+json + Authorization: Bearer {{EVIDENCE_ACCESS_TOKEN}} + Accept: application/jose+json } body:json { { - "target": { - "type": "Person", - "identifiers": [ - { - "scheme": "farmer_id", - "value": "FR-1001" - } - ] - }, - "claims": [ - "eligible-for-climate-smart-input-voucher" - ], - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json" + "requestNonce": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", + "requirement": "https://id.registrystack.org/solmara/requirement/nagdi-voucher/v1", + "purpose": "voucher-eligibility-review", + "subjects": [{ + "role": "subject", + "selector": { + "profile": "farmer-reference-v1", + "values": {"farmer_id": "FR-1001"} + } + }] } } diff --git a/requests/registry-lab/40 - NAgDI Voucher/02 - Livestock movement control.bru b/requests/registry-lab/40 - NAgDI Voucher/02 - Livestock movement control.bru index 3319b6c..b5565df 100644 --- a/requests/registry-lab/40 - NAgDI Voucher/02 - Livestock movement control.bru +++ b/requests/registry-lab/40 - NAgDI Voucher/02 - Livestock movement control.bru @@ -5,32 +5,27 @@ meta { } post { - url: {{nagdi_notary_url}}/v1/evaluations + url: {{nagdi_evidence_url}}/v1/evidence body: json auth: none } headers { - x-api-key: {{NAGDI_NOTARY_TOKEN}} - Data-Purpose: {{livestock_purpose}} - Accept: application/vnd.registry-notary.claim-result+json + Authorization: Bearer {{EVIDENCE_ACCESS_TOKEN}} + Accept: application/jose+json } body:json { { - "target": { - "type": "Person", - "identifiers": [ - { - "scheme": "farmer_id", - "value": "FR-1001" - } - ] - }, - "claims": [ - "eligible-for-livestock-movement-permit" - ], - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json" + "requestNonce": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", + "requirement": "https://id.registrystack.org/solmara/requirement/nagdi-livestock/v1", + "purpose": "livestock-movement-control", + "subjects": [{ + "role": "subject", + "selector": { + "profile": "farmer-reference-v1", + "values": {"farmer_id": "FR-1001"} + } + }] } } diff --git a/requests/registry-lab/50 - Relay V2/01 - CRA death lookup.bru b/requests/registry-lab/50 - Relay V2/01 - CRA death lookup.bru new file mode 100644 index 0000000..be902fd --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/01 - CRA death lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 01 - CRA death lookup + type: http + seq: 1 +} + +post { + url: {{cra_relay_url}}/v2/resources/civil-person/lookups/death-by-uin?fields=deceased + body: json + auth: none +} + +headers { + Authorization: Bearer {{CRA_PENSION_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"uin":"2300109568"}} +} diff --git a/requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru b/requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru new file mode 100644 index 0000000..c2c5d0b --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 02 - NIA eSignet lookup + type: http + seq: 2 +} + +post { + url: {{nia_relay_url}}/v2/resources/population-person/lookups/esignet-userinfo?fields=individualId,givenName,familyName,gender,birthdate,active + body: json + auth: none +} + +headers { + Authorization: Bearer {{NIA_ESIGNET_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"uin":"2300010248"}} +} diff --git a/requests/registry-lab/50 - Relay V2/03 - MoSD enrolment lookup.bru b/requests/registry-lab/50 - Relay V2/03 - MoSD enrolment lookup.bru new file mode 100644 index 0000000..39e6c06 --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/03 - MoSD enrolment lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 03 - MoSD enrolment lookup + type: http + seq: 3 +} + +post { + url: {{mosd_relay_url}}/v2/resources/beneficiary-enrolment/lookups/by-uin?fields=duplicateEnrolment + body: json + auth: none +} + +headers { + Authorization: Bearer {{MOSD_CHILD_BENEFIT_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"uin":"2300010248"}} +} diff --git a/requests/registry-lab/50 - Relay V2/04 - SIPF pension lookup.bru b/requests/registry-lab/50 - Relay V2/04 - SIPF pension lookup.bru new file mode 100644 index 0000000..0a6d09d --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/04 - SIPF pension lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 04 - SIPF pension lookup + type: http + seq: 4 +} + +post { + url: {{sipf_relay_url}}/v2/resources/pension-payment/lookups/by-pensioner-uin?fields=paymentStatus + body: json + auth: none +} + +headers { + Authorization: Bearer {{SIPF_PENSION_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"pensionerUin":"2300109568"}} +} diff --git a/requests/registry-lab/50 - Relay V2/05 - NAgDI voucher lookup.bru b/requests/registry-lab/50 - Relay V2/05 - NAgDI voucher lookup.bru new file mode 100644 index 0000000..4eebc4b --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/05 - NAgDI voucher lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 05 - NAgDI voucher lookup + type: http + seq: 5 +} + +post { + url: {{nagdi_relay_url}}/v2/resources/farmer/lookups/voucher-by-farmer-id?fields=farmerRegistered,dataUseAuthorized,activeSmallholderFarmer,activeFarmParcel,cropDeclaredForSeason,districtClimateRiskActive,voucherEntitlementCurrent,voucherNotRedeemed + body: json + auth: none +} + +headers { + Authorization: Bearer {{NAGDI_VOUCHER_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"farmerId":"FR-000001"}} +} diff --git a/requests/registry-lab/50 - Relay V2/06 - CRA citizen-link lookup.bru b/requests/registry-lab/50 - Relay V2/06 - CRA citizen-link lookup.bru new file mode 100644 index 0000000..f0bf1f5 --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/06 - CRA citizen-link lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 06 - CRA citizen-link lookup + type: http + seq: 6 +} + +post { + url: {{cra_relay_url}}/v2/resources/civil-person/lookups/citizen-link-by-uin?fields=linkedUin + body: json + auth: none +} + +headers { + Authorization: Bearer {{CRA_CITIZEN_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"uin":"2300010248"}} +} diff --git a/requests/registry-lab/50 - Relay V2/07 - SIPF survivor lookup.bru b/requests/registry-lab/50 - Relay V2/07 - SIPF survivor lookup.bru new file mode 100644 index 0000000..42b8eb3 --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/07 - SIPF survivor lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 07 - SIPF survivor lookup + type: http + seq: 7 +} + +post { + url: {{sipf_relay_url}}/v2/resources/survivor-case/lookups/by-spouse-uin?fields=survivorEligible + body: json + auth: none +} + +headers { + Authorization: Bearer {{SIPF_SURVIVOR_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"spouseUin":"2300118698"}} +} diff --git a/requests/registry-lab/50 - Relay V2/08 - NAgDI livestock lookup.bru b/requests/registry-lab/50 - Relay V2/08 - NAgDI livestock lookup.bru new file mode 100644 index 0000000..e24e1ec --- /dev/null +++ b/requests/registry-lab/50 - Relay V2/08 - NAgDI livestock lookup.bru @@ -0,0 +1,20 @@ +meta { + name: 08 - NAgDI livestock lookup + type: http + seq: 8 +} + +post { + url: {{nagdi_relay_url}}/v2/resources/livestock-herd/lookups/movement-by-farmer-id?fields=registeredHerd,herdVaccinationCurrent,originDistrictNotQuarantinedForSpecies,destinationDistrictOpen,noConflictingOpenMovementPermit + body: json + auth: none +} + +headers { + Authorization: Bearer {{NAGDI_LIVESTOCK_RELAY_TOKEN}} + Accept: application/json +} + +body:json { + {"selectors":{"farmerId":"FR-000002"}} +} diff --git a/requests/registry-lab/README.md b/requests/registry-lab/README.md index bb52fd3..9e4e65a 100644 --- a/requests/registry-lab/README.md +++ b/requests/registry-lab/README.md @@ -1,11 +1,27 @@ -# Solmara Lab API Workspace +# Solmara Lab API workspace -Wave 1 placeholders for the three Solmara stories: +The examples exercise the authority-owned reset: -1. Birth to child benefit. -2. Death to pension stop plus survivor benefit. -3. Farmer climate-smart voucher plus livestock movement control. +1. The child-benefit programme composes four independently signed assertions. +2. CRA and SIPF issue separate pension and survivor assertions. +3. NAgDI issues separate voucher and livestock assertions. +4. The Relay V2 folder demonstrates only the eight governed exact lookups used + by Evidence and the optional NIA eSignet profile. -Set the environment URLs and bearer tokens once compose or hosted deployment -generates the actual service endpoints. No partner-system or governed-ops -folders are part of this wave 1 surface. +Set `EVIDENCE_ACCESS_TOKEN` to a short-lived Registry Mint token for the +authority Evidence endpoints. Set `CHILD_BENEFIT_FEDERATOR_TOKEN` only when +calling the programme application. Purpose is part of each Evidence request or +programme request body. It is not an ambient HTTP header. + +Each Relay request uses a different placeholder token variable because Mint +fixes the scope and purpose claim per client. The example selectors identify +synthetic lab fixtures only. Relay responses remain no-store and disclose only +the explicitly requested governed fields. + +Every direct Evidence response is a flattened ES256 JWS. Verify it against the +JWKS of the authority host that received the request. The examples contain no +real-person selector, token value, source row, private audit data, or signing +key. + +The fixed request nonces are readable examples. Replace each one with 32 random +bytes encoded as unpadded base64url before using the collection outside the lab. diff --git a/requests/registry-lab/environments/Hosted Lab.bru b/requests/registry-lab/environments/Hosted Lab.bru index fa2b1ac..42df55b 100644 --- a/requests/registry-lab/environments/Hosted Lab.bru +++ b/requests/registry-lab/environments/Hosted Lab.bru @@ -1,16 +1,25 @@ vars { child_benefit_federator_url: https://child-benefit-federator.solmara.registrystack.org - cra_notary_url: https://cra-notary.solmara.registrystack.org - sipf_notary_url: https://sipf-notary.solmara.registrystack.org - nagdi_notary_url: https://nagdi-notary.solmara.registrystack.org + cra_evidence_url: https://cra-evidence.solmara.registrystack.org + nia_evidence_url: https://nia-evidence.solmara.registrystack.org + sro_evidence_url: https://sro-evidence.solmara.registrystack.org + mosd_programme_evidence_url: https://mosd-programme-evidence.solmara.registrystack.org + sipf_evidence_url: https://sipf-evidence.solmara.registrystack.org + nagdi_evidence_url: https://nagdi-evidence.solmara.registrystack.org + cra_relay_url: https://cra-relay.solmara.registrystack.org + nia_relay_url: https://nia-relay.solmara.registrystack.org + mosd_relay_url: https://mosd-programme-relay.solmara.registrystack.org + sipf_relay_url: https://sipf-relay.solmara.registrystack.org + nagdi_relay_url: https://nagdi-relay.solmara.registrystack.org metadata_url: https://metadata.solmara.registrystack.org - child_benefit_purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - pension_payment_purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - survivor_benefit_purpose: https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - voucher_purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - livestock_purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control CHILD_BENEFIT_FEDERATOR_TOKEN: - CRA_PENSION_CLIENT_TOKEN: - SIPF_PENSION_CLIENT_TOKEN: - NAGDI_NOTARY_TOKEN: + EVIDENCE_ACCESS_TOKEN: + CRA_PENSION_RELAY_TOKEN: + CRA_CITIZEN_RELAY_TOKEN: + NIA_ESIGNET_RELAY_TOKEN: + MOSD_CHILD_BENEFIT_RELAY_TOKEN: + SIPF_PENSION_RELAY_TOKEN: + SIPF_SURVIVOR_RELAY_TOKEN: + NAGDI_VOUCHER_RELAY_TOKEN: + NAGDI_LIVESTOCK_RELAY_TOKEN: } diff --git a/requests/registry-lab/environments/Local Compose.bru b/requests/registry-lab/environments/Local Compose.bru index e274201..b7a784f 100644 --- a/requests/registry-lab/environments/Local Compose.bru +++ b/requests/registry-lab/environments/Local Compose.bru @@ -1,16 +1,25 @@ vars { child_benefit_federator_url: http://127.0.0.1:4321 - cra_notary_url: http://127.0.0.1:4325 - sipf_notary_url: http://127.0.0.1:4322 - nagdi_notary_url: http://127.0.0.1:4323 + cra_evidence_url: https://localhost:4341/evidence/cra + nia_evidence_url: https://localhost:4341/evidence/nia + sro_evidence_url: https://localhost:4341/evidence/sro + mosd_programme_evidence_url: https://localhost:4341/evidence/mosd-programme + sipf_evidence_url: https://localhost:4341/evidence/sipf + nagdi_evidence_url: https://localhost:4341/evidence/nagdi + cra_relay_url: http://127.0.0.1:4311 + nia_relay_url: http://127.0.0.1:4312 + mosd_relay_url: http://127.0.0.1:4314 + sipf_relay_url: http://127.0.0.1:4315 + nagdi_relay_url: http://127.0.0.1:4316 metadata_url: http://127.0.0.1:4331 - child_benefit_purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - pension_payment_purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - survivor_benefit_purpose: https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - voucher_purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - livestock_purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control CHILD_BENEFIT_FEDERATOR_TOKEN: - CRA_PENSION_CLIENT_TOKEN: - SIPF_PENSION_CLIENT_TOKEN: - NAGDI_NOTARY_TOKEN: + EVIDENCE_ACCESS_TOKEN: + CRA_PENSION_RELAY_TOKEN: + CRA_CITIZEN_RELAY_TOKEN: + NIA_ESIGNET_RELAY_TOKEN: + MOSD_CHILD_BENEFIT_RELAY_TOKEN: + SIPF_PENSION_RELAY_TOKEN: + SIPF_SURVIVOR_RELAY_TOKEN: + NAGDI_VOUCHER_RELAY_TOKEN: + NAGDI_LIVESTOCK_RELAY_TOKEN: } diff --git a/runtime/registry-projects/hosted/cra-civil/notary/notary.yaml b/runtime/registry-projects/hosted/cra-civil/notary/notary.yaml deleted file mode 100644 index f5453e2..0000000 --- a/runtime/registry-projects/hosted/cra-civil/notary/notary.yaml +++ /dev/null @@ -1,233 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - evidence:cra:child-benefit:read - - fingerprint: - name: CRA_CITIZEN_CLIENT_TOKEN_HASH - provider: env - id: citizen-portal - scopes: - - evidence:cra:citizen:read - - fingerprint: - name: CRA_PENSION_CLIENT_TOKEN_HASH - provider: env - id: pension-review-desk - scopes: - - evidence:cra:pension:read -deployment: - profile: hosted_lab -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - - https://id.registrystack.org/solmara/purpose/citizen-self-service - - https://id.registrystack.org/solmara/purpose/pension-payment-review - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - id: solmara-cra-civil.cra-child-benefit.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: birth-is-registered - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - evidence:cra:child-benefit:read - rule: - bindings: {} - expression: 'civil.matched && civil.birth_brn != null ? civil.birth_brn != '''' : false' - type: cel - subject_type: person - title: birth is registered - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - id: solmara-cra-civil.cra-child-benefit.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: child-age-under-5 - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - evidence:cra:child-benefit:read - rule: - bindings: {} - expression: |- - civil.matched && civil.birth_date != null && civil.deceased == false - ? date.age_on(civil.birth_date, as_of_date) >= 0 - && date.age_on(civil.birth_date, as_of_date) < 5 - : false - type: cel - subject_type: person - title: child age under 5 - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:c101d11d7fe66efe8400330851d65e4e105be62eae6ae9325eb43a83daae8aed - id: solmara-cra-civil.cra-citizen-record.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: civil-record-linked - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service - required_scopes: - - evidence:cra:citizen:read - rule: - bindings: {} - expression: civil.matched - type: cel - subject_type: person - title: civil record linked - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:80486f20e013dd4a99af89a22efe3c83638f37f3f79ea7c912a8d5b237db2924 - id: solmara-cra-civil.cra-pension-death.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: person-is-deceased - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - required_scopes: - - evidence:cra:pension:read - rule: - bindings: {} - expression: civil.matched && civil.deceased == true - type: cel - subject_type: person - title: person is deceased - value: - nullable: false - type: boolean - version: '1' - credential_profiles: {} - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/cra-notary-relay-token - workload_client_id: cra-notary - service_id: cra-notary - variables: - as_of_date: - from: request.variables.as_of_date - type: date -instance: - environment: hosted - id: cra-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-child-benefit-civil.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-child-benefit-civil.json deleted file mode 100644 index aad610b..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-child-benefit-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-child-benefit.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:9ec50673e36783484c8e35137bd65396a871ecc12c2e8fdd2cc68b1f0cab108b","id":"relay.cra-child-benefit.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:cra-child-benefit","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-citizen-record-civil.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-citizen-record-civil.json deleted file mode 100644 index f0ef1ff..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-citizen-record-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-citizen-record.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"citizen-self-service","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:ddf7a76ffe3dabacabdc1df18588bb87826a00a061572ad49c5052baf073f576","id":"relay.cra-citizen-record.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/citizen-self-service"],"required_scope":"registry:consult:cra-citizen-record","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-pension-death-civil.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-pension-death-civil.json deleted file mode 100644 index 2f64031..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/consultation-contracts/cra-pension-death-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-pension-death.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:dd59f03e034f68b35706ae6193bdd580081f2b596c6b92673fc7a66961e34fc1","id":"relay.cra-pension-death.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/pension-payment-review"],"required_scope":"registry:consult:cra-pension-death","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/conformance.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/conformance.json deleted file mode 100644 index 9e550c6..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"birth-is-registered":true,"child-age-under-5":false,"civil-record-linked":true,"person-is-deceased":false},"error":null,"outcome":"match","outputs":{"birth_brn":"BRN-2027-0101-00001","birth_date":"2027-03-14","deceased":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"birth_brn":"BRN-2027-0101-00001","birth_date":"2027-03-14","deceased":false},"headers":{},"status":200}}}],"name":"cra-civil-person-future-birth-date","request":{"claims":[{"id":"birth-is-registered"},{"id":"child-age-under-5"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"},"variables":{"as_of_date":"2026-07-14"}},"variables":{"as_of_date":"2026-07-14"}},{"classification":"synthetic","expect":{"claims":{"birth-is-registered":true,"child-age-under-5":true,"civil-record-linked":true,"person-is-deceased":false},"error":null,"outcome":"match","outputs":{"birth_brn":"BRN-2022-0101-00001","birth_date":"2022-03-14","deceased":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"birth_brn":"BRN-2022-0101-00001","birth_date":"2022-03-14","deceased":false},"headers":{},"status":200}}}],"name":"cra-civil-person-match","request":{"claims":[{"id":"civil-record-linked"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/citizen-self-service","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{"as_of_date":"2026-07-14"}},{"classification":"synthetic","expect":{"claims":{"birth-is-registered":false,"child-age-under-5":false,"civil-record-linked":false,"person-is-deceased":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"cra-civil-person-no-match","request":{"claims":[{"id":"person-is-deceased"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/pension-payment-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"9999999999"}],"type":"Person"}},"variables":{"as_of_date":"2026-07-14"}}],"integration":"solmara-cra-civil-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/minimization.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/minimization.json deleted file mode 100644 index 9d92e3c..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-cra-civil-person-by-uin","operations":{},"outputs":{"birth_brn":{"from":"snapshot.record.birth_brn","max_bytes":128,"maximum":null,"minimum":null,"nullable":true,"type":"string"},"birth_date":{"from":"snapshot.record.birth_date","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"date"},"deceased":{"from":"snapshot.record.deceased","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/negative-security.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/negative-security.json deleted file mode 100644 index 18ba42a..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/evidence/civil-person-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-cra-civil-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/integration-packs/civil-person-by-uin.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/integration-packs/civil-person-by-uin.json deleted file mode 100644 index 5533854..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/integration-packs/civil-person-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd"],"minimization":["sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263"],"negative_security":["sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-cra-civil-person-by-uin","output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-child-benefit-civil.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-child-benefit-civil.json deleted file mode 100644 index 329227a..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-child-benefit-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-child-benefit.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-citizen-record-civil.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-citizen-record-civil.json deleted file mode 100644 index ea8fb47..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-citizen-record-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-citizen-record.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-pension-death-civil.json b/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-pension-death-civil.json deleted file mode 100644 index 5b5ec3b..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/artifacts/private-bindings/cra-pension-death-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-pension-death.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/cra-civil/relay/relay-consultation.yaml b/runtime/registry-projects/hosted/cra-civil/relay/relay-consultation.yaml deleted file mode 100644 index e82fd3d..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/relay-consultation.yaml +++ /dev/null @@ -1,138 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - cra-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://cra-relay.solmara.registrystack.org - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/civil-person-by-uin/conformance.json - sha256: sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd - - class: negative_security - path: artifacts/evidence/civil-person-by-uin/negative-security.json - sha256: sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f - - class: minimization - path: artifacts/evidence/civil-person-by-uin/minimization.json - sha256: sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263 - integration_packs: - - hash: sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499 - path: artifacts/integration-packs/civil-person-by-uin.json - sha256: sha256:ad85a4bbcbd9c164264afeb98c11c549cc0c54997b39572261282211ab9104b2 - private_bindings: - - hash: sha256:1fb0b8c2b28da70d6f22675209ced135f4855fcfdc2fd38a32aadaa0c70be0dd - path: artifacts/private-bindings/cra-child-benefit-civil.json - sha256: sha256:087d5ddfc5bbc43620f9bf499cda7343ff0b762427451f30fd3541bb7d230e22 - - hash: sha256:918b533b198e4e45e368db0b3d8737728700f4b6ee428e10a93a1a06460649b1 - path: artifacts/private-bindings/cra-citizen-record-civil.json - sha256: sha256:d564828b5a94041f3bb718c16b40a0a53ea68a94ed917bf96e5ed04f0ec8d086 - - hash: sha256:fbac0434f957c897098a1577a20ae8763258b1a1e2385a577129258dbbe86579 - path: artifacts/private-bindings/cra-pension-death-civil.json - sha256: sha256:f4eea1f1f2df7755c729d0a645778ca9805e15e3875962595c0038cc46142f51 - public_contracts: - - hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - path: artifacts/consultation-contracts/cra-child-benefit-civil.json - sha256: sha256:65bfb5000e186b4e461c392454e01c76149e821551152ec79a22f28b1e15f6b1 - - hash: sha256:c101d11d7fe66efe8400330851d65e4e105be62eae6ae9325eb43a83daae8aed - path: artifacts/consultation-contracts/cra-citizen-record-civil.json - sha256: sha256:ffb8a94778c5d1015c14ecc03fd06d7f2b2d5926aca0654cc80887d930bf74d2 - - hash: sha256:80486f20e013dd4a99af89a22efe3c83638f37f3f79ea7c912a8d5b237db2924 - path: artifacts/consultation-contracts/cra-pension-death-civil.json - sha256: sha256:2fbbfadc074822821239ff13ead71115d7d64c6378e39c4d03a870eb3b6655ea - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: cra-notary - principal_id: cra-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 5222333210205388 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 3104283851243516304 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: cra-civil-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/cra-civil/relay/relay.yaml b/runtime/registry-projects/hosted/cra-civil/relay/relay.yaml deleted file mode 100644 index 66bd9da..0000000 --- a/runtime/registry-projects/hosted/cra-civil/relay/relay.yaml +++ /dev/null @@ -1,83 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: [] - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://cra-relay.solmara.registrystack.org - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: cra-civil-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/mosd-programme/notary/notary.yaml b/runtime/registry-projects/hosted/mosd-programme/notary/notary.yaml deleted file mode 100644 index 6dd83e0..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/notary/notary.yaml +++ /dev/null @@ -1,76 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - mosd_programme:evidence_verification -deployment: - profile: hosted_lab -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - enrollment: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - duplicate_flag: - nullable: false - type: boolean - profile: - contract_hash: sha256:9da47bc7171991c51e098b44cb0667595b344d5f4c2b994a67f0c3b401d4b5b3 - id: solmara-mosd-programme.child-benefit.enrollment - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: not-already-enrolled - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - mosd_programme:evidence_verification - rule: - bindings: {} - expression: enrollment.matched && enrollment.duplicate_flag == false - type: cel - subject_type: person - title: not already enrolled - value: - nullable: false - type: boolean - version: '1' - credential_profiles: {} - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/programme-notary-relay-token - workload_client_id: programme-notary - service_id: programme-notary - variables: {} -instance: - environment: hosted - id: programme-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/consultation-contracts/child-benefit-enrollment.json b/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/consultation-contracts/child-benefit-enrollment.json deleted file mode 100644 index bc41921..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/consultation-contracts/child-benefit-enrollment.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-mosd-programme.child-benefit.enrollment","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:50a4f9fd40526b837aa90b02d27e13e98f66d9de1362eb06fb2465afbae31c53","id":"relay.child-benefit.enrollment","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:child-benefit","workload":"programme-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-mosd-programme.enrollment-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["duplicate_flag"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":67108864,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/conformance.json b/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/conformance.json deleted file mode 100644 index 6910195..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"not-already-enrolled":true},"error":null,"outcome":"match","outputs":{"duplicate_flag":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"duplicate_flag":false},"headers":{},"status":200}}}],"name":"enrollment-match","request":{"claims":[{"id":"not-already-enrolled"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"not-already-enrolled":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"2300999997"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"enrollment-no-match","request":null,"variables":{}}],"integration":"enrollment-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/minimization.json b/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/minimization.json deleted file mode 100644 index 1d539e7..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"enrollment-by-uin","operations":{},"outputs":{"duplicate_flag":{"from":"snapshot.record.duplicate_flag","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/negative-security.json b/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/negative-security.json deleted file mode 100644 index 0b38f83..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"enrollment-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/integration-packs/enrollment-by-uin.json b/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/integration-packs/enrollment-by-uin.json deleted file mode 100644 index 729f6a0..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/integration-packs/enrollment-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-mosd-programme.enrollment-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8"],"minimization":["sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83"],"negative_security":["sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"enrollment-by-uin","output":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/private-bindings/child-benefit-enrollment.json b/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/private-bindings/child-benefit-enrollment.json deleted file mode 100644 index 078b94e..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/artifacts/private-bindings/child-benefit-enrollment.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8","id":"solmara-mosd-programme.enrollment-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"duplicate_flag":"duplicate_flag"}},"table_provider":"mosd_enroll__materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf"},"profile":{"id":"solmara-mosd-programme.child-benefit.enrollment","version":"1"},"registry_instance":"solmara-mosd-programme","source_instance":"materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf","tenant":"solmara-mosd-programme"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/relay-consultation.yaml b/runtime/registry-projects/hosted/mosd-programme/relay/relay-consultation.yaml deleted file mode 100644 index 63d65e5..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/relay-consultation.yaml +++ /dev/null @@ -1,118 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://mosd-programme-relay.solmara.registrystack.org - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/enrollment-by-uin/minimization.json - sha256: sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83 - - class: negative_security - path: artifacts/evidence/enrollment-by-uin/negative-security.json - sha256: sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282 - - class: conformance - path: artifacts/evidence/enrollment-by-uin/conformance.json - sha256: sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8 - integration_packs: - - hash: sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8 - path: artifacts/integration-packs/enrollment-by-uin.json - sha256: sha256:40ac75a61c3346b282e3abadd16654b634ebe0ac60c77a7b4c2ac98cf5f3af50 - private_bindings: - - hash: sha256:ae3674a401a41daa3325d73cd22363dba2d4c5145ae22cdc64587a6e0a293654 - path: artifacts/private-bindings/child-benefit-enrollment.json - sha256: sha256:fd8fc27359128d39dbed4cd6842d7db21f3b5eef21359f0b6bec93c0f6822f18 - public_contracts: - - hash: sha256:9da47bc7171991c51e098b44cb0667595b344d5f4c2b994a67f0c3b401d4b5b3 - path: artifacts/consultation-contracts/child-benefit-enrollment.json - sha256: sha256:253ee934a9c9bbf725e233f90c195d057893eeea14bc9bc12330c0957245ff10 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: programme-notary - principal_id: programme-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 9069287079765840165 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 891105660075992367 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: programme-mis-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/mosd-programme/relay/relay.yaml b/runtime/registry-projects/hosted/mosd-programme/relay/relay.yaml deleted file mode 100644 index 915fc47..0000000 --- a/runtime/registry-projects/hosted/mosd-programme/relay/relay.yaml +++ /dev/null @@ -1,76 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://mosd-programme-relay.solmara.registrystack.org - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: programme-mis-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml b/runtime/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml deleted file mode 100644 index 00b03fb..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/notary/notary.yaml +++ /dev/null @@ -1,412 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: NAGDI_CLIENT_TOKEN_HASH - provider: env - id: nagdi-service - scopes: - - nagdi_agriculture:evidence_verification -deployment: - profile: hosted_lab -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/livestock-movement-control - - https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - claims: - - credential_profiles: - - livestock.livestock_movement_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - herd: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - destination_district_open: - nullable: false - type: boolean - herd_vaccination_current: - nullable: false - type: boolean - no_conflicting_open_movement_permit: - nullable: false - type: boolean - origin_district_not_quarantined_for_species: - nullable: false - type: boolean - registered_herd: - nullable: false - type: boolean - profile: - contract_hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - id: solmara-nagdi-agriculture.livestock.herd - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: eligible-for-livestock-movement-permit - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: |- - herd.matched && herd.registered_herd - && herd.origin_district_not_quarantined_for_species - && herd.herd_vaccination_current - && herd.destination_district_open - && herd.no_conflicting_open_movement_permit - type: cel - subject_type: person - title: eligible for livestock movement permit - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - livestock.livestock_movement_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - herd: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - destination_district_open: - nullable: false - type: boolean - herd_vaccination_current: - nullable: false - type: boolean - no_conflicting_open_movement_permit: - nullable: false - type: boolean - origin_district_not_quarantined_for_species: - nullable: false - type: boolean - registered_herd: - nullable: false - type: boolean - profile: - contract_hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - id: solmara-nagdi-agriculture.livestock.herd - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: origin-district-not-quarantined-for-species - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: herd.matched && herd.origin_district_not_quarantined_for_species - type: cel - subject_type: person - title: origin district not quarantined for species - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - livestock.livestock_movement_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - herd: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - destination_district_open: - nullable: false - type: boolean - herd_vaccination_current: - nullable: false - type: boolean - no_conflicting_open_movement_permit: - nullable: false - type: boolean - origin_district_not_quarantined_for_species: - nullable: false - type: boolean - registered_herd: - nullable: false - type: boolean - profile: - contract_hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - id: solmara-nagdi-agriculture.livestock.herd - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: registered-herd - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: herd.matched && herd.registered_herd - type: cel - subject_type: person - title: registered herd - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - voucher.climate_smart_voucher_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - farmer: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - active_farm_parcel: - nullable: false - type: boolean - active_smallholder_farmer: - nullable: false - type: boolean - crop_declared_for_season: - nullable: false - type: boolean - data_use_authorized: - nullable: false - type: boolean - district_climate_risk_active: - nullable: false - type: boolean - farmer_registered: - nullable: false - type: boolean - voucher_entitlement_current: - nullable: false - type: boolean - voucher_not_redeemed: - nullable: false - type: boolean - profile: - contract_hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - id: solmara-nagdi-agriculture.voucher.farmer - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: data-use-authorized-for-purpose - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: farmer.matched && farmer.data_use_authorized - type: cel - subject_type: person - title: data use authorized for purpose - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - voucher.climate_smart_voucher_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - farmer: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - active_farm_parcel: - nullable: false - type: boolean - active_smallholder_farmer: - nullable: false - type: boolean - crop_declared_for_season: - nullable: false - type: boolean - data_use_authorized: - nullable: false - type: boolean - district_climate_risk_active: - nullable: false - type: boolean - farmer_registered: - nullable: false - type: boolean - voucher_entitlement_current: - nullable: false - type: boolean - voucher_not_redeemed: - nullable: false - type: boolean - profile: - contract_hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - id: solmara-nagdi-agriculture.voucher.farmer - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: eligible-for-climate-smart-input-voucher - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: |- - farmer.matched && farmer.farmer_registered - && farmer.data_use_authorized - && farmer.active_smallholder_farmer - && farmer.active_farm_parcel - && farmer.crop_declared_for_season - && farmer.district_climate_risk_active - && farmer.voucher_entitlement_current - && farmer.voucher_not_redeemed - type: cel - subject_type: person - title: eligible for climate smart input voucher - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - voucher.climate_smart_voucher_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - farmer: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - active_farm_parcel: - nullable: false - type: boolean - active_smallholder_farmer: - nullable: false - type: boolean - crop_declared_for_season: - nullable: false - type: boolean - data_use_authorized: - nullable: false - type: boolean - district_climate_risk_active: - nullable: false - type: boolean - farmer_registered: - nullable: false - type: boolean - voucher_entitlement_current: - nullable: false - type: boolean - voucher_not_redeemed: - nullable: false - type: boolean - profile: - contract_hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - id: solmara-nagdi-agriculture.voucher.farmer - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: farmer-registered - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: farmer.matched && farmer.farmer_registered - type: cel - subject_type: person - title: farmer registered - value: - nullable: false - type: boolean - version: '1' - credential_profiles: - livestock.livestock_movement_sd_jwt: - allowed_claims: - - registered-herd - - origin-district-not-quarantined-for-species - - eligible-for-livestock-movement-permit - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:nagdi - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/livestock-movement-permit - voucher.climate_smart_voucher_sd_jwt: - allowed_claims: - - farmer-registered - - data-use-authorized-for-purpose - - eligible-for-climate-smart-input-voucher - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:nagdi - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/climate-smart-voucher-eligibility - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/nagdi-notary-relay-token - workload_client_id: nagdi-notary - service_id: nagdi-notary - signing_keys: - project-issuer: - alg: EdDSA - kid: did:web:id.registrystack.org:solmara:authority:nagdi#issuer-key-1 - private_jwk_env: NAGDI_NOTARY_ISSUER_JWK - provider: local_jwk_env - status: active - variables: {} -instance: - environment: hosted - id: nagdi-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/consultation-contracts/livestock-herd.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/consultation-contracts/livestock-herd.json deleted file mode 100644 index adf39dd..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/consultation-contracts/livestock-herd.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.livestock.herd","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"agricultural-data-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:05746b9128c73f3a079b88d4a6a5c392ea4d317755aa8e0771e0c83325a16b28","id":"relay.livestock.herd","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/livestock-movement-control"],"required_scope":"registry:consult:livestock","workload":"nagdi-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"integration":{"id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["destination_district_open","herd_vaccination_current","no_conflicting_open_movement_permit","origin_district_not_quarantined_for_species","registered_herd"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":134217728,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":604800000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/consultation-contracts/voucher-farmer.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/consultation-contracts/voucher-farmer.json deleted file mode 100644 index 3f414ad..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/consultation-contracts/voucher-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.voucher.farmer","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"agricultural-data-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:6c9b13e328828747f1d32991be3ab0681112c283dae9f614a7187c8ca0d72a01","id":"relay.voucher.farmer","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"],"required_scope":"registry:consult:voucher","workload":"nagdi-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"integration":{"id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["active_farm_parcel","active_smallholder_farmer","crop_declared_for_season","data_use_authorized","district_climate_risk_active","farmer_registered","voucher_entitlement_current","voucher_not_redeemed"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":134217728,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":604800000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/conformance.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/conformance.json deleted file mode 100644 index 4743085..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"data-use-authorized-for-purpose":true,"eligible-for-climate-smart-input-voucher":true,"farmer-registered":true},"error":null,"outcome":"match","outputs":{"active_farm_parcel":true,"active_smallholder_farmer":true,"crop_declared_for_season":true,"data_use_authorized":true,"district_climate_risk_active":true,"farmer_registered":true,"voucher_entitlement_current":true,"voucher_not_redeemed":true}},"input":{"farmer_id":"FR-1001"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"active_farm_parcel":true,"active_smallholder_farmer":true,"crop_declared_for_season":true,"data_use_authorized":true,"district_climate_risk_active":true,"farmer_registered":true,"voucher_entitlement_current":true,"voucher_not_redeemed":true},"headers":{},"status":200}}}],"name":"farmer-voucher-match","request":{"claims":[{"id":"farmer-registered"},{"id":"data-use-authorized-for-purpose"},{"id":"eligible-for-climate-smart-input-voucher"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/voucher-eligibility-review","target":{"identifiers":[{"scheme":"farmer_id","value":"FR-1001"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"data-use-authorized-for-purpose":false,"eligible-for-climate-smart-input-voucher":false,"farmer-registered":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"farmer_id":"FR-9999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"farmer-voucher-no-match","request":null,"variables":{}}],"integration":"farmer-voucher-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/minimization.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/minimization.json deleted file mode 100644 index 59e808a..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"farmer-voucher-by-farmer","operations":{},"outputs":{"active_farm_parcel":{"from":"snapshot.record.active_farm_parcel","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"from":"snapshot.record.active_smallholder_farmer","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"crop_declared_for_season":{"from":"snapshot.record.crop_declared_for_season","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"data_use_authorized":{"from":"snapshot.record.data_use_authorized","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"district_climate_risk_active":{"from":"snapshot.record.district_climate_risk_active","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"farmer_registered":{"from":"snapshot.record.farmer_registered","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"from":"snapshot.record.voucher_entitlement_current","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"from":"snapshot.record.voucher_not_redeemed","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json deleted file mode 100644 index 7dfbf8c..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"farmer-voucher-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/conformance.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/conformance.json deleted file mode 100644 index 25a9c51..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"farmer_id":"FR-8888"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true},{"destination_district_open":false,"herd_vaccination_current":false,"no_conflicting_open_movement_permit":false,"origin_district_not_quarantined_for_species":false,"registered_herd":false}],"headers":{},"status":200}}}],"name":"livestock-movement-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"eligible-for-livestock-movement-permit":true,"origin-district-not-quarantined-for-species":true,"registered-herd":true},"error":null,"outcome":"match","outputs":{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true}},"input":{"farmer_id":"FR-1001"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true},"headers":{},"status":200}}}],"name":"livestock-movement-match","request":{"claims":[{"id":"registered-herd"},{"id":"origin-district-not-quarantined-for-species"},{"id":"eligible-for-livestock-movement-permit"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/livestock-movement-control","target":{"identifiers":[{"scheme":"farmer_id","value":"FR-1001"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"eligible-for-livestock-movement-permit":false,"origin-district-not-quarantined-for-species":false,"registered-herd":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"farmer_id":"FR-9999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"livestock-movement-no-match","request":null,"variables":{}}],"integration":"livestock-movement-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/minimization.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/minimization.json deleted file mode 100644 index 6768a09..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"livestock-movement-by-farmer","operations":{},"outputs":{"destination_district_open":{"from":"snapshot.record.destination_district_open","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"herd_vaccination_current":{"from":"snapshot.record.herd_vaccination_current","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"from":"snapshot.record.no_conflicting_open_movement_permit","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"from":"snapshot.record.origin_district_not_quarantined_for_species","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"registered_herd":{"from":"snapshot.record.registered_herd","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/negative-security.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/negative-security.json deleted file mode 100644 index a823e60..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"livestock-movement-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/integration-packs/farmer-voucher-by-farmer.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/integration-packs/farmer-voucher-by-farmer.json deleted file mode 100644 index ff96512..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/integration-packs/farmer-voucher-by-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52"],"minimization":["sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b"],"negative_security":["sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d"]},"input_slots":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"logical_operation":"farmer-voucher-by-farmer","output":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":604800000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"farmer_id":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/integration-packs/livestock-movement-by-farmer.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/integration-packs/livestock-movement-by-farmer.json deleted file mode 100644 index 043c50a..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/integration-packs/livestock-movement-by-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910"],"minimization":["sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5"],"negative_security":["sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce"]},"input_slots":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"logical_operation":"livestock-movement-by-farmer","output":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":604800000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"farmer_id":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/private-bindings/livestock-herd.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/private-bindings/livestock-herd.json deleted file mode 100644 index 7db8adf..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/private-bindings/livestock-herd.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092","id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"farmer_id":{"comparison":"binary_equality","input":"farmer_id","physical_field":"farmer_id","physical_type":"utf8"}},"projection":{"destination_district_open":"destination_district_open","herd_vaccination_current":"herd_vaccination_current","no_conflicting_open_movement_permit":"no_conflicting_open_movement_permit","origin_district_not_quarantined_for_species":"origin_district_not_quarantined_for_species","registered_herd":"registered_herd"}},"table_provider":"nagdi_herd__materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b"},"profile":{"id":"solmara-nagdi-agriculture.livestock.herd","version":"1"},"registry_instance":"solmara-nagdi-agriculture","source_instance":"materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b","tenant":"solmara-nagdi-agriculture"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/private-bindings/voucher-farmer.json b/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/private-bindings/voucher-farmer.json deleted file mode 100644 index eddcecb..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/artifacts/private-bindings/voucher-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd","id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"farmer_id":{"comparison":"binary_equality","input":"farmer_id","physical_field":"farmer_id","physical_type":"utf8"}},"projection":{"active_farm_parcel":"active_farm_parcel","active_smallholder_farmer":"active_smallholder_farmer","crop_declared_for_season":"crop_declared_for_season","data_use_authorized":"data_use_authorized","district_climate_risk_active":"district_climate_risk_active","farmer_registered":"farmer_registered","voucher_entitlement_current":"voucher_entitlement_current","voucher_not_redeemed":"voucher_not_redeemed"}},"table_provider":"nagdi_farmer__materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7"},"profile":{"id":"solmara-nagdi-agriculture.voucher.farmer","version":"1"},"registry_instance":"solmara-nagdi-agriculture","source_instance":"materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7","tenant":"solmara-nagdi-agriculture"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/relay-consultation.yaml b/runtime/registry-projects/hosted/nagdi-agriculture/relay/relay-consultation.yaml deleted file mode 100644 index ab91080..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/relay-consultation.yaml +++ /dev/null @@ -1,234 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nagdi-relay.solmara.registrystack.org - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/livestock-movement-by-farmer/conformance.json - sha256: sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910 - - class: minimization - path: artifacts/evidence/livestock-movement-by-farmer/minimization.json - sha256: sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5 - - class: conformance - path: artifacts/evidence/farmer-voucher-by-farmer/conformance.json - sha256: sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52 - - class: negative_security - path: artifacts/evidence/farmer-voucher-by-farmer/negative-security.json - sha256: sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d - - class: negative_security - path: artifacts/evidence/livestock-movement-by-farmer/negative-security.json - sha256: sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce - - class: minimization - path: artifacts/evidence/farmer-voucher-by-farmer/minimization.json - sha256: sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b - integration_packs: - - hash: sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd - path: artifacts/integration-packs/farmer-voucher-by-farmer.json - sha256: sha256:ed6f77b36df0d70ad9ffea72fdfedc21cc0a3621db3cdf4ea70d18f8960db12c - - hash: sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092 - path: artifacts/integration-packs/livestock-movement-by-farmer.json - sha256: sha256:7af1198e4ba477344621f4361613c5ba1d0d30bf1a2bc94e94b7a7c5d8e8ca16 - private_bindings: - - hash: sha256:f319d52859657a0a1acaabba01034558012eeeb11e36e406d9def07a72814416 - path: artifacts/private-bindings/livestock-herd.json - sha256: sha256:33efab2ec578e7baf3dc70c7a6c139eb76a5cd80f4a2c753fc474b795477d306 - - hash: sha256:04ea387eadcc2279e7c791241c46b77f41dcbb02bd15919a3e246370f3443714 - path: artifacts/private-bindings/voucher-farmer.json - sha256: sha256:75a3c0379a33f5c7029cf91ebb87a42c661a5e8ddd93c5fb7edf37f0f5f51d9b - public_contracts: - - hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - path: artifacts/consultation-contracts/livestock-herd.json - sha256: sha256:a1f4045c36462881aec560fcf103c1f78252bf0e2acf81ddd6b75ce21ccfd84c - - hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - path: artifacts/consultation-contracts/voucher-farmer.json - sha256: sha256:8ea73ba2779ef33a85cc5f34988fe0d14f13f1892baa185d0d5c67e76dcbe1ad - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nagdi-notary - principal_id: nagdi-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4554467022379962639 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 7450858971948805253 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nagdi-agriculture-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/nagdi-agriculture/relay/relay.yaml b/runtime/registry-projects/hosted/nagdi-agriculture/relay/relay.yaml deleted file mode 100644 index d8d88d5..0000000 --- a/runtime/registry-projects/hosted/nagdi-agriculture/relay/relay.yaml +++ /dev/null @@ -1,174 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nagdi-relay.solmara.registrystack.org - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nagdi-agriculture-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/nia-population/notary/notary.yaml b/runtime/registry-projects/hosted/nia-population/notary/notary.yaml deleted file mode 100644 index e99568f..0000000 --- a/runtime/registry-projects/hosted/nia-population/notary/notary.yaml +++ /dev/null @@ -1,152 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - evidence:nia:child-benefit:read - - fingerprint: - name: NIA_CITIZEN_CLIENT_TOKEN_HASH - provider: env - id: citizen-portal - scopes: - - evidence:nia:citizen:read -deployment: - profile: hosted_lab -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - - https://id.registrystack.org/solmara/purpose/citizen-self-service - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - population: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - alive: - nullable: false - type: boolean - identity_status: - max_bytes: 128 - nullable: false - type: string - profile: - contract_hash: sha256:2b34d7466e4f45126055cdb3ec18026a6f0cf3051a863ad951eedfe6ed729958 - id: solmara-nia-population.nia-child-benefit.population - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: population-record-active - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - evidence:nia:child-benefit:read - rule: - bindings: {} - expression: |- - population.matched && population.identity_status == 'active' - && population.alive == true - type: cel - subject_type: person - title: population record active - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - nia-citizen-status.citizen-population-status - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - population: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - alive: - nullable: false - type: boolean - identity_status: - max_bytes: 128 - nullable: false - type: string - profile: - contract_hash: sha256:9433a86942bdfbb1add5e45e2c3462eb224a2e8097be0267a52eeaaecfde0c69 - id: solmara-nia-population.nia-citizen-status.population - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: citizen-population-record-active - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service - required_scopes: - - evidence:nia:citizen:read - rule: - bindings: {} - expression: |- - population.matched && population.identity_status == 'active' - && population.alive == true - type: cel - subject_type: person - title: citizen population record active - value: - nullable: false - type: boolean - version: '1' - credential_profiles: - nia-citizen-status.citizen-population-status: - allowed_claims: - - citizen-population-record-active - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:nia - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/citizen-population-status - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/nia-notary-relay-token - workload_client_id: nia-notary - service_id: nia-notary - signing_keys: - project-issuer: - alg: EdDSA - kid: did:web:id.registrystack.org:solmara:authority:nia#issuer-key-1 - private_jwk_env: NIA_NOTARY_ISSUER_JWK - provider: local_jwk_env - status: active - variables: {} -instance: - environment: hosted - id: nia-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/consultation-contracts/nia-child-benefit-population.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/consultation-contracts/nia-child-benefit-population.json deleted file mode 100644 index 90ad104..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/consultation-contracts/nia-child-benefit-population.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.nia-child-benefit.population","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:7af974a445cf4ebaec93647f7d6aa80732d70829150702ab0891c06f91850359","id":"relay.nia-child-benefit.population","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:nia-child-benefit","workload":"nia-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["alive","identity_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":536870912,"max_source_records":10000000},"immutable_generation":true,"max_snapshot_age_ms":300000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/consultation-contracts/nia-citizen-status-population.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/consultation-contracts/nia-citizen-status-population.json deleted file mode 100644 index 08977c4..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/consultation-contracts/nia-citizen-status-population.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.nia-citizen-status.population","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"citizen-self-service","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:0f648848f4934f37c2dbce9e58040e2ce7b43d39a5d1c8d0391cdd4aa07b9834","id":"relay.nia-citizen-status.population","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/citizen-self-service"],"required_scope":"registry:consult:nia-citizen-status","workload":"nia-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["alive","identity_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":536870912,"max_source_records":10000000},"immutable_generation":true,"max_snapshot_age_ms":300000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/conformance.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/conformance.json deleted file mode 100644 index 752267e..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"citizen-population-record-active":true,"population-record-active":true},"error":null,"outcome":"match","outputs":{"alive":true,"identity_status":"active"}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"alive":true,"identity_status":"active"},"headers":{},"status":200}}}],"name":"nia-population-person-match","request":{"claims":[{"id":"population-record-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"citizen-population-record-active":false,"population-record-active":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"nia-population-person-no-match","request":{"claims":[{"id":"citizen-population-record-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/citizen-self-service","target":{"identifiers":[{"scheme":"solmara_uin","value":"9999999999"}],"type":"Person"}},"variables":{}}],"integration":"solmara-nia-population-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/minimization.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/minimization.json deleted file mode 100644 index 223b391..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-nia-population-person-by-uin","operations":{},"outputs":{"alive":{"from":"snapshot.record.alive","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"identity_status":{"from":"snapshot.record.identity_status","max_bytes":128,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/negative-security.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/negative-security.json deleted file mode 100644 index 53dcfa4..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/evidence/population-person-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-nia-population-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/integration-packs/population-person-by-uin.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/integration-packs/population-person-by-uin.json deleted file mode 100644 index 7430026..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/integration-packs/population-person-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd"],"minimization":["sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407"],"negative_security":["sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-nia-population-person-by-uin","output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":300000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/private-bindings/nia-child-benefit-population.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/private-bindings/nia-child-benefit-population.json deleted file mode 100644 index 9d14494..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/private-bindings/nia-child-benefit-population.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9","id":"solmara-nia-population.solmara-nia-population-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"alive":"alive","identity_status":"identity_status"}},"table_provider":"population__materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d"},"profile":{"id":"solmara-nia-population.nia-child-benefit.population","version":"1"},"registry_instance":"solmara-nia-population","source_instance":"materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d","tenant":"solmara-nia-population"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nia-population/relay/artifacts/private-bindings/nia-citizen-status-population.json b/runtime/registry-projects/hosted/nia-population/relay/artifacts/private-bindings/nia-citizen-status-population.json deleted file mode 100644 index 735159f..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/artifacts/private-bindings/nia-citizen-status-population.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9","id":"solmara-nia-population.solmara-nia-population-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"alive":"alive","identity_status":"identity_status"}},"table_provider":"population__materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d"},"profile":{"id":"solmara-nia-population.nia-citizen-status.population","version":"1"},"registry_instance":"solmara-nia-population","source_instance":"materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d","tenant":"solmara-nia-population"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/nia-population/relay/relay-consultation.yaml b/runtime/registry-projects/hosted/nia-population/relay/relay-consultation.yaml deleted file mode 100644 index 8b7c257..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/relay-consultation.yaml +++ /dev/null @@ -1,251 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - nia-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nia-relay.solmara.registrystack.org - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/population-person-by-uin/minimization.json - sha256: sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407 - - class: conformance - path: artifacts/evidence/population-person-by-uin/conformance.json - sha256: sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd - - class: negative_security - path: artifacts/evidence/population-person-by-uin/negative-security.json - sha256: sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab - integration_packs: - - hash: sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9 - path: artifacts/integration-packs/population-person-by-uin.json - sha256: sha256:83e0e332c81c331e2329535fdf1983be1427359a0e1bdf16986b8bc4a2f24b3e - private_bindings: - - hash: sha256:fa33b620ee699baa8efb7f233dc46884826b4e4b64940a0fa27cca5b1871bb40 - path: artifacts/private-bindings/nia-child-benefit-population.json - sha256: sha256:41dd7eba725d043adbdfbfcc7a95178364970c5b8f76525fa75db87c31d228b6 - - hash: sha256:286382e48365cf203f3b0b5712bb6c8f94d18d33657078dc2522e42afb76aa8f - path: artifacts/private-bindings/nia-citizen-status-population.json - sha256: sha256:89e52d120d4c5ab0fc451e74e2fe0bb11966e7e1e4ab59505713d78f567ae8b7 - public_contracts: - - hash: sha256:2b34d7466e4f45126055cdb3ec18026a6f0cf3051a863ad951eedfe6ed729958 - path: artifacts/consultation-contracts/nia-child-benefit-population.json - sha256: sha256:43e56a4eaff600b7fbb8ca382f6d00bee299dcd9e528f50de89143ad9f3502f3 - - hash: sha256:9433a86942bdfbb1add5e45e2c3462eb224a2e8097be0267a52eeaaecfde0c69 - path: artifacts/consultation-contracts/nia-citizen-status-population.json - sha256: sha256:3ecdd494bdec2c8b591c24385a3c808d49a9b9562f9809e1a7dd24dcc66693a1 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nia-notary - principal_id: nia-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 1016450306001272100 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6539803953464966811 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nia-population-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/nia-population/relay/relay.yaml b/runtime/registry-projects/hosted/nia-population/relay/relay.yaml deleted file mode 100644 index 49e1a9b..0000000 --- a/runtime/registry-projects/hosted/nia-population/relay/relay.yaml +++ /dev/null @@ -1,203 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - solmara-esignet - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://nia-relay.solmara.registrystack.org - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: hosted_lab -instance: - environment: hosted - id: nia-population-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/sipf-pensions/notary/notary.yaml b/runtime/registry-projects/hosted/sipf-pensions/notary/notary.yaml deleted file mode 100644 index 8750911..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/notary/notary.yaml +++ /dev/null @@ -1,136 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: SIPF_PENSION_CLIENT_TOKEN_HASH - provider: env - id: pension-review-desk - scopes: - - evidence:sipf:pension-payment:read - - evidence:sipf:survivor-benefit:read -deployment: - profile: hosted_lab -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/pension-payment-review - - https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - pension: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - payment_status: - max_bytes: 128 - nullable: false - type: string - profile: - contract_hash: sha256:04deb5f47932e163e61b8f7ce9c4d4adff54a7558128edeb367518962abde1bd - id: solmara-sipf-pensions.sipf-pension-payment-review.pension - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: pension-payment-active - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - required_scopes: - - evidence:sipf:pension-payment:read - rule: - bindings: {} - expression: pension.matched && pension.payment_status == 'active' - type: cel - subject_type: person - title: pension payment active - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - sipf-survivor-benefit.survivor-benefit-status - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - survivor: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - survivor_eligible: - nullable: false - type: boolean - profile: - contract_hash: sha256:de1c2295d58905dc34046288edf36263fcccf99cf684deaa885ab76f472ed6d6 - id: solmara-sipf-pensions.sipf-survivor-benefit.survivor - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: survivor-is-eligible - purpose: https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - required_scopes: - - evidence:sipf:survivor-benefit:read - rule: - bindings: {} - expression: survivor.matched && survivor.survivor_eligible == true - type: cel - subject_type: person - title: survivor is eligible - value: - nullable: false - type: boolean - version: '1' - credential_profiles: - sipf-survivor-benefit.survivor-benefit-status: - allowed_claims: - - survivor-is-eligible - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:sipf - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/survivor-benefit-status - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/sipf-notary-relay-token - workload_client_id: sipf-notary - service_id: sipf-notary - signing_keys: - project-issuer: - alg: EdDSA - kid: did:web:id.registrystack.org:solmara:authority:sipf#issuer-key-1 - private_jwk_env: SIPF_NOTARY_ISSUER_JWK - provider: local_jwk_env - status: active - variables: {} -instance: - environment: hosted - id: sipf-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json deleted file mode 100644 index ad9440e..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.sipf-pension-payment-review.pension","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:96e56b4b170376860f623777977f7c72a19cd960854a48f568e1e92ca33f85e7","id":"relay.sipf-pension-payment-review.pension","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/pension-payment-review"],"required_scope":"registry:consult:sipf-pension-payment-review","workload":"sipf-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["payment_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json deleted file mode 100644 index a4fd9c0..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.sipf-survivor-benefit.survivor","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:ec6b5310716606dac48159abec1e512757fb9a18bff8a6e0ac634db932b2696b","id":"relay.sipf-survivor-benefit.survivor","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"],"required_scope":"registry:consult:sipf-survivor-benefit","workload":"sipf-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["survivor_eligible"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/conformance.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/conformance.json deleted file mode 100644 index 8babb7e..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"uin":"2300109568"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"payment_status":"active"},{"payment_status":"suspended"}],"headers":{},"status":200}}}],"name":"sipf-pension-case-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"pension-payment-active":true},"error":null,"outcome":"match","outputs":{"payment_status":"active"}},"input":{"uin":"2300109568"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"payment_status":"active"},"headers":{},"status":200}}}],"name":"sipf-pension-case-match","request":{"claims":[{"id":"pension-payment-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/pension-payment-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300109568"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"pension-payment-active":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"sipf-pension-case-no-match","request":null,"variables":{}}],"integration":"solmara-sipf-pension-case-by-pensioner","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/minimization.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/minimization.json deleted file mode 100644 index 27b99c4..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-sipf-pension-case-by-pensioner","operations":{},"outputs":{"payment_status":{"from":"snapshot.record.payment_status","max_bytes":128,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/negative-security.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/negative-security.json deleted file mode 100644 index c62fd58..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-sipf-pension-case-by-pensioner","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/conformance.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/conformance.json deleted file mode 100644 index e33e4f9..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"uin":"2300118698"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"survivor_eligible":true},{"survivor_eligible":false}],"headers":{},"status":200}}}],"name":"sipf-survivor-case-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"survivor-is-eligible":true},"error":null,"outcome":"match","outputs":{"survivor_eligible":true}},"input":{"uin":"2300118698"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"survivor_eligible":true},"headers":{},"status":200}}}],"name":"sipf-survivor-case-match","request":{"claims":[{"id":"survivor-is-eligible"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/survivor-benefit-determination","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300118698"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"survivor-is-eligible":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"sipf-survivor-case-no-match","request":null,"variables":{}}],"integration":"solmara-sipf-pension-case-by-survivor","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/minimization.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/minimization.json deleted file mode 100644 index e246e22..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-sipf-pension-case-by-survivor","operations":{},"outputs":{"survivor_eligible":{"from":"snapshot.record.survivor_eligible","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/negative-security.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/negative-security.json deleted file mode 100644 index 72f6ab7..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-sipf-pension-case-by-survivor","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-pensioner.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-pensioner.json deleted file mode 100644 index 7fac5ec..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-pensioner.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e"],"minimization":["sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660"],"negative_security":["sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-sipf-pension-case-by-pensioner","output":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-survivor.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-survivor.json deleted file mode 100644 index a0e0dda..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059"],"minimization":["sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f"],"negative_security":["sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-sipf-pension-case-by-survivor","output":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/private-bindings/sipf-pension-payment-review-pension.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/private-bindings/sipf-pension-payment-review-pension.json deleted file mode 100644 index 2b3b78b..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/private-bindings/sipf-pension-payment-review-pension.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e","id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"pensioner_uin","physical_type":"utf8"}},"projection":{"payment_status":"payment_status"}},"table_provider":"pension__materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21"},"profile":{"id":"solmara-sipf-pensions.sipf-pension-payment-review.pension","version":"1"},"registry_instance":"solmara-sipf-pensions","source_instance":"materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21","tenant":"solmara-sipf-pensions"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/private-bindings/sipf-survivor-benefit-survivor.json b/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/private-bindings/sipf-survivor-benefit-survivor.json deleted file mode 100644 index 8485dcd..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/artifacts/private-bindings/sipf-survivor-benefit-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235","id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"spouse_uin","physical_type":"utf8"}},"projection":{"survivor_eligible":"survivor_eligible"}},"table_provider":"survivor__materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539"},"profile":{"id":"solmara-sipf-pensions.sipf-survivor-benefit.survivor","version":"1"},"registry_instance":"solmara-sipf-pensions","source_instance":"materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539","tenant":"solmara-sipf-pensions"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/relay-consultation.yaml b/runtime/registry-projects/hosted/sipf-pensions/relay/relay-consultation.yaml deleted file mode 100644 index 280b116..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/relay-consultation.yaml +++ /dev/null @@ -1,182 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sipf-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sipf-relay.solmara.registrystack.org - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/pension-case-by-survivor/minimization.json - sha256: sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f - - class: conformance - path: artifacts/evidence/pension-case-by-pensioner/conformance.json - sha256: sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e - - class: negative_security - path: artifacts/evidence/pension-case-by-survivor/negative-security.json - sha256: sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7 - - class: minimization - path: artifacts/evidence/pension-case-by-pensioner/minimization.json - sha256: sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660 - - class: conformance - path: artifacts/evidence/pension-case-by-survivor/conformance.json - sha256: sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059 - - class: negative_security - path: artifacts/evidence/pension-case-by-pensioner/negative-security.json - sha256: sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d - integration_packs: - - hash: sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e - path: artifacts/integration-packs/pension-case-by-pensioner.json - sha256: sha256:6252d74d83d4074d4eee0ae60cb2d1aac357c1a0a56664ab92120933ad0e77b3 - - hash: sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235 - path: artifacts/integration-packs/pension-case-by-survivor.json - sha256: sha256:ca28e59689fa5e842f1435cd8c7bf922117ff2afc7bf3af08788781fcf458168 - private_bindings: - - hash: sha256:928e8a72b68fcdf9fe3ae4381cc71918d78a5c8f8bd138be09df42528a6fdcd3 - path: artifacts/private-bindings/sipf-pension-payment-review-pension.json - sha256: sha256:9499ffd554e9d00acdf62509e3dad500834050c4abff6ce0b1cc299fa35b7828 - - hash: sha256:5b46b40895d8fc69247e473ff117e119a08453121f5ecc9215e1297961ae701d - path: artifacts/private-bindings/sipf-survivor-benefit-survivor.json - sha256: sha256:37a68f42bf4870f69eb4faf6c820049c868951b49edbbe99483e61ae2cbde463 - public_contracts: - - hash: sha256:04deb5f47932e163e61b8f7ce9c4d4adff54a7558128edeb367518962abde1bd - path: artifacts/consultation-contracts/sipf-pension-payment-review-pension.json - sha256: sha256:75211b8be4be68778d4a0aa044fa46778b507017028dfa1b43c9330f9700a167 - - hash: sha256:de1c2295d58905dc34046288edf36263fcccf99cf684deaa885ab76f472ed6d6 - path: artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json - sha256: sha256:6bebf5122b4248dbc3eaefc5e3f7de1705796e30a1b8011b8c0fc3adf2c255f9 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sipf-notary - principal_id: sipf-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4444991830243562918 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6117019989203477116 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sipf-pensions-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/sipf-pensions/relay/relay.yaml b/runtime/registry-projects/hosted/sipf-pensions/relay/relay.yaml deleted file mode 100644 index 5a2c71f..0000000 --- a/runtime/registry-projects/hosted/sipf-pensions/relay/relay.yaml +++ /dev/null @@ -1,121 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: [] - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sipf-relay.solmara.registrystack.org - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sipf-pensions-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/sro-social/notary/notary.yaml b/runtime/registry-projects/hosted/sro-social/notary/notary.yaml deleted file mode 100644 index 06d03f4..0000000 --- a/runtime/registry-projects/hosted/sro-social/notary/notary.yaml +++ /dev/null @@ -1,77 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - sro_social:evidence_verification -deployment: - profile: hosted_lab -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - household: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - poverty_band: - max_bytes: 64 - nullable: false - type: string - profile: - contract_hash: sha256:7c6800ab1eea861c2e2500ba3e93bb9ddcb6d1212d7701756098155faaee4697 - id: solmara-sro-social.child-benefit.household - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: household-below-poverty-threshold - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - sro_social:evidence_verification - rule: - bindings: {} - expression: household.matched && household.poverty_band == "priority" - type: cel - subject_type: person - title: household below poverty threshold - value: - nullable: false - type: boolean - version: '1' - credential_profiles: {} - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/sro-notary-relay-token - workload_client_id: sro-notary - service_id: sro-notary - variables: {} -instance: - environment: hosted - id: sro-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/hosted/sro-social/relay/artifacts/consultation-contracts/child-benefit-household.json b/runtime/registry-projects/hosted/sro-social/relay/artifacts/consultation-contracts/child-benefit-household.json deleted file mode 100644 index e8bbb22..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/artifacts/consultation-contracts/child-benefit-household.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sro-social.child-benefit.household","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:1f2bc3ace6f53ef9e6aa82974582fdd31113142f164f5afaf8328dafe39d92d3","id":"relay.child-benefit.household","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:child-benefit","workload":"sro-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sro-social.child-benefit-household-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["poverty_band"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":67108864,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":2678400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/conformance.json b/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/conformance.json deleted file mode 100644 index 6a52524..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"household-below-poverty-threshold":true},"error":null,"outcome":"match","outputs":{"poverty_band":"priority"}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"poverty_band":"priority"},"headers":{},"status":200}}}],"name":"child-benefit-household-match","request":{"claims":[{"id":"household-below-poverty-threshold"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"household-below-poverty-threshold":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"2300999997"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"child-benefit-household-no-match","request":null,"variables":{}}],"integration":"child-benefit-household-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/minimization.json b/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/minimization.json deleted file mode 100644 index fc5378a..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"child-benefit-household-by-uin","operations":{},"outputs":{"poverty_band":{"from":"snapshot.record.poverty_band","max_bytes":64,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/negative-security.json b/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/negative-security.json deleted file mode 100644 index 9037e0a..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"child-benefit-household-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/hosted/sro-social/relay/artifacts/integration-packs/child-benefit-household-by-uin.json b/runtime/registry-projects/hosted/sro-social/relay/artifacts/integration-packs/child-benefit-household-by-uin.json deleted file mode 100644 index 7133664..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/artifacts/integration-packs/child-benefit-household-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sro-social.child-benefit-household-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce"],"minimization":["sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0"],"negative_security":["sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"child-benefit-household-by-uin","output":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":2678400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sro-social/relay/artifacts/private-bindings/child-benefit-household.json b/runtime/registry-projects/hosted/sro-social/relay/artifacts/private-bindings/child-benefit-household.json deleted file mode 100644 index e46d4f8..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/artifacts/private-bindings/child-benefit-household.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e","id":"solmara-sro-social.child-benefit-household-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"poverty_band":"poverty_band"}},"table_provider":"sro_child__materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d"},"profile":{"id":"solmara-sro-social.child-benefit.household","version":"1"},"registry_instance":"solmara-sro-social","source_instance":"materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d","tenant":"solmara-sro-social"} \ No newline at end of file diff --git a/runtime/registry-projects/hosted/sro-social/relay/relay-consultation.yaml b/runtime/registry-projects/hosted/sro-social/relay/relay-consultation.yaml deleted file mode 100644 index dcacafe..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/relay-consultation.yaml +++ /dev/null @@ -1,118 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sro-relay.solmara.registrystack.org - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -consultation: - artifacts: - evidence: - - class: negative_security - path: artifacts/evidence/child-benefit-household-by-uin/negative-security.json - sha256: sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95 - - class: minimization - path: artifacts/evidence/child-benefit-household-by-uin/minimization.json - sha256: sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0 - - class: conformance - path: artifacts/evidence/child-benefit-household-by-uin/conformance.json - sha256: sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce - integration_packs: - - hash: sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e - path: artifacts/integration-packs/child-benefit-household-by-uin.json - sha256: sha256:bbf3bfb05e1f6020bdc14457db9549ad084ad421b5c59715bdb51d5ea67dc4d3 - private_bindings: - - hash: sha256:4dd783c0c8bcf6b6fee3aefd511c719dab911053adf3f39874cc99deded3bcfb - path: artifacts/private-bindings/child-benefit-household.json - sha256: sha256:9515fa0b01838a1f07f25b2b86b75843e8ef888438b82989f5ab7d657e2865ba - public_contracts: - - hash: sha256:7c6800ab1eea861c2e2500ba3e93bb9ddcb6d1212d7701756098155faaee4697 - path: artifacts/consultation-contracts/child-benefit-household.json - sha256: sha256:77470bdaa56f3bf30beb1734e0aa0fd676068b09f45d576ce32cae4a51920efb - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sro-notary - principal_id: sro-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 368637434756268187 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 2694379094216255332 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sro-social-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/hosted/sro-social/relay/relay.yaml b/runtime/registry-projects/hosted/sro-social/relay/relay.yaml deleted file mode 100644 index fe45cb9..0000000 --- a/runtime/registry-projects/hosted/sro-social/relay/relay.yaml +++ /dev/null @@ -1,76 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: false - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: https://workload-issuer.solmara.registrystack.org - jwks_url: https://workload-issuer.solmara.registrystack.org/.well-known/jwks.json -catalog: - base_url: https://sro-relay.solmara.registrystack.org - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: hosted_lab -instance: - environment: hosted - id: sro-social-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/cra-civil/notary/notary.yaml b/runtime/registry-projects/local/cra-civil/notary/notary.yaml deleted file mode 100644 index 8f6c6f8..0000000 --- a/runtime/registry-projects/local/cra-civil/notary/notary.yaml +++ /dev/null @@ -1,235 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - evidence:cra:child-benefit:read - - fingerprint: - name: CRA_CITIZEN_CLIENT_TOKEN_HASH - provider: env - id: citizen-portal - scopes: - - evidence:cra:citizen:read - - fingerprint: - name: CRA_PENSION_CLIENT_TOKEN_HASH - provider: env - id: pension-review-desk - scopes: - - evidence:cra:pension:read -cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - - https://id.registrystack.org/solmara/purpose/citizen-self-service - - https://id.registrystack.org/solmara/purpose/pension-payment-review - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - id: solmara-cra-civil.cra-child-benefit.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: birth-is-registered - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - evidence:cra:child-benefit:read - rule: - bindings: {} - expression: 'civil.matched && civil.birth_brn != null ? civil.birth_brn != '''' : false' - type: cel - subject_type: person - title: birth is registered - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - id: solmara-cra-civil.cra-child-benefit.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: child-age-under-5 - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - evidence:cra:child-benefit:read - rule: - bindings: {} - expression: |- - civil.matched && civil.birth_date != null && civil.deceased == false - ? date.age_on(civil.birth_date, as_of_date) >= 0 - && date.age_on(civil.birth_date, as_of_date) < 5 - : false - type: cel - subject_type: person - title: child age under 5 - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:c101d11d7fe66efe8400330851d65e4e105be62eae6ae9325eb43a83daae8aed - id: solmara-cra-civil.cra-citizen-record.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: civil-record-linked - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service - required_scopes: - - evidence:cra:citizen:read - rule: - bindings: {} - expression: civil.matched - type: cel - subject_type: person - title: civil record linked - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - civil: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - birth_brn: - max_bytes: 128 - nullable: true - type: string - birth_date: - nullable: false - type: date - deceased: - nullable: false - type: boolean - profile: - contract_hash: sha256:80486f20e013dd4a99af89a22efe3c83638f37f3f79ea7c912a8d5b237db2924 - id: solmara-cra-civil.cra-pension-death.civil - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: person-is-deceased - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - required_scopes: - - evidence:cra:pension:read - rule: - bindings: {} - expression: civil.matched && civil.deceased == true - type: cel - subject_type: person - title: person is deceased - value: - nullable: false - type: boolean - version: '1' - credential_profiles: {} - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/cra-notary-relay-token - workload_client_id: cra-notary - service_id: cra-notary - variables: - as_of_date: - from: request.variables.as_of_date - type: date -instance: - environment: local - id: cra-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-child-benefit-civil.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-child-benefit-civil.json deleted file mode 100644 index aad610b..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-child-benefit-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-child-benefit.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:9ec50673e36783484c8e35137bd65396a871ecc12c2e8fdd2cc68b1f0cab108b","id":"relay.cra-child-benefit.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:cra-child-benefit","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-citizen-record-civil.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-citizen-record-civil.json deleted file mode 100644 index f0ef1ff..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-citizen-record-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-citizen-record.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"citizen-self-service","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:ddf7a76ffe3dabacabdc1df18588bb87826a00a061572ad49c5052baf073f576","id":"relay.cra-citizen-record.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/citizen-self-service"],"required_scope":"registry:consult:cra-citizen-record","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-pension-death-civil.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-pension-death-civil.json deleted file mode 100644 index 2f64031..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/consultation-contracts/cra-pension-death-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.cra-pension-death.civil","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:dd59f03e034f68b35706ae6193bdd580081f2b596c6b92673fc7a66961e34fc1","id":"relay.cra-pension-death.civil","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/pension-payment-review"],"required_scope":"registry:consult:cra-pension-death","workload":"cra-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["birth_brn","birth_date","deceased"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/conformance.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/conformance.json deleted file mode 100644 index 9e550c6..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"birth-is-registered":true,"child-age-under-5":false,"civil-record-linked":true,"person-is-deceased":false},"error":null,"outcome":"match","outputs":{"birth_brn":"BRN-2027-0101-00001","birth_date":"2027-03-14","deceased":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"birth_brn":"BRN-2027-0101-00001","birth_date":"2027-03-14","deceased":false},"headers":{},"status":200}}}],"name":"cra-civil-person-future-birth-date","request":{"claims":[{"id":"birth-is-registered"},{"id":"child-age-under-5"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"},"variables":{"as_of_date":"2026-07-14"}},"variables":{"as_of_date":"2026-07-14"}},{"classification":"synthetic","expect":{"claims":{"birth-is-registered":true,"child-age-under-5":true,"civil-record-linked":true,"person-is-deceased":false},"error":null,"outcome":"match","outputs":{"birth_brn":"BRN-2022-0101-00001","birth_date":"2022-03-14","deceased":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"birth_brn":"BRN-2022-0101-00001","birth_date":"2022-03-14","deceased":false},"headers":{},"status":200}}}],"name":"cra-civil-person-match","request":{"claims":[{"id":"civil-record-linked"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/citizen-self-service","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{"as_of_date":"2026-07-14"}},{"classification":"synthetic","expect":{"claims":{"birth-is-registered":false,"child-age-under-5":false,"civil-record-linked":false,"person-is-deceased":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"cra-civil-person-no-match","request":{"claims":[{"id":"person-is-deceased"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/pension-payment-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"9999999999"}],"type":"Person"}},"variables":{"as_of_date":"2026-07-14"}}],"integration":"solmara-cra-civil-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/minimization.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/minimization.json deleted file mode 100644 index 9d92e3c..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-cra-civil-person-by-uin","operations":{},"outputs":{"birth_brn":{"from":"snapshot.record.birth_brn","max_bytes":128,"maximum":null,"minimum":null,"nullable":true,"type":"string"},"birth_date":{"from":"snapshot.record.birth_date","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"date"},"deceased":{"from":"snapshot.record.deceased","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/negative-security.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/negative-security.json deleted file mode 100644 index 18ba42a..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/evidence/civil-person-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-cra-civil-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/integration-packs/civil-person-by-uin.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/integration-packs/civil-person-by-uin.json deleted file mode 100644 index 5533854..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/integration-packs/civil-person-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd"],"minimization":["sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263"],"negative_security":["sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-cra-civil-person-by-uin","output":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"max_bytes":10,"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"birth_brn":{"max_bytes":128,"nullable":true,"type":"string"},"birth_date":{"nullable":false,"type":"date"},"deceased":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-child-benefit-civil.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-child-benefit-civil.json deleted file mode 100644 index 329227a..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-child-benefit-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-child-benefit.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-citizen-record-civil.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-citizen-record-civil.json deleted file mode 100644 index ea8fb47..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-citizen-record-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-citizen-record.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-pension-death-civil.json b/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-pension-death-civil.json deleted file mode 100644 index 5b5ec3b..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/artifacts/private-bindings/cra-pension-death-civil.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499","id":"solmara-cra-civil.solmara-cra-civil-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"birth_brn":"birth_brn","birth_date":"birth_date","deceased":"deceased"}},"table_provider":"civil_people__materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382"},"profile":{"id":"solmara-cra-civil.cra-pension-death.civil","version":"1"},"registry_instance":"solmara-cra-civil","source_instance":"materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382","tenant":"solmara-cra-civil"} \ No newline at end of file diff --git a/runtime/registry-projects/local/cra-civil/relay/relay-consultation.yaml b/runtime/registry-projects/local/cra-civil/relay/relay-consultation.yaml deleted file mode 100644 index 797c8b3..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/relay-consultation.yaml +++ /dev/null @@ -1,138 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - cra-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/civil-person-by-uin/conformance.json - sha256: sha256:1943d411187b226b981cddc2cd0442e73953d29cb5a047bbb38c729d78883afd - - class: negative_security - path: artifacts/evidence/civil-person-by-uin/negative-security.json - sha256: sha256:2613360dc2b35b1e02aa276bc52ff7ff35fc083dc4713d1036df405eb3523f0f - - class: minimization - path: artifacts/evidence/civil-person-by-uin/minimization.json - sha256: sha256:5fa0459553307e10c55a7613981afdc2a42c0ea02e2bb93fe76ad6f2d2fcd263 - integration_packs: - - hash: sha256:84e87197948e5b921d95efc8cfe1b3d08159585e9473d8d54299c73da90f9499 - path: artifacts/integration-packs/civil-person-by-uin.json - sha256: sha256:ad85a4bbcbd9c164264afeb98c11c549cc0c54997b39572261282211ab9104b2 - private_bindings: - - hash: sha256:1fb0b8c2b28da70d6f22675209ced135f4855fcfdc2fd38a32aadaa0c70be0dd - path: artifacts/private-bindings/cra-child-benefit-civil.json - sha256: sha256:087d5ddfc5bbc43620f9bf499cda7343ff0b762427451f30fd3541bb7d230e22 - - hash: sha256:918b533b198e4e45e368db0b3d8737728700f4b6ee428e10a93a1a06460649b1 - path: artifacts/private-bindings/cra-citizen-record-civil.json - sha256: sha256:d564828b5a94041f3bb718c16b40a0a53ea68a94ed917bf96e5ed04f0ec8d086 - - hash: sha256:fbac0434f957c897098a1577a20ae8763258b1a1e2385a577129258dbbe86579 - path: artifacts/private-bindings/cra-pension-death-civil.json - sha256: sha256:f4eea1f1f2df7755c729d0a645778ca9805e15e3875962595c0038cc46142f51 - public_contracts: - - hash: sha256:35acae8dfe5b6cc1c18f9aedbc188c93ded49a46553b7ab21451eb772e26918e - path: artifacts/consultation-contracts/cra-child-benefit-civil.json - sha256: sha256:65bfb5000e186b4e461c392454e01c76149e821551152ec79a22f28b1e15f6b1 - - hash: sha256:c101d11d7fe66efe8400330851d65e4e105be62eae6ae9325eb43a83daae8aed - path: artifacts/consultation-contracts/cra-citizen-record-civil.json - sha256: sha256:ffb8a94778c5d1015c14ecc03fd06d7f2b2d5926aca0654cc80887d930bf74d2 - - hash: sha256:80486f20e013dd4a99af89a22efe3c83638f37f3f79ea7c912a8d5b237db2924 - path: artifacts/consultation-contracts/cra-pension-death-civil.json - sha256: sha256:2fbbfadc074822821239ff13ead71115d7d64c6378e39c4d03a870eb3b6655ea - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: cra-notary - principal_id: cra-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 5222333210205388 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 3104283851243516304 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: cra-civil-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/cra-civil/relay/relay.yaml b/runtime/registry-projects/local/cra-civil/relay/relay.yaml deleted file mode 100644 index 53cef5a..0000000 --- a/runtime/registry-projects/local/cra-civil/relay/relay.yaml +++ /dev/null @@ -1,83 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: [] - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-cra-civil - title: solmara-cra-civil governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized civil_people entity - entities: [] - id: civil_people - owner: solmara-cra-civil - sensitivity: personal - tables: - - access: - aggregate_scope: civil_people:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_f5d795774cce29f1dfb681aae7844653315f52188d39b841602bb758e7e53382 - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: birth_brn - nullable: true - sensitive: true - type: string - - name: birth_date - nullable: false - sensitive: true - type: date - - name: deceased - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/interior-civil/fixtures/civil_person_projection.csv - type: file - title: civil_people - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: cra-civil-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/mosd-programme/notary/notary.yaml b/runtime/registry-projects/local/mosd-programme/notary/notary.yaml deleted file mode 100644 index 133d85d..0000000 --- a/runtime/registry-projects/local/mosd-programme/notary/notary.yaml +++ /dev/null @@ -1,78 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - mosd_programme:evidence_verification -cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - enrollment: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - duplicate_flag: - nullable: false - type: boolean - profile: - contract_hash: sha256:9da47bc7171991c51e098b44cb0667595b344d5f4c2b994a67f0c3b401d4b5b3 - id: solmara-mosd-programme.child-benefit.enrollment - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: not-already-enrolled - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - mosd_programme:evidence_verification - rule: - bindings: {} - expression: enrollment.matched && enrollment.duplicate_flag == false - type: cel - subject_type: person - title: not already enrolled - value: - nullable: false - type: boolean - version: '1' - credential_profiles: {} - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/programme-notary-relay-token - workload_client_id: programme-notary - service_id: programme-notary - variables: {} -instance: - environment: local - id: programme-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/local/mosd-programme/relay/artifacts/consultation-contracts/child-benefit-enrollment.json b/runtime/registry-projects/local/mosd-programme/relay/artifacts/consultation-contracts/child-benefit-enrollment.json deleted file mode 100644 index bc41921..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/artifacts/consultation-contracts/child-benefit-enrollment.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-mosd-programme.child-benefit.enrollment","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:50a4f9fd40526b837aa90b02d27e13e98f66d9de1362eb06fb2465afbae31c53","id":"relay.child-benefit.enrollment","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:child-benefit","workload":"programme-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-mosd-programme.enrollment-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["duplicate_flag"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":67108864,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/conformance.json b/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/conformance.json deleted file mode 100644 index 6910195..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"not-already-enrolled":true},"error":null,"outcome":"match","outputs":{"duplicate_flag":false}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"duplicate_flag":false},"headers":{},"status":200}}}],"name":"enrollment-match","request":{"claims":[{"id":"not-already-enrolled"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"not-already-enrolled":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"2300999997"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"enrollment-no-match","request":null,"variables":{}}],"integration":"enrollment-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/minimization.json b/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/minimization.json deleted file mode 100644 index 1d539e7..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"enrollment-by-uin","operations":{},"outputs":{"duplicate_flag":{"from":"snapshot.record.duplicate_flag","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/negative-security.json b/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/negative-security.json deleted file mode 100644 index 0b38f83..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/artifacts/evidence/enrollment-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"enrollment-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/mosd-programme/relay/artifacts/integration-packs/enrollment-by-uin.json b/runtime/registry-projects/local/mosd-programme/relay/artifacts/integration-packs/enrollment-by-uin.json deleted file mode 100644 index 729f6a0..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/artifacts/integration-packs/enrollment-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-mosd-programme.enrollment-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8"],"minimization":["sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83"],"negative_security":["sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"enrollment-by-uin","output":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"duplicate_flag":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/mosd-programme/relay/artifacts/private-bindings/child-benefit-enrollment.json b/runtime/registry-projects/local/mosd-programme/relay/artifacts/private-bindings/child-benefit-enrollment.json deleted file mode 100644 index 078b94e..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/artifacts/private-bindings/child-benefit-enrollment.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8","id":"solmara-mosd-programme.enrollment-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"duplicate_flag":"duplicate_flag"}},"table_provider":"mosd_enroll__materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf"},"profile":{"id":"solmara-mosd-programme.child-benefit.enrollment","version":"1"},"registry_instance":"solmara-mosd-programme","source_instance":"materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf","tenant":"solmara-mosd-programme"} \ No newline at end of file diff --git a/runtime/registry-projects/local/mosd-programme/relay/relay-consultation.yaml b/runtime/registry-projects/local/mosd-programme/relay/relay-consultation.yaml deleted file mode 100644 index 263cc9e..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/relay-consultation.yaml +++ /dev/null @@ -1,118 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/enrollment-by-uin/minimization.json - sha256: sha256:2d57d4d6bf1f97aba14b447b84f844af70791a8eac8bff7be2cc72a4aa3fce83 - - class: negative_security - path: artifacts/evidence/enrollment-by-uin/negative-security.json - sha256: sha256:5468291382c61d128d2aff47dfbb7151ae1736c72d64b7a69e1209cd67d95282 - - class: conformance - path: artifacts/evidence/enrollment-by-uin/conformance.json - sha256: sha256:f7df3b836234ef5893833546f66bb989f0b004ca1d32c006f72a13ebb3a4c2d8 - integration_packs: - - hash: sha256:cffdf1b631e81fee923267d17c17f3001685b1b6681ddb8b93040b0c2691a6e8 - path: artifacts/integration-packs/enrollment-by-uin.json - sha256: sha256:40ac75a61c3346b282e3abadd16654b634ebe0ac60c77a7b4c2ac98cf5f3af50 - private_bindings: - - hash: sha256:ae3674a401a41daa3325d73cd22363dba2d4c5145ae22cdc64587a6e0a293654 - path: artifacts/private-bindings/child-benefit-enrollment.json - sha256: sha256:fd8fc27359128d39dbed4cd6842d7db21f3b5eef21359f0b6bec93c0f6822f18 - public_contracts: - - hash: sha256:9da47bc7171991c51e098b44cb0667595b344d5f4c2b994a67f0c3b401d4b5b3 - path: artifacts/consultation-contracts/child-benefit-enrollment.json - sha256: sha256:253ee934a9c9bbf725e233f90c195d057893eeea14bc9bc12330c0957245ff10 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: programme-notary - principal_id: programme-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 9069287079765840165 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 891105660075992367 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: programme-mis-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/mosd-programme/relay/relay.yaml b/runtime/registry-projects/local/mosd-programme/relay/relay.yaml deleted file mode 100644 index bb3f817..0000000 --- a/runtime/registry-projects/local/mosd-programme/relay/relay.yaml +++ /dev/null @@ -1,76 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - programme-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-mosd-programme - title: solmara-mosd-programme governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized mosd_enroll entity - entities: [] - id: mosd_enroll - owner: solmara-mosd-programme - sensitivity: personal - tables: - - access: - aggregate_scope: mosd_enroll:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_5f676d9c112ce16b7ede827eab122868889c2a4c9a1d4b38a214beecadc245bf - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: duplicate_flag - nullable: false - sensitive: true - type: boolean - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/programme_mis_enrollment.csv - type: file - title: mosd_enroll - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: programme-mis-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/nagdi-agriculture/notary/notary.yaml b/runtime/registry-projects/local/nagdi-agriculture/notary/notary.yaml deleted file mode 100644 index d4f45e9..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/notary/notary.yaml +++ /dev/null @@ -1,414 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: NAGDI_CLIENT_TOKEN_HASH - provider: env - id: nagdi-service - scopes: - - nagdi_agriculture:evidence_verification -cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/livestock-movement-control - - https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - claims: - - credential_profiles: - - livestock.livestock_movement_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - herd: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - destination_district_open: - nullable: false - type: boolean - herd_vaccination_current: - nullable: false - type: boolean - no_conflicting_open_movement_permit: - nullable: false - type: boolean - origin_district_not_quarantined_for_species: - nullable: false - type: boolean - registered_herd: - nullable: false - type: boolean - profile: - contract_hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - id: solmara-nagdi-agriculture.livestock.herd - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: eligible-for-livestock-movement-permit - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: |- - herd.matched && herd.registered_herd - && herd.origin_district_not_quarantined_for_species - && herd.herd_vaccination_current - && herd.destination_district_open - && herd.no_conflicting_open_movement_permit - type: cel - subject_type: person - title: eligible for livestock movement permit - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - livestock.livestock_movement_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - herd: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - destination_district_open: - nullable: false - type: boolean - herd_vaccination_current: - nullable: false - type: boolean - no_conflicting_open_movement_permit: - nullable: false - type: boolean - origin_district_not_quarantined_for_species: - nullable: false - type: boolean - registered_herd: - nullable: false - type: boolean - profile: - contract_hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - id: solmara-nagdi-agriculture.livestock.herd - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: origin-district-not-quarantined-for-species - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: herd.matched && herd.origin_district_not_quarantined_for_species - type: cel - subject_type: person - title: origin district not quarantined for species - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - livestock.livestock_movement_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - herd: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - destination_district_open: - nullable: false - type: boolean - herd_vaccination_current: - nullable: false - type: boolean - no_conflicting_open_movement_permit: - nullable: false - type: boolean - origin_district_not_quarantined_for_species: - nullable: false - type: boolean - registered_herd: - nullable: false - type: boolean - profile: - contract_hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - id: solmara-nagdi-agriculture.livestock.herd - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: registered-herd - purpose: https://id.registrystack.org/solmara/purpose/livestock-movement-control - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: herd.matched && herd.registered_herd - type: cel - subject_type: person - title: registered herd - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - voucher.climate_smart_voucher_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - farmer: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - active_farm_parcel: - nullable: false - type: boolean - active_smallholder_farmer: - nullable: false - type: boolean - crop_declared_for_season: - nullable: false - type: boolean - data_use_authorized: - nullable: false - type: boolean - district_climate_risk_active: - nullable: false - type: boolean - farmer_registered: - nullable: false - type: boolean - voucher_entitlement_current: - nullable: false - type: boolean - voucher_not_redeemed: - nullable: false - type: boolean - profile: - contract_hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - id: solmara-nagdi-agriculture.voucher.farmer - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: data-use-authorized-for-purpose - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: farmer.matched && farmer.data_use_authorized - type: cel - subject_type: person - title: data use authorized for purpose - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - voucher.climate_smart_voucher_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - farmer: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - active_farm_parcel: - nullable: false - type: boolean - active_smallholder_farmer: - nullable: false - type: boolean - crop_declared_for_season: - nullable: false - type: boolean - data_use_authorized: - nullable: false - type: boolean - district_climate_risk_active: - nullable: false - type: boolean - farmer_registered: - nullable: false - type: boolean - voucher_entitlement_current: - nullable: false - type: boolean - voucher_not_redeemed: - nullable: false - type: boolean - profile: - contract_hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - id: solmara-nagdi-agriculture.voucher.farmer - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: eligible-for-climate-smart-input-voucher - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: |- - farmer.matched && farmer.farmer_registered - && farmer.data_use_authorized - && farmer.active_smallholder_farmer - && farmer.active_farm_parcel - && farmer.crop_declared_for_season - && farmer.district_climate_risk_active - && farmer.voucher_entitlement_current - && farmer.voucher_not_redeemed - type: cel - subject_type: person - title: eligible for climate smart input voucher - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - voucher.climate_smart_voucher_sd_jwt - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - farmer: - inputs: - farmer_id: request.target.identifiers.farmer_id - outputs: - active_farm_parcel: - nullable: false - type: boolean - active_smallholder_farmer: - nullable: false - type: boolean - crop_declared_for_season: - nullable: false - type: boolean - data_use_authorized: - nullable: false - type: boolean - district_climate_risk_active: - nullable: false - type: boolean - farmer_registered: - nullable: false - type: boolean - voucher_entitlement_current: - nullable: false - type: boolean - voucher_not_redeemed: - nullable: false - type: boolean - profile: - contract_hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - id: solmara-nagdi-agriculture.voucher.farmer - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: farmer-registered - purpose: https://id.registrystack.org/solmara/purpose/voucher-eligibility-review - required_scopes: - - nagdi_agriculture:evidence_verification - rule: - bindings: {} - expression: farmer.matched && farmer.farmer_registered - type: cel - subject_type: person - title: farmer registered - value: - nullable: false - type: boolean - version: '1' - credential_profiles: - livestock.livestock_movement_sd_jwt: - allowed_claims: - - registered-herd - - origin-district-not-quarantined-for-species - - eligible-for-livestock-movement-permit - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:nagdi - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/livestock-movement-permit - voucher.climate_smart_voucher_sd_jwt: - allowed_claims: - - farmer-registered - - data-use-authorized-for-purpose - - eligible-for-climate-smart-input-voucher - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:nagdi - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/climate-smart-voucher-eligibility - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/nagdi-notary-relay-token - workload_client_id: nagdi-notary - service_id: nagdi-notary - signing_keys: - project-issuer: - alg: EdDSA - kid: did:web:id.registrystack.org:solmara:authority:nagdi#issuer-key-1 - private_jwk_env: NAGDI_NOTARY_ISSUER_JWK - provider: local_jwk_env - status: active - variables: {} -instance: - environment: local - id: nagdi-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/consultation-contracts/livestock-herd.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/consultation-contracts/livestock-herd.json deleted file mode 100644 index adf39dd..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/consultation-contracts/livestock-herd.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.livestock.herd","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"agricultural-data-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:05746b9128c73f3a079b88d4a6a5c392ea4d317755aa8e0771e0c83325a16b28","id":"relay.livestock.herd","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/livestock-movement-control"],"required_scope":"registry:consult:livestock","workload":"nagdi-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"integration":{"id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["destination_district_open","herd_vaccination_current","no_conflicting_open_movement_permit","origin_district_not_quarantined_for_species","registered_herd"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":134217728,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":604800000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/consultation-contracts/voucher-farmer.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/consultation-contracts/voucher-farmer.json deleted file mode 100644 index 3f414ad..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/consultation-contracts/voucher-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.voucher.farmer","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"agricultural-data-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:6c9b13e328828747f1d32991be3ab0681112c283dae9f614a7187c8ca0d72a01","id":"relay.voucher.farmer","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"],"required_scope":"registry:consult:voucher","workload":"nagdi-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"integration":{"id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["active_farm_parcel","active_smallholder_farmer","crop_declared_for_season","data_use_authorized","district_climate_risk_active","farmer_registered","voucher_entitlement_current","voucher_not_redeemed"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":134217728,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":604800000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/conformance.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/conformance.json deleted file mode 100644 index 4743085..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"data-use-authorized-for-purpose":true,"eligible-for-climate-smart-input-voucher":true,"farmer-registered":true},"error":null,"outcome":"match","outputs":{"active_farm_parcel":true,"active_smallholder_farmer":true,"crop_declared_for_season":true,"data_use_authorized":true,"district_climate_risk_active":true,"farmer_registered":true,"voucher_entitlement_current":true,"voucher_not_redeemed":true}},"input":{"farmer_id":"FR-1001"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"active_farm_parcel":true,"active_smallholder_farmer":true,"crop_declared_for_season":true,"data_use_authorized":true,"district_climate_risk_active":true,"farmer_registered":true,"voucher_entitlement_current":true,"voucher_not_redeemed":true},"headers":{},"status":200}}}],"name":"farmer-voucher-match","request":{"claims":[{"id":"farmer-registered"},{"id":"data-use-authorized-for-purpose"},{"id":"eligible-for-climate-smart-input-voucher"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/voucher-eligibility-review","target":{"identifiers":[{"scheme":"farmer_id","value":"FR-1001"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"data-use-authorized-for-purpose":false,"eligible-for-climate-smart-input-voucher":false,"farmer-registered":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"farmer_id":"FR-9999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"farmer-voucher-no-match","request":null,"variables":{}}],"integration":"farmer-voucher-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/minimization.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/minimization.json deleted file mode 100644 index 59e808a..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"farmer-voucher-by-farmer","operations":{},"outputs":{"active_farm_parcel":{"from":"snapshot.record.active_farm_parcel","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"from":"snapshot.record.active_smallholder_farmer","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"crop_declared_for_season":{"from":"snapshot.record.crop_declared_for_season","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"data_use_authorized":{"from":"snapshot.record.data_use_authorized","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"district_climate_risk_active":{"from":"snapshot.record.district_climate_risk_active","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"farmer_registered":{"from":"snapshot.record.farmer_registered","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"from":"snapshot.record.voucher_entitlement_current","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"from":"snapshot.record.voucher_not_redeemed","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json deleted file mode 100644 index 7dfbf8c..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/farmer-voucher-by-farmer/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"farmer-voucher-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/conformance.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/conformance.json deleted file mode 100644 index 25a9c51..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"farmer_id":"FR-8888"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true},{"destination_district_open":false,"herd_vaccination_current":false,"no_conflicting_open_movement_permit":false,"origin_district_not_quarantined_for_species":false,"registered_herd":false}],"headers":{},"status":200}}}],"name":"livestock-movement-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"eligible-for-livestock-movement-permit":true,"origin-district-not-quarantined-for-species":true,"registered-herd":true},"error":null,"outcome":"match","outputs":{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true}},"input":{"farmer_id":"FR-1001"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"destination_district_open":true,"herd_vaccination_current":true,"no_conflicting_open_movement_permit":true,"origin_district_not_quarantined_for_species":true,"registered_herd":true},"headers":{},"status":200}}}],"name":"livestock-movement-match","request":{"claims":[{"id":"registered-herd"},{"id":"origin-district-not-quarantined-for-species"},{"id":"eligible-for-livestock-movement-permit"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/livestock-movement-control","target":{"identifiers":[{"scheme":"farmer_id","value":"FR-1001"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"eligible-for-livestock-movement-permit":false,"origin-district-not-quarantined-for-species":false,"registered-herd":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"farmer_id":"FR-9999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"livestock-movement-no-match","request":null,"variables":{}}],"integration":"livestock-movement-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/minimization.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/minimization.json deleted file mode 100644 index 6768a09..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"livestock-movement-by-farmer","operations":{},"outputs":{"destination_district_open":{"from":"snapshot.record.destination_district_open","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"herd_vaccination_current":{"from":"snapshot.record.herd_vaccination_current","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"from":"snapshot.record.no_conflicting_open_movement_permit","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"from":"snapshot.record.origin_district_not_quarantined_for_species","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"registered_herd":{"from":"snapshot.record.registered_herd","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/negative-security.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/negative-security.json deleted file mode 100644 index a823e60..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/evidence/livestock-movement-by-farmer/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"livestock-movement-by-farmer","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/integration-packs/farmer-voucher-by-farmer.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/integration-packs/farmer-voucher-by-farmer.json deleted file mode 100644 index ff96512..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/integration-packs/farmer-voucher-by-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52"],"minimization":["sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b"],"negative_security":["sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d"]},"input_slots":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"logical_operation":"farmer-voucher-by-farmer","output":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":604800000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"active_farm_parcel":{"nullable":false,"type":"boolean"},"active_smallholder_farmer":{"nullable":false,"type":"boolean"},"crop_declared_for_season":{"nullable":false,"type":"boolean"},"data_use_authorized":{"nullable":false,"type":"boolean"},"district_climate_risk_active":{"nullable":false,"type":"boolean"},"farmer_registered":{"nullable":false,"type":"boolean"},"voucher_entitlement_current":{"nullable":false,"type":"boolean"},"voucher_not_redeemed":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"farmer_id":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/integration-packs/livestock-movement-by-farmer.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/integration-packs/livestock-movement-by-farmer.json deleted file mode 100644 index 043c50a..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/integration-packs/livestock-movement-by-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910"],"minimization":["sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5"],"negative_security":["sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce"]},"input_slots":{"farmer_id":{"maxLength":16,"pattern":"^FR-[0-9]+$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":64}},"logical_operation":"livestock-movement-by-farmer","output":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":604800000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"destination_district_open":{"nullable":false,"type":"boolean"},"herd_vaccination_current":{"nullable":false,"type":"boolean"},"no_conflicting_open_movement_permit":{"nullable":false,"type":"boolean"},"origin_district_not_quarantined_for_species":{"nullable":false,"type":"boolean"},"registered_herd":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"farmer_id":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/private-bindings/livestock-herd.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/private-bindings/livestock-herd.json deleted file mode 100644 index 7db8adf..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/private-bindings/livestock-herd.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092","id":"solmara-nagdi-agriculture.livestock-movement-by-farmer","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"farmer_id":{"comparison":"binary_equality","input":"farmer_id","physical_field":"farmer_id","physical_type":"utf8"}},"projection":{"destination_district_open":"destination_district_open","herd_vaccination_current":"herd_vaccination_current","no_conflicting_open_movement_permit":"no_conflicting_open_movement_permit","origin_district_not_quarantined_for_species":"origin_district_not_quarantined_for_species","registered_herd":"registered_herd"}},"table_provider":"nagdi_herd__materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b"},"profile":{"id":"solmara-nagdi-agriculture.livestock.herd","version":"1"},"registry_instance":"solmara-nagdi-agriculture","source_instance":"materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b","tenant":"solmara-nagdi-agriculture"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/private-bindings/voucher-farmer.json b/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/private-bindings/voucher-farmer.json deleted file mode 100644 index eddcecb..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/artifacts/private-bindings/voucher-farmer.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd","id":"solmara-nagdi-agriculture.farmer-voucher-by-farmer","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"farmer_id":{"comparison":"binary_equality","input":"farmer_id","physical_field":"farmer_id","physical_type":"utf8"}},"projection":{"active_farm_parcel":"active_farm_parcel","active_smallholder_farmer":"active_smallholder_farmer","crop_declared_for_season":"crop_declared_for_season","data_use_authorized":"data_use_authorized","district_climate_risk_active":"district_climate_risk_active","farmer_registered":"farmer_registered","voucher_entitlement_current":"voucher_entitlement_current","voucher_not_redeemed":"voucher_not_redeemed"}},"table_provider":"nagdi_farmer__materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7"},"profile":{"id":"solmara-nagdi-agriculture.voucher.farmer","version":"1"},"registry_instance":"solmara-nagdi-agriculture","source_instance":"materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7","tenant":"solmara-nagdi-agriculture"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/relay-consultation.yaml b/runtime/registry-projects/local/nagdi-agriculture/relay/relay-consultation.yaml deleted file mode 100644 index 1ec71f5..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/relay-consultation.yaml +++ /dev/null @@ -1,234 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -consultation: - artifacts: - evidence: - - class: conformance - path: artifacts/evidence/livestock-movement-by-farmer/conformance.json - sha256: sha256:10bdc960cb62d9030cee7895a45ce77a6df1ebe133f7beac2d6feb05aaf18910 - - class: minimization - path: artifacts/evidence/livestock-movement-by-farmer/minimization.json - sha256: sha256:260f25e125f7e98d53c4daff92af0b92a6a3a3992f17914782cef9b2441356c5 - - class: conformance - path: artifacts/evidence/farmer-voucher-by-farmer/conformance.json - sha256: sha256:6d1e0b039f6ed71b79f382260701582ef255c1b377e7d01ee942b1b9c2c53e52 - - class: negative_security - path: artifacts/evidence/farmer-voucher-by-farmer/negative-security.json - sha256: sha256:aee98b658e32ced730958f390ab158801d7c0023420f476067aaf5a7e8d58e0d - - class: negative_security - path: artifacts/evidence/livestock-movement-by-farmer/negative-security.json - sha256: sha256:db2275b39b446020d10e7b729bf9d5c1c3e73651b5019f60809ef2af55d789ce - - class: minimization - path: artifacts/evidence/farmer-voucher-by-farmer/minimization.json - sha256: sha256:ff7da0d595c08d92a85ca406da498fb54b49046332466b28e8fd960912e9a32b - integration_packs: - - hash: sha256:eef622a1e97ac245493f2348cecad9b3b9245b7b0ce30d9db7ed602cc80827cd - path: artifacts/integration-packs/farmer-voucher-by-farmer.json - sha256: sha256:ed6f77b36df0d70ad9ffea72fdfedc21cc0a3621db3cdf4ea70d18f8960db12c - - hash: sha256:9d1daa45060e9f5c073be4a42f175c398907d7f00c650554aeddeedb9bd94092 - path: artifacts/integration-packs/livestock-movement-by-farmer.json - sha256: sha256:7af1198e4ba477344621f4361613c5ba1d0d30bf1a2bc94e94b7a7c5d8e8ca16 - private_bindings: - - hash: sha256:f319d52859657a0a1acaabba01034558012eeeb11e36e406d9def07a72814416 - path: artifacts/private-bindings/livestock-herd.json - sha256: sha256:33efab2ec578e7baf3dc70c7a6c139eb76a5cd80f4a2c753fc474b795477d306 - - hash: sha256:04ea387eadcc2279e7c791241c46b77f41dcbb02bd15919a3e246370f3443714 - path: artifacts/private-bindings/voucher-farmer.json - sha256: sha256:75a3c0379a33f5c7029cf91ebb87a42c661a5e8ddd93c5fb7edf37f0f5f51d9b - public_contracts: - - hash: sha256:9eef29eec9341e5425a81d9c835232293f68a88652008793b2045e1711e6ef60 - path: artifacts/consultation-contracts/livestock-herd.json - sha256: sha256:a1f4045c36462881aec560fcf103c1f78252bf0e2acf81ddd6b75ce21ccfd84c - - hash: sha256:8c72e6f499969307d6d4bce11dbdf493ead8306880f359ab458e55f2e2abde0c - path: artifacts/consultation-contracts/voucher-farmer.json - sha256: sha256:8ea73ba2779ef33a85cc5f34988fe0d14f13f1892baa185d0d5c67e76dcbe1ad - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nagdi-notary - principal_id: nagdi-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4554467022379962639 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 7450858971948805253 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: nagdi-agriculture-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/nagdi-agriculture/relay/relay.yaml b/runtime/registry-projects/local/nagdi-agriculture/relay/relay.yaml deleted file mode 100644 index e58cf8f..0000000 --- a/runtime/registry-projects/local/nagdi-agriculture/relay/relay.yaml +++ /dev/null @@ -1,174 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - nagdi-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-nagdi-agriculture - title: solmara-nagdi-agriculture governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_farmer entity - entities: [] - id: nagdi_farmer - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_farmer:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a5df746e609cc2000dcc08b086dce4fbd407a4908364552aa2ea32be9d99c6f7 - materialization: snapshot - primary_key: farmer_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: active_farm_parcel - nullable: false - sensitive: true - type: boolean - - name: active_smallholder_farmer - nullable: false - sensitive: true - type: boolean - - name: crop_declared_for_season - nullable: false - sensitive: true - type: boolean - - name: data_use_authorized - nullable: false - sensitive: true - type: boolean - - name: district_climate_risk_active - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: farmer_registered - nullable: false - sensitive: true - type: boolean - - name: voucher_entitlement_current - nullable: false - sensitive: true - type: boolean - - name: voucher_not_redeemed - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/farmer_voucher.csv - type: file - title: nagdi_farmer - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized nagdi_herd entity - entities: [] - id: nagdi_herd - owner: solmara-nagdi-agriculture - sensitivity: personal - tables: - - access: - aggregate_scope: nagdi_herd:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c530010904da8a151e2b3437cbb64d2c54dcbc140b0fcf9d6331e97e73063e9b - materialization: snapshot - primary_key: herd_id - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: destination_district_open - nullable: false - sensitive: true - type: boolean - - name: farmer_id - nullable: false - sensitive: true - type: string - - name: herd_id - nullable: false - sensitive: true - type: string - - name: herd_vaccination_current - nullable: false - sensitive: true - type: boolean - - name: no_conflicting_open_movement_permit - nullable: false - sensitive: true - type: boolean - - name: origin_district_not_quarantined_for_species - nullable: false - sensitive: true - type: boolean - - name: registered_herd - nullable: false - sensitive: true - type: boolean - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/agriculture-nagdi/fixtures/livestock_movement.csv - type: file - title: nagdi_herd - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: nagdi-agriculture-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/nia-population/notary/notary.yaml b/runtime/registry-projects/local/nia-population/notary/notary.yaml deleted file mode 100644 index 0bfc2da..0000000 --- a/runtime/registry-projects/local/nia-population/notary/notary.yaml +++ /dev/null @@ -1,154 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - evidence:nia:child-benefit:read - - fingerprint: - name: NIA_CITIZEN_CLIENT_TOKEN_HASH - provider: env - id: citizen-portal - scopes: - - evidence:nia:citizen:read -cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - - https://id.registrystack.org/solmara/purpose/citizen-self-service - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - population: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - alive: - nullable: false - type: boolean - identity_status: - max_bytes: 128 - nullable: false - type: string - profile: - contract_hash: sha256:2b34d7466e4f45126055cdb3ec18026a6f0cf3051a863ad951eedfe6ed729958 - id: solmara-nia-population.nia-child-benefit.population - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: population-record-active - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - evidence:nia:child-benefit:read - rule: - bindings: {} - expression: |- - population.matched && population.identity_status == 'active' - && population.alive == true - type: cel - subject_type: person - title: population record active - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - nia-citizen-status.citizen-population-status - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - population: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - alive: - nullable: false - type: boolean - identity_status: - max_bytes: 128 - nullable: false - type: string - profile: - contract_hash: sha256:9433a86942bdfbb1add5e45e2c3462eb224a2e8097be0267a52eeaaecfde0c69 - id: solmara-nia-population.nia-citizen-status.population - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: citizen-population-record-active - purpose: https://id.registrystack.org/solmara/purpose/citizen-self-service - required_scopes: - - evidence:nia:citizen:read - rule: - bindings: {} - expression: |- - population.matched && population.identity_status == 'active' - && population.alive == true - type: cel - subject_type: person - title: citizen population record active - value: - nullable: false - type: boolean - version: '1' - credential_profiles: - nia-citizen-status.citizen-population-status: - allowed_claims: - - citizen-population-record-active - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:nia - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/citizen-population-status - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/nia-notary-relay-token - workload_client_id: nia-notary - service_id: nia-notary - signing_keys: - project-issuer: - alg: EdDSA - kid: did:web:id.registrystack.org:solmara:authority:nia#issuer-key-1 - private_jwk_env: NIA_NOTARY_ISSUER_JWK - provider: local_jwk_env - status: active - variables: {} -instance: - environment: local - id: nia-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/consultation-contracts/nia-child-benefit-population.json b/runtime/registry-projects/local/nia-population/relay/artifacts/consultation-contracts/nia-child-benefit-population.json deleted file mode 100644 index 90ad104..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/consultation-contracts/nia-child-benefit-population.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.nia-child-benefit.population","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:7af974a445cf4ebaec93647f7d6aa80732d70829150702ab0891c06f91850359","id":"relay.nia-child-benefit.population","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:nia-child-benefit","workload":"nia-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["alive","identity_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":536870912,"max_source_records":10000000},"immutable_generation":true,"max_snapshot_age_ms":300000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/consultation-contracts/nia-citizen-status-population.json b/runtime/registry-projects/local/nia-population/relay/artifacts/consultation-contracts/nia-citizen-status-population.json deleted file mode 100644 index 08977c4..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/consultation-contracts/nia-citizen-status-population.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.nia-citizen-status.population","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"citizen-self-service","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:0f648848f4934f37c2dbce9e58040e2ce7b43d39a5d1c8d0391cdd4aa07b9834","id":"relay.nia-citizen-status.population","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/citizen-self-service"],"required_scope":"registry:consult:nia-citizen-status","workload":"nia-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["alive","identity_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":536870912,"max_source_records":10000000},"immutable_generation":true,"max_snapshot_age_ms":300000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/conformance.json b/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/conformance.json deleted file mode 100644 index 752267e..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"citizen-population-record-active":true,"population-record-active":true},"error":null,"outcome":"match","outputs":{"alive":true,"identity_status":"active"}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"alive":true,"identity_status":"active"},"headers":{},"status":200}}}],"name":"nia-population-person-match","request":{"claims":[{"id":"population-record-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"citizen-population-record-active":false,"population-record-active":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"nia-population-person-no-match","request":{"claims":[{"id":"citizen-population-record-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/citizen-self-service","target":{"identifiers":[{"scheme":"solmara_uin","value":"9999999999"}],"type":"Person"}},"variables":{}}],"integration":"solmara-nia-population-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/minimization.json b/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/minimization.json deleted file mode 100644 index 223b391..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-nia-population-person-by-uin","operations":{},"outputs":{"alive":{"from":"snapshot.record.alive","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"},"identity_status":{"from":"snapshot.record.identity_status","max_bytes":128,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/negative-security.json b/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/negative-security.json deleted file mode 100644 index 53dcfa4..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/evidence/population-person-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-nia-population-person-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/integration-packs/population-person-by-uin.json b/runtime/registry-projects/local/nia-population/relay/artifacts/integration-packs/population-person-by-uin.json deleted file mode 100644 index 7430026..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/integration-packs/population-person-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-nia-population.solmara-nia-population-person-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd"],"minimization":["sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407"],"negative_security":["sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-nia-population-person-by-uin","output":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":300000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"alive":{"nullable":false,"type":"boolean"},"identity_status":{"max_bytes":128,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/private-bindings/nia-child-benefit-population.json b/runtime/registry-projects/local/nia-population/relay/artifacts/private-bindings/nia-child-benefit-population.json deleted file mode 100644 index 9d14494..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/private-bindings/nia-child-benefit-population.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9","id":"solmara-nia-population.solmara-nia-population-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"alive":"alive","identity_status":"identity_status"}},"table_provider":"population__materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d"},"profile":{"id":"solmara-nia-population.nia-child-benefit.population","version":"1"},"registry_instance":"solmara-nia-population","source_instance":"materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d","tenant":"solmara-nia-population"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nia-population/relay/artifacts/private-bindings/nia-citizen-status-population.json b/runtime/registry-projects/local/nia-population/relay/artifacts/private-bindings/nia-citizen-status-population.json deleted file mode 100644 index 735159f..0000000 --- a/runtime/registry-projects/local/nia-population/relay/artifacts/private-bindings/nia-citizen-status-population.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9","id":"solmara-nia-population.solmara-nia-population-person-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"alive":"alive","identity_status":"identity_status"}},"table_provider":"population__materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d"},"profile":{"id":"solmara-nia-population.nia-citizen-status.population","version":"1"},"registry_instance":"solmara-nia-population","source_instance":"materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d","tenant":"solmara-nia-population"} \ No newline at end of file diff --git a/runtime/registry-projects/local/nia-population/relay/relay-consultation.yaml b/runtime/registry-projects/local/nia-population/relay/relay-consultation.yaml deleted file mode 100644 index da20425..0000000 --- a/runtime/registry-projects/local/nia-population/relay/relay-consultation.yaml +++ /dev/null @@ -1,251 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - nia-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/population-person-by-uin/minimization.json - sha256: sha256:af6daeecb8d517d40d9b631abf938dceac510f1a6fb9da8eebbb29034c2a2407 - - class: conformance - path: artifacts/evidence/population-person-by-uin/conformance.json - sha256: sha256:bae5ef90128eb60a3ef4a3eeea3bea815e73bf6ccfdd521a48a3043446bd5fbd - - class: negative_security - path: artifacts/evidence/population-person-by-uin/negative-security.json - sha256: sha256:bc4aecd3a77aa8f7f92699b4a39b5bd43e760fae1d4243856a6f1457781806ab - integration_packs: - - hash: sha256:72b42442af5dffa0ad96ef2bb6266fd422ae1f16299b3fc82dbd3bf3fd9b58e9 - path: artifacts/integration-packs/population-person-by-uin.json - sha256: sha256:83e0e332c81c331e2329535fdf1983be1427359a0e1bdf16986b8bc4a2f24b3e - private_bindings: - - hash: sha256:fa33b620ee699baa8efb7f233dc46884826b4e4b64940a0fa27cca5b1871bb40 - path: artifacts/private-bindings/nia-child-benefit-population.json - sha256: sha256:41dd7eba725d043adbdfbfcc7a95178364970c5b8f76525fa75db87c31d228b6 - - hash: sha256:286382e48365cf203f3b0b5712bb6c8f94d18d33657078dc2522e42afb76aa8f - path: artifacts/private-bindings/nia-citizen-status-population.json - sha256: sha256:89e52d120d4c5ab0fc451e74e2fe0bb11966e7e1e4ab59505713d78f567ae8b7 - public_contracts: - - hash: sha256:2b34d7466e4f45126055cdb3ec18026a6f0cf3051a863ad951eedfe6ed729958 - path: artifacts/consultation-contracts/nia-child-benefit-population.json - sha256: sha256:43e56a4eaff600b7fbb8ca382f6d00bee299dcd9e528f50de89143ad9f3502f3 - - hash: sha256:9433a86942bdfbb1add5e45e2c3462eb224a2e8097be0267a52eeaaecfde0c69 - path: artifacts/consultation-contracts/nia-citizen-status-population.json - sha256: sha256:3ecdd494bdec2c8b591c24385a3c808d49a9b9562f9809e1a7dd24dcc66693a1 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: nia-notary - principal_id: nia-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 1016450306001272100 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6539803953464966811 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: local -instance: - environment: local - id: nia-population-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/nia-population/relay/relay.yaml b/runtime/registry-projects/local/nia-population/relay/relay.yaml deleted file mode 100644 index 3ea4469..0000000 --- a/runtime/registry-projects/local/nia-population/relay/relay.yaml +++ /dev/null @@ -1,203 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - solmara-esignet - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-nia-population - title: solmara-nia-population governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Governed population attributes used by NIA-owned services. - entities: - - access: - aggregate_scope: population:aggregate - evidence_verification_scope: population:evidence_verification - metadata_scope: population:metadata - read_scope: population:rows - aggregates: [] - api: - allowed_expansions: [] - allowed_filters: - - field: legacy_nid - ops: - - eq - - field: uin - ops: - - eq - default_limit: 25 - governed_policy: - allowed_assurance: [] - permitted_jurisdictions: [] - permitted_purposes: - - https://id.registrystack.org/solmara/purpose/esignet-identity-verification - redaction_fields: [] - require_consent: false - require_legal_basis: false - trusted_context: {} - max_limit: 100 - require_purpose_header: true - required_filter_bindings: [] - required_filters: [] - attribute_release_profiles: - - claims: - - expression: null - name: birthdate - required: true - sensitivity: personal - source_field: birth_date - - expression: null - name: family_name - required: true - sensitivity: direct_identifier - source_field: family_name - - expression: null - name: gender - required: false - sensitivity: personal - source_field: sex - - expression: null - name: given_name - required: true - sensitivity: direct_identifier - source_field: given_name - - expression: null - name: individual_id - required: true - sensitivity: direct_identifier - source_field: uin - - expression: - cel: source.given_name + ' ' + source.family_name - name: name - required: true - sensitivity: direct_identifier - source_field: null - description: Minimal identity attributes for the Solmara eSignet authenticator. - id: solmara-nia-userinfo - purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification - release_conditions: - expression: - cel: source.identity_status == 'active' && source.alive == true - release_scope: population:identity_release - subject: - id_type: national_id - source_field: uin - title: SolmaraID UserInfo - version: v1 - description: Governed population attributes used by NIA-owned services. - fields: - - from: alive - name: alive - sensitive: true - - from: birth_date - name: birth_date - sensitive: true - - from: family_name - name: family_name - sensitive: true - - from: given_name - name: given_name - sensitive: true - - from: identity_status - name: identity_status - sensitive: true - - from: legacy_nid - name: legacy_nid - sensitive: true - - from: sex - name: sex - sensitive: true - - from: uin - name: uin - sensitive: true - name: population - relationships: [] - spatial: null - table: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - title: NIA population records - id: population - owner: National Identity Agency - sensitivity: personal - tables: - - access: - aggregate_scope: population:aggregate - metadata_scope: population:metadata - aggregates: [] - api: - allowed_filters: [] - default_limit: 25 - max_limit: 100 - require_purpose_header: true - id: materialization_aae79577068250722b57ed81fc12d038a35dc36e179ee2d7efb168fd9942f05d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: alive - nullable: false - sensitive: true - type: boolean - - name: birth_date - nullable: false - sensitive: true - type: date - - name: family_name - nullable: false - sensitive: true - type: string - - name: given_name - nullable: false - sensitive: true - type: string - - name: identity_status - nullable: false - sensitive: true - type: string - - name: legacy_nid - nullable: true - sensitive: true - type: string - - name: sex - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_NIA_DATABASE_URL - table: - name: population_person - schema: public - type: postgres - title: NIA population records - update_frequency: continuous -deployment: - profile: local -instance: - environment: local - id: nia-population-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/sipf-pensions/notary/notary.yaml b/runtime/registry-projects/local/sipf-pensions/notary/notary.yaml deleted file mode 100644 index a3d7b1f..0000000 --- a/runtime/registry-projects/local/sipf-pensions/notary/notary.yaml +++ /dev/null @@ -1,138 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: SIPF_PENSION_CLIENT_TOKEN_HASH - provider: env - id: pension-review-desk - scopes: - - evidence:sipf:pension-payment:read - - evidence:sipf:survivor-benefit:read -cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/pension-payment-review - - https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - pension: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - payment_status: - max_bytes: 128 - nullable: false - type: string - profile: - contract_hash: sha256:04deb5f47932e163e61b8f7ce9c4d4adff54a7558128edeb367518962abde1bd - id: solmara-sipf-pensions.sipf-pension-payment-review.pension - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: pension-payment-active - purpose: https://id.registrystack.org/solmara/purpose/pension-payment-review - required_scopes: - - evidence:sipf:pension-payment:read - rule: - bindings: {} - expression: pension.matched && pension.payment_status == 'active' - type: cel - subject_type: person - title: pension payment active - value: - nullable: false - type: boolean - version: '1' - - credential_profiles: - - sipf-survivor-benefit.survivor-benefit-status - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - survivor: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - survivor_eligible: - nullable: false - type: boolean - profile: - contract_hash: sha256:de1c2295d58905dc34046288edf36263fcccf99cf684deaa885ab76f472ed6d6 - id: solmara-sipf-pensions.sipf-survivor-benefit.survivor - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: survivor-is-eligible - purpose: https://id.registrystack.org/solmara/purpose/survivor-benefit-determination - required_scopes: - - evidence:sipf:survivor-benefit:read - rule: - bindings: {} - expression: survivor.matched && survivor.survivor_eligible == true - type: cel - subject_type: person - title: survivor is eligible - value: - nullable: false - type: boolean - version: '1' - credential_profiles: - sipf-survivor-benefit.survivor-benefit-status: - allowed_claims: - - survivor-is-eligible - disclosure: - allowed: - - value - - predicate - - redacted - format: application/dc+sd-jwt - issuer: did:web:id.registrystack.org:solmara:authority:sipf - signing_key: project-issuer - validity_seconds: 600 - vct: https://id.registrystack.org/solmara/vct/survivor-benefit-status - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/sipf-notary-relay-token - workload_client_id: sipf-notary - service_id: sipf-notary - signing_keys: - project-issuer: - alg: EdDSA - kid: did:web:id.registrystack.org:solmara:authority:sipf#issuer-key-1 - private_jwk_env: SIPF_NOTARY_ISSUER_JWK - provider: local_jwk_env - status: active - variables: {} -instance: - environment: local - id: sipf-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json deleted file mode 100644 index ad9440e..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/consultation-contracts/sipf-pension-payment-review-pension.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.sipf-pension-payment-review.pension","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:96e56b4b170376860f623777977f7c72a19cd960854a48f568e1e92ca33f85e7","id":"relay.sipf-pension-payment-review.pension","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/pension-payment-review"],"required_scope":"registry:consult:sipf-pension-payment-review","workload":"sipf-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["payment_status"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json deleted file mode 100644 index a4fd9c0..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.sipf-survivor-benefit.survivor","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}}},"authorization":{"consent":{"required":false},"legal_basis":"social-insurance-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:ec6b5310716606dac48159abec1e512757fb9a18bff8a6e0ac634db932b2696b","id":"relay.sipf-survivor-benefit.survivor","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"],"required_scope":"registry:consult:sipf-survivor-benefit","workload":"sipf-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["survivor_eligible"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":268435456,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":86400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/conformance.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/conformance.json deleted file mode 100644 index 8babb7e..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"uin":"2300109568"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"payment_status":"active"},{"payment_status":"suspended"}],"headers":{},"status":200}}}],"name":"sipf-pension-case-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"pension-payment-active":true},"error":null,"outcome":"match","outputs":{"payment_status":"active"}},"input":{"uin":"2300109568"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"payment_status":"active"},"headers":{},"status":200}}}],"name":"sipf-pension-case-match","request":{"claims":[{"id":"pension-payment-active"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/pension-payment-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300109568"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"pension-payment-active":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"sipf-pension-case-no-match","request":null,"variables":{}}],"integration":"solmara-sipf-pension-case-by-pensioner","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/minimization.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/minimization.json deleted file mode 100644 index 27b99c4..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-sipf-pension-case-by-pensioner","operations":{},"outputs":{"payment_status":{"from":"snapshot.record.payment_status","max_bytes":128,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/negative-security.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/negative-security.json deleted file mode 100644 index c62fd58..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-pensioner/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-sipf-pension-case-by-pensioner","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/conformance.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/conformance.json deleted file mode 100644 index e33e4f9..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{},"error":null,"outcome":"ambiguous","outputs":{}},"input":{"uin":"2300118698"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[{"survivor_eligible":true},{"survivor_eligible":false}],"headers":{},"status":200}}}],"name":"sipf-survivor-case-ambiguous","request":null,"variables":{}},{"classification":"synthetic","expect":{"claims":{"survivor-is-eligible":true},"error":null,"outcome":"match","outputs":{"survivor_eligible":true}},"input":{"uin":"2300118698"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"survivor_eligible":true},"headers":{},"status":200}}}],"name":"sipf-survivor-case-match","request":{"claims":[{"id":"survivor-is-eligible"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/survivor-benefit-determination","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300118698"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"survivor-is-eligible":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"9999999999"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"sipf-survivor-case-no-match","request":null,"variables":{}}],"integration":"solmara-sipf-pension-case-by-survivor","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/minimization.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/minimization.json deleted file mode 100644 index e246e22..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"solmara-sipf-pension-case-by-survivor","operations":{},"outputs":{"survivor_eligible":{"from":"snapshot.record.survivor_eligible","max_bytes":null,"maximum":null,"minimum":null,"nullable":false,"type":"boolean"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/negative-security.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/negative-security.json deleted file mode 100644 index 72f6ab7..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/evidence/pension-case-by-survivor/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"solmara-sipf-pension-case-by-survivor","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-pensioner.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-pensioner.json deleted file mode 100644 index 7fac5ec..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-pensioner.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e"],"minimization":["sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660"],"negative_security":["sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-sipf-pension-case-by-pensioner","output":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"payment_status":{"max_bytes":128,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-survivor.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-survivor.json deleted file mode 100644 index a0e0dda..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/integration-packs/pension-case-by-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059"],"minimization":["sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f"],"negative_security":["sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"solmara-sipf-pension-case-by-survivor","output":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":86400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"survivor_eligible":{"nullable":false,"type":"boolean"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/private-bindings/sipf-pension-payment-review-pension.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/private-bindings/sipf-pension-payment-review-pension.json deleted file mode 100644 index 2b3b78b..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/private-bindings/sipf-pension-payment-review-pension.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e","id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-pensioner","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"pensioner_uin","physical_type":"utf8"}},"projection":{"payment_status":"payment_status"}},"table_provider":"pension__materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21"},"profile":{"id":"solmara-sipf-pensions.sipf-pension-payment-review.pension","version":"1"},"registry_instance":"solmara-sipf-pensions","source_instance":"materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21","tenant":"solmara-sipf-pensions"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/private-bindings/sipf-survivor-benefit-survivor.json b/runtime/registry-projects/local/sipf-pensions/relay/artifacts/private-bindings/sipf-survivor-benefit-survivor.json deleted file mode 100644 index 8485dcd..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/artifacts/private-bindings/sipf-survivor-benefit-survivor.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235","id":"solmara-sipf-pensions.solmara-sipf-pension-case-by-survivor","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"spouse_uin","physical_type":"utf8"}},"projection":{"survivor_eligible":"survivor_eligible"}},"table_provider":"survivor__materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539"},"profile":{"id":"solmara-sipf-pensions.sipf-survivor-benefit.survivor","version":"1"},"registry_instance":"solmara-sipf-pensions","source_instance":"materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539","tenant":"solmara-sipf-pensions"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sipf-pensions/relay/relay-consultation.yaml b/runtime/registry-projects/local/sipf-pensions/relay/relay-consultation.yaml deleted file mode 100644 index a9cae68..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/relay-consultation.yaml +++ /dev/null @@ -1,182 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - sipf-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -consultation: - artifacts: - evidence: - - class: minimization - path: artifacts/evidence/pension-case-by-survivor/minimization.json - sha256: sha256:1a5c2d191a2468a4898556ef0e1cc164847f8df69e86a09c8f4c73eb1c705e5f - - class: conformance - path: artifacts/evidence/pension-case-by-pensioner/conformance.json - sha256: sha256:34d1119e31d3a2e22571b9532541d057874aebebbde363ed80a137bce5a6772e - - class: negative_security - path: artifacts/evidence/pension-case-by-survivor/negative-security.json - sha256: sha256:4ee198ac5719ce112ceb91f596e75dcbd933a8d6ceb0051910b876a3789147c7 - - class: minimization - path: artifacts/evidence/pension-case-by-pensioner/minimization.json - sha256: sha256:7955c481459333ab942755b224be6da87f180ea87b3e6bb35e1b56ddcdab5660 - - class: conformance - path: artifacts/evidence/pension-case-by-survivor/conformance.json - sha256: sha256:a1f2073fc39da5b7a4c52bbd6d8f7a414e719b5c9dcf293509afac4d27f39059 - - class: negative_security - path: artifacts/evidence/pension-case-by-pensioner/negative-security.json - sha256: sha256:dd7bf51e1b1a639d515b9aad9e3b598aa7b4f54760b191a8c363e28abd4e8a2d - integration_packs: - - hash: sha256:ebcce823eb11489afd04cce13a550317ae7f9b9ef6c503bd68490d26085ab73e - path: artifacts/integration-packs/pension-case-by-pensioner.json - sha256: sha256:6252d74d83d4074d4eee0ae60cb2d1aac357c1a0a56664ab92120933ad0e77b3 - - hash: sha256:f4416975dcbe4c9eaea878107ea9e97a10b1fd9e02484b1d5b498b5c805d7235 - path: artifacts/integration-packs/pension-case-by-survivor.json - sha256: sha256:ca28e59689fa5e842f1435cd8c7bf922117ff2afc7bf3af08788781fcf458168 - private_bindings: - - hash: sha256:928e8a72b68fcdf9fe3ae4381cc71918d78a5c8f8bd138be09df42528a6fdcd3 - path: artifacts/private-bindings/sipf-pension-payment-review-pension.json - sha256: sha256:9499ffd554e9d00acdf62509e3dad500834050c4abff6ce0b1cc299fa35b7828 - - hash: sha256:5b46b40895d8fc69247e473ff117e119a08453121f5ecc9215e1297961ae701d - path: artifacts/private-bindings/sipf-survivor-benefit-survivor.json - sha256: sha256:37a68f42bf4870f69eb4faf6c820049c868951b49edbbe99483e61ae2cbde463 - public_contracts: - - hash: sha256:04deb5f47932e163e61b8f7ce9c4d4adff54a7558128edeb367518962abde1bd - path: artifacts/consultation-contracts/sipf-pension-payment-review-pension.json - sha256: sha256:75211b8be4be68778d4a0aa044fa46778b507017028dfa1b43c9330f9700a167 - - hash: sha256:de1c2295d58905dc34046288edf36263fcccf99cf684deaa885ab76f472ed6d6 - path: artifacts/consultation-contracts/sipf-survivor-benefit-survivor.json - sha256: sha256:6bebf5122b4248dbc3eaefc5e3f7de1705796e30a1b8011b8c0fc3adf2c255f9 - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sipf-notary - principal_id: sipf-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 4444991830243562918 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 6117019989203477116 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: sipf-pensions-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/sipf-pensions/relay/relay.yaml b/runtime/registry-projects/local/sipf-pensions/relay/relay.yaml deleted file mode 100644 index 0fc6e7f..0000000 --- a/runtime/registry-projects/local/sipf-pensions/relay/relay.yaml +++ /dev/null @@ -1,121 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: [] - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-sipf-pensions - title: solmara-sipf-pensions governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized pension entity - entities: [] - id: pension - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: pension:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_e378f482aa5f7ec6ca19d81f76802de996046719bcf2976f033a9ff5c8dedd21 - materialization: snapshot - primary_key: pensioner_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: payment_status - nullable: false - sensitive: true - type: string - - name: pensioner_uin - nullable: false - sensitive: true - type: string - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_pension_payment - schema: public - type: postgres - title: pension - update_frequency: as_needed -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized survivor entity - entities: [] - id: survivor - owner: solmara-sipf-pensions - sensitivity: personal - tables: - - access: - aggregate_scope: survivor:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_c346e9a9e571c9cff9cc50780a950c7e58e5cd048091b530157c5cb540d9d539 - materialization: snapshot - primary_key: spouse_uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: spouse_uin - nullable: false - sensitive: true - type: string - - name: survivor_eligible - nullable: false - sensitive: true - type: boolean - strict: true - source: - connection_env: SOLMARA_SIPF_DATABASE_URL - table: - name: sipf_survivor_benefit - schema: public - type: postgres - title: survivor - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: sipf-pensions-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/sro-social/notary/notary.yaml b/runtime/registry-projects/local/sro-social/notary/notary.yaml deleted file mode 100644 index 6723e8e..0000000 --- a/runtime/registry-projects/local/sro-social/notary/notary.yaml +++ /dev/null @@ -1,79 +0,0 @@ -audit: - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - sink: stdout -auth: - api_keys: - - fingerprint: - name: SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH - provider: env - id: child-benefit-federator - scopes: - - sro_social:evidence_verification -cel: - worker_memory_bytes: 1073741824 -deployment: - profile: local -evidence: - allowed_purposes: - - https://id.registrystack.org/solmara/purpose/child-benefit-review - claims: - - credential_profiles: [] - disclosure: - allowed: - - predicate - - redacted - default: predicate - downgrade: deny - evidence_mode: - consultations: - household: - inputs: - uin: request.target.identifiers.solmara_uin - outputs: - poverty_band: - max_bytes: 64 - nullable: false - type: string - profile: - contract_hash: sha256:7c6800ab1eea861c2e2500ba3e93bb9ddcb6d1212d7701756098155faaee4697 - id: solmara-sro-social.child-benefit.household - type: registry_backed - formats: - - application/vnd.registry-notary.claim-result+json - id: household-below-poverty-threshold - purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review - required_scopes: - - sro_social:evidence_verification - rule: - bindings: {} - expression: household.matched && household.poverty_band == "priority" - type: cel - subject_type: person - title: household below poverty threshold - value: - nullable: false - type: boolean - version: '1' - credential_profiles: {} - enabled: true - max_credential_validity_seconds: 600 - relay: - allow_insecure_localhost: true - allowed_private_cidrs: [] - base_url: http://127.0.0.1:8080 - max_in_flight: 8 - token_file: /run/secrets/sro-notary-relay-token - workload_client_id: sro-notary - service_id: sro-notary - variables: {} -instance: - environment: local - id: sro-notary -server: - bind: 127.0.0.1:8081 - request_timeout: 30s -state: - postgresql: - root_certificate_path: /etc/solmara/postgres/root.crt - url_env: REGISTRY_NOTARY_POSTGRES_URL - storage: postgresql diff --git a/runtime/registry-projects/local/sro-social/relay/artifacts/consultation-contracts/child-benefit-household.json b/runtime/registry-projects/local/sro-social/relay/artifacts/consultation-contracts/child-benefit-household.json deleted file mode 100644 index e8bbb22..0000000 --- a/runtime/registry-projects/local/sro-social/relay/artifacts/consultation-contracts/child-benefit-household.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sro-social.child-benefit.household","schema":"registry.relay.consultation-contract.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}}},"authorization":{"consent":{"required":false},"legal_basis":"child-benefit-act","mandatory_obligations":[],"policy":{"decision_cache":"disabled","hash":"sha256:1f2bc3ace6f53ef9e6aa82974582fdd31113142f164f5afaf8328dafe39d92d3","id":"relay.child-benefit.household","max_decision_age_ms":1000,"unavailable":"deny"},"purposes":["https://id.registrystack.org/solmara/purpose/child-benefit-review"],"required_scope":"registry:consult:child-benefit","workload":"sro-notary"},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"inputs":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"integration":{"id":"solmara-sro-social.child-benefit-household-by-uin","revision":1},"materialization":{"digest_bound_active_pointer":true,"footprint":{"fields":["poverty_band"],"max_credential_exchanges":0,"max_data_destinations":1,"max_data_exchanges":1,"max_source_bytes":67108864,"max_source_records":1000000},"immutable_generation":true,"max_snapshot_age_ms":2678400000,"refresh_class":"scheduled","snapshot_retention_generations":2,"stale_behavior":"unavailable"},"output":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"public_behavior":{"denial_code":"consultation.denied","denial_timing_profile":"measured-uniform-v1","outcomes":["match","no_match","ambiguous"]},"runtime":{"platform_profile":"registry-stack.consultation.v1","script_abi":null,"source_capability":"snapshot"},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}},"subject":{"mode":"single_subject","selector_provenance":{"type":"workload_selected"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/conformance.json b/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/conformance.json deleted file mode 100644 index 6a52524..0000000 --- a/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/conformance.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"conformance","fixtures":[{"classification":"synthetic","expect":{"claims":{"household-below-poverty-threshold":true},"error":null,"outcome":"match","outputs":{"poverty_band":"priority"}},"input":{"uin":"2300010248"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":{"poverty_band":"priority"},"headers":{},"status":200}}}],"name":"child-benefit-household-match","request":{"claims":[{"id":"household-below-poverty-threshold"}],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json","purpose":"https://id.registrystack.org/solmara/purpose/child-benefit-review","target":{"identifiers":[{"scheme":"solmara_uin","value":"2300010248"}],"type":"Person"}},"variables":{}},{"classification":"synthetic","expect":{"claims":{"household-below-poverty-threshold":false},"error":null,"outcome":"no_match","outputs":{}},"input":{"uin":"2300999997"},"interactions":[{"expect":{"body":null,"headers":{},"method":"GET","path":"/snapshot","query":{}},"respond":{"Http":{"body":[],"headers":{},"status":200}}}],"name":"child-benefit-household-no-match","request":null,"variables":{}}],"integration":"child-benefit-household-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/minimization.json b/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/minimization.json deleted file mode 100644 index fc5378a..0000000 --- a/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/minimization.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"minimization","integration":"child-benefit-household-by-uin","operations":{},"outputs":{"poverty_band":{"from":"snapshot.record.poverty_band","max_bytes":64,"maximum":null,"minimum":null,"nullable":false,"type":"string"}},"schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/negative-security.json b/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/negative-security.json deleted file mode 100644 index 9037e0a..0000000 --- a/runtime/registry-projects/local/sro-social/relay/artifacts/evidence/child-benefit-household-by-uin/negative-security.json +++ /dev/null @@ -1 +0,0 @@ -{"class":"negative_security","fixtures":[],"integration":"child-benefit-household-by-uin","schema":"registry.project.integration-evidence.v1"} diff --git a/runtime/registry-projects/local/sro-social/relay/artifacts/integration-packs/child-benefit-household-by-uin.json b/runtime/registry-projects/local/sro-social/relay/artifacts/integration-packs/child-benefit-household-by-uin.json deleted file mode 100644 index 7133664..0000000 --- a/runtime/registry-projects/local/sro-social/relay/artifacts/integration-packs/child-benefit-household-by-uin.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"solmara-sro-social.child-benefit-household-by-uin","schema":"registry.relay.integration-pack.v1","spec":{"acquisition":{"class":"materialized_snapshot","fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}}},"bounds":{"max_credential_exchanges":0,"max_data_destinations":0,"max_data_exchanges":0,"max_disclosed_records":1,"max_in_flight":8,"max_source_bytes":1048576,"max_source_matches":2,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"deployment_parameters":{},"evidence":{"conformance":["sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce"],"minimization":["sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0"],"negative_security":["sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95"]},"input_slots":{"uin":{"maxLength":10,"pattern":"^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$","role":"selector","type":"string","x-registry-canonicalization":"identity","x-registry-max-bytes":40}},"logical_operation":"child-benefit-household-by-uin","output":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"plan":{"credential_destination_slot":null,"credential_operation":null,"data_destination_slot":null,"kind":"snapshot_exact","rhai":null,"snapshot":{"immutable_generation":true,"max_snapshot_age_ms":2678400000,"unavailable":"unavailable"}},"reviewed_acquisition":{"cardinality":"probe_two","class":"materialized_snapshot","control_fields":{},"fields":{"poverty_band":{"max_bytes":64,"nullable":false,"type":"string"}},"reject_unknown_fields":true,"selector":{"components":{"uin":"snapshot_key"},"type":"snapshot_exact_and"}},"source_provenance":{"source_observed_at":{"type":"absent"},"source_revision":{"type":"absent"}}},"version":"1"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sro-social/relay/artifacts/private-bindings/child-benefit-household.json b/runtime/registry-projects/local/sro-social/relay/artifacts/private-bindings/child-benefit-household.json deleted file mode 100644 index e46d4f8..0000000 --- a/runtime/registry-projects/local/sro-social/relay/artifacts/private-bindings/child-benefit-household.json +++ /dev/null @@ -1 +0,0 @@ -{"capabilities":{"allow_script":false},"credential":null,"credential_destination":null,"data_destination":null,"deployment_parameters":{},"integration_pack":{"hash":"sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e","id":"solmara-sro-social.child-benefit-household-by-uin","version":"1"},"limits":{"max_in_flight":8,"max_public_response_bytes":65536,"max_source_bytes":1048576,"quota_burst":8,"quota_per_minute":60,"timeout_ms":15000},"materialization":{"mapping":{"keys":{"uin":{"comparison":"binary_equality","input":"uin","physical_field":"uin","physical_type":"utf8"}},"projection":{"poverty_band":"poverty_band"}},"table_provider":"sro_child__materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d"},"profile":{"id":"solmara-sro-social.child-benefit.household","version":"1"},"registry_instance":"solmara-sro-social","source_instance":"materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d","tenant":"solmara-sro-social"} \ No newline at end of file diff --git a/runtime/registry-projects/local/sro-social/relay/relay-consultation.yaml b/runtime/registry-projects/local/sro-social/relay/relay-consultation.yaml deleted file mode 100644 index 9c9289e..0000000 --- a/runtime/registry-projects/local/sro-social/relay/relay-consultation.yaml +++ /dev/null @@ -1,118 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -consultation: - artifacts: - evidence: - - class: negative_security - path: artifacts/evidence/child-benefit-household-by-uin/negative-security.json - sha256: sha256:2862a52ca5baefa27db4330b1e8aaf5287c338be2e4733c10695528cd11c0b95 - - class: minimization - path: artifacts/evidence/child-benefit-household-by-uin/minimization.json - sha256: sha256:7992e9674748854ae462ff87d054ab88e00aa5671c04de3b11c4863194fdbdd0 - - class: conformance - path: artifacts/evidence/child-benefit-household-by-uin/conformance.json - sha256: sha256:d85f20f78036bb44a72ae05846565b2d7a705f92bb5836a75285c71ef63da9ce - integration_packs: - - hash: sha256:7f32b8cf5cee550693f4c1a99d5859d251bbb54c6840c56a4c7ec1cf63c6462e - path: artifacts/integration-packs/child-benefit-household-by-uin.json - sha256: sha256:bbf3bfb05e1f6020bdc14457db9549ad084ad421b5c59715bdb51d5ea67dc4d3 - private_bindings: - - hash: sha256:4dd783c0c8bcf6b6fee3aefd511c719dab911053adf3f39874cc99deded3bcfb - path: artifacts/private-bindings/child-benefit-household.json - sha256: sha256:9515fa0b01838a1f07f25b2b86b75843e8ef888438b82989f5ab7d657e2865ba - public_contracts: - - hash: sha256:7c6800ab1eea861c2e2500ba3e93bb9ddcb6d1212d7701756098155faaee4697 - path: artifacts/consultation-contracts/child-benefit-household.json - sha256: sha256:77470bdaa56f3bf30beb1734e0aa0fd676068b09f45d576ce32cae4a51920efb - rhai_scripts: [] - audit_pseudonym_materials: - - key_id: epoch-1 - source: - name: REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1 - provider: environment - authorized_workload: - audience: registry-relay - client_claim_selector: azp - client_value: sro-notary - principal_id: sro-notary - source_credentials: [] - state_plane: - audit_pseudonym_keyring_lock_key: 368637434756268187 - chain_key_epoch_id: project-consultation-chain-1 - database_url_env: REGISTRY_RELAY_CONSULTATION_DATABASE_URL - root_certificate_path: /etc/solmara/postgres/root.crt - serving_fence_lock_key: 2694379094216255332 -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: sro-social-relay-consultation -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/runtime/registry-projects/local/sro-social/relay/relay.yaml b/runtime/registry-projects/local/sro-social/relay/relay.yaml deleted file mode 100644 index 989731e..0000000 --- a/runtime/registry-projects/local/sro-social/relay/relay.yaml +++ /dev/null @@ -1,76 +0,0 @@ -audit: - hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET - sink: stdout -auth: - mode: oidc - oidc: - allow_dev_insecure_fetch_urls: true - allowed_clients: - - sro-notary - audiences: - - registry-relay - issuer: http://127.0.0.1:8090 - jwks_url: http://127.0.0.1:8090/.well-known/jwks.json -catalog: - base_url: http://127.0.0.1:8080 - publisher: solmara-sro-social - title: solmara-sro-social governed Registry Relay -datasets: -- access_rights: restricted - aggregates: [] - conforms_to: [] - defaults: - materialization: snapshot - refresh: - interval: 1m - mode: interval - description: Materialized sro_child entity - entities: [] - id: sro_child - owner: solmara-sro-social - sensitivity: personal - tables: - - access: - aggregate_scope: sro_child:aggregate - metadata_scope: registry-internal:materialization - aggregates: [] - api: - allowed_filters: [] - default_limit: 1 - max_limit: 1 - require_purpose_header: false - id: materialization_a2fcdd986475cdde3629a2c14184c4d678c8f6a99ae777f97831bc4ffbcb540d - materialization: snapshot - primary_key: uin - refresh: - interval: 1m - mode: interval - schema: - fields: - - name: poverty_band - nullable: false - sensitive: true - type: string - - name: uin - nullable: false - sensitive: true - type: string - strict: true - source: - format: - csv: - delimiter: null - header_row: 1 - quote: null - path: /var/lib/solmara/ministries/social-development/fixtures/child_benefit_household.csv - type: file - title: sro_child - update_frequency: as_needed -deployment: - profile: local -instance: - environment: local - id: sro-social-relay -server: - bind: 127.0.0.1:8080 -standards: {} diff --git a/scenario-runner/Dockerfile b/scenario-runner/Dockerfile index 411d024..9f72ba0 100644 --- a/scenario-runner/Dockerfile +++ b/scenario-runner/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine +FROM python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df WORKDIR /app ENV PYTHONUNBUFFERED=1 @@ -9,7 +9,7 @@ ENV SCENARIO_RUNNER_PORT=8080 # Runtime dependencies (currently just cryptography, for holder proof signing) # are declared in the repo-root uv.lock. Export and install the exact locked # versions rather than letting pip resolve them independently. -COPY --from=ghcr.io/astral-sh/uv:0.11.16 /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv@sha256:440fd6477af86a2f1b38080c539f1672cd22acb1b1a47e321dba5158ab08864d /uv /usr/local/bin/uv COPY pyproject.toml uv.lock ./ RUN uv export --frozen --no-dev --no-hashes -o requirements.txt \ && pip install --no-cache-dir -r requirements.txt \ @@ -17,7 +17,6 @@ RUN uv export --frozen --no-dev --no-hashes -o requirements.txt \ COPY scenarios ./scenarios COPY scenario-runner ./scenario-runner -COPY scripts/relay_workload_identity_agent.py ./scripts/relay_workload_identity_agent.py EXPOSE 8080 CMD ["python", "scenario-runner/server.py"] diff --git a/scenario-runner/child_benefit_federator.py b/scenario-runner/child_benefit_federator.py index 4535e85..c1151ea 100644 --- a/scenario-runner/child_benefit_federator.py +++ b/scenario-runner/child_benefit_federator.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -"""Child-benefit evidence collector for the Solmara lab. +"""Application-level child-benefit Evidence collector. -This is an application service, not a Notary and not an eligibility engine. -It asks the four authority-owned Notaries for their minimized predicates over -the ordinary Registry Notary HTTP API, then returns a source-attributed -evidence set. The programme policy layer remains responsible for eligibility. +The collector owns no eligibility rule. It asks one Evidence deployment for +four separately governed requirements and returns their signed concept values +with source attribution. Registry rows remain behind each authority's Relay. """ from __future__ import annotations @@ -12,564 +11,156 @@ import hmac import json import os -import secrets -import time -from datetime import date from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any -from urllib.parse import unquote, urlparse +from urllib.parse import urlparse -from scenarios.common import ( - CLAIM_RESULT_FORMAT, - PURPOSES, - StepHttpResult, - auth_headers, - evaluation_body, - http_json, -) -from scenarios.service_config import ( - authority_service_id, - service_token, - service_token_env, - service_url, -) +from scenarios.common import PURPOSES, evidence_body, evidence_headers, http_json, normalized_evidence_result +from scenarios.service_config import authority_service_id, requirement_config, requirement_id, service_token, service_url -API_VERSION = "solmara-child-benefit-evidence/v1" +API_VERSION = "solmara-child-benefit-evidence/v2" FEDERATOR_SERVICE_ID = "child-benefit-federator" FEDERATOR_TOKEN_ENV = "CHILD_BENEFIT_FEDERATOR_TOKEN" CHILD_PURPOSE = PURPOSES["child_benefit"] -SUPPORTED_DISCLOSURES = {"predicate"} -SENSITIVE_RAW_CLAIMS = {"household-poverty-score", "household-profile"} MAX_REQUEST_BODY_BYTES = 64 * 1024 -ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - - SOURCE_ROUTES: tuple[dict[str, Any], ...] = ( - { - "client_id": "cra-child-benefit", - "authority": "Civil Registration Authority", - "claims": ("birth-is-registered", "child-age-under-5"), - }, - { - "client_id": "nia-child-benefit", - "authority": "National Identity Agency", - "claims": ("population-record-active",), - }, - { - "client_id": "sro-child-benefit", - "authority": "Social Registry Office", - "claims": ("household-below-poverty-threshold",), - }, - { - "client_id": "programme-child-benefit", - "authority": "MoSD Programme MIS", - "claims": ("not-already-enrolled",), - }, + {"client_id": "cra-child-benefit", "claims": ("birth-is-registered", "child-age-under-5")}, + {"client_id": "nia-child-benefit", "claims": ("population-record-active",)}, + {"client_id": "sro-child-benefit", "claims": ("household-below-poverty-threshold",)}, + {"client_id": "programme-child-benefit", "claims": ("not-already-enrolled",)}, ) -CLAIM_ROUTES = { - claim_id: route for route in SOURCE_ROUTES for claim_id in route["claims"] -} - - -class RequestBodyError(Exception): - def __init__(self, status: HTTPStatus, detail: str) -> None: - super().__init__(detail) - self.status = status - self.detail = detail - - -class AuthorityUpstreamError(Exception): - def __init__(self, route: dict[str, Any], status: int | None, code: str) -> None: - super().__init__(code) - self.route = route - self.status = status - self.code = code +CLAIM_ROUTES = {claim: route for route in SOURCE_ROUTES for claim in route["claims"]} class ChildBenefitFederatorHandler(BaseHTTPRequestHandler): - server_version = "SolmaraChildBenefitEvidence/1.0" + server_version = "SolmaraChildBenefitEvidence/2.0" def do_GET(self) -> None: - parts = path_parts(self.path) - if parts in (["health"], ["healthz"]): + path = urlparse(self.path).path + if path in {"/health", "/healthz"}: self.write_json({"status": "ok", "service": FEDERATOR_SERVICE_ID}) return - if parts == ["v1", "claims"]: + if path == "/v1/claims": if not self.require_token(): return - purpose = self.headers.get("Data-Purpose", "") - if purpose and purpose != CHILD_PURPOSE: - self.write_problem( - HTTPStatus.FORBIDDEN, - "pdp.purpose_not_permitted", - "Purpose not permitted", - "This service only collects evidence for child-benefit review.", - ) - return - self.write_json(claim_catalog()) + self.write_json({"schema_version": API_VERSION, "claims": [{"claim_id": claim, "authority": requirement_config(route["client_id"])["name"]} for claim, route in CLAIM_ROUTES.items()]}) return - self.write_problem( - HTTPStatus.NOT_FOUND, "not_found", "Not found", "No such application route." - ) + self.write_problem(HTTPStatus.NOT_FOUND, "not_found", "No such application route.") def do_POST(self) -> None: - if path_parts(self.path) != ["v1", "evaluations"]: - self.write_problem( - HTTPStatus.NOT_FOUND, - "not_found", - "Not found", - "No such application route.", - ) + if urlparse(self.path).path != "/v1/evaluations": + self.write_problem(HTTPStatus.NOT_FOUND, "not_found", "No such application route.") return if not self.require_token(): return - purpose = self.headers.get("Data-Purpose", "") - if purpose != CHILD_PURPOSE: - self.write_problem( - HTTPStatus.FORBIDDEN, - "pdp.purpose_not_permitted", - "Purpose not permitted", - "This service only collects evidence for child-benefit review.", - ) - return - - try: - body = self.read_body() - except RequestBodyError as error: - self.write_problem( - error.status, - "request.invalid", - "Invalid evidence request", - error.detail, - ) - return - - if body.get("disclosure", "predicate") not in SUPPORTED_DISCLOSURES: - self.write_problem( - HTTPStatus.FORBIDDEN, - "pdp.purpose_not_permitted", - "Disclosure not permitted", - "This purpose permits minimized predicates only.", - ) - return - requested = requested_claims(body) - if any(claim in SENSITIVE_RAW_CLAIMS for claim in requested): - self.write_problem( - HTTPStatus.FORBIDDEN, - "pdp.purpose_not_permitted", - "Disclosure not permitted", - "Raw source fields are not available through this purpose.", - ) - return - if body.get("format", "application/json") != "application/json": - self.write_problem( - HTTPStatus.BAD_REQUEST, - "request.unsupported_format", - "Unsupported response format", - "Omit format or set it to application/json.", - ) + body = self.read_body() + if body is None: return - if not accepts_media_type(self.headers.get("Accept", ""), "application/json"): - self.write_problem( - HTTPStatus.NOT_ACCEPTABLE, - "request.not_acceptable", - "Unsupported response representation", - "Set Accept to application/json.", - ) + purpose = body.get("purpose") + if purpose != CHILD_PURPOSE: + self.write_problem(HTTPStatus.FORBIDDEN, "purpose_not_permitted", "Only child-benefit-review is permitted.") return - subject = subject_id(body) - variables = request_variables(body, requested) - unknown = [claim for claim in requested if claim not in CLAIM_ROUTES] - if ( - not subject - or not requested - or unknown - or len(requested) != len(set(requested)) - or variables is None - ): - self.write_problem( - HTTPStatus.BAD_REQUEST, - "request.invalid", - "Invalid evidence request", - "The request must name unique supported child-benefit predicates and a solmara_uin target.", - ) + claims = requested_claims(body) + if not subject or not claims or len(claims) != len(set(claims)) or any(claim not in CLAIM_ROUTES for claim in claims): + self.write_problem(HTTPStatus.BAD_REQUEST, "invalid_request", "Name one UIN and unique supported claims.") return - - try: - evidence = collect_evidence( - subject, - requested, - purpose, - body.get("target"), - variables, - ) - except AuthorityUpstreamError as error: - self.write_problem( - HTTPStatus.BAD_GATEWAY, - "authority.upstream_failed", - "Authority evidence unavailable", - f"{error.route['authority']} did not return the requested minimized evidence.", - ) + token = service_token("cra-child-benefit") + if not token: + self.write_problem(HTTPStatus.SERVICE_UNAVAILABLE, "mint_unavailable", "No Evidence access token is available.") return - self.write_json(evidence) + results: list[dict[str, Any]] = [] + trace: list[dict[str, Any]] = [] + signed_evidence: list[Any] = [] + for route in SOURCE_ROUTES: + requested = [claim for claim in claims if claim in route["claims"]] + if not requested: + continue + url = service_url(route["client_id"]) + headers = evidence_headers(token) + request = evidence_body(subject, requirement_id(route["client_id"]), purpose) + response = normalized_evidence_result( + http_json("POST", url, headers, request), + request=request, + service_id=route["client_id"], + ) + if response.status is None or not 200 <= response.status < 300: + self.write_problem(HTTPStatus.BAD_GATEWAY, "evidence_unavailable", "A required Evidence assertion was unavailable.") + return + returned = {item["claim_id"]: item for item in response.body.get("results", [])} + if any(claim not in returned for claim in requested): + self.write_problem(HTTPStatus.BAD_GATEWAY, "evidence_unavailable", "A required Evidence assertion was unavailable.") + return + presentation = response.body["presentation"] + results.extend({**returned[claim], "presentation": presentation} for claim in requested) + signed_evidence.append(response.body.get("signed_evidence")) + trace.append({"authority": presentation["authority"], "service_id": authority_service_id(route["client_id"]), "issuer": presentation["issuer"], "provider": presentation["provider"], "source": presentation["source"], "status": response.status}) + self.write_json({"schema_version": API_VERSION, "orchestration": {"service_id": FEDERATOR_SERVICE_ID, "decision": "not_composed"}, "purpose": purpose, "target": {"type": "Person", "binding": "withheld"}, "results": results, "signed_evidence": signed_evidence, "source_trace": trace}) def require_token(self) -> bool: expected = os.environ.get(FEDERATOR_TOKEN_ENV, "") received = self.headers.get("x-api-key", "") if expected and hmac.compare_digest(received, expected): return True - self.write_problem( - HTTPStatus.UNAUTHORIZED, - "auth.missing_or_invalid", - "Authentication required", - f"Set the {FEDERATOR_TOKEN_ENV} synthetic lab token.", - ) + self.write_problem(HTTPStatus.UNAUTHORIZED, "authentication_required", "A valid local application token is required.") return False - def read_body(self) -> dict[str, Any]: - raw_length = self.headers.get("Content-Length") + def read_body(self) -> dict[str, Any] | None: try: - length = int(raw_length) if raw_length is not None else 0 - except ValueError as error: - raise RequestBodyError( - HTTPStatus.BAD_REQUEST, "Content-Length must be a decimal byte count." - ) from error - if length <= 0: - raise RequestBodyError( - HTTPStatus.BAD_REQUEST, "A non-empty JSON request body is required." - ) - if length > MAX_REQUEST_BODY_BYTES: - raise RequestBodyError( - HTTPStatus.REQUEST_ENTITY_TOO_LARGE, - f"The JSON request body must not exceed {MAX_REQUEST_BODY_BYTES} bytes.", - ) - raw = self.rfile.read(length) - if len(raw) != length: - raise RequestBodyError( - HTTPStatus.BAD_REQUEST, - "The JSON request body ended before Content-Length bytes arrived.", - ) + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + length = 0 + if length <= 0 or length > MAX_REQUEST_BODY_BYTES: + self.write_problem(HTTPStatus.BAD_REQUEST, "invalid_request", "A bounded JSON object is required.") + return None try: - parsed = json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise RequestBodyError( - HTTPStatus.BAD_REQUEST, "The request body must be a valid JSON object." - ) from error - if not isinstance(parsed, dict): - raise RequestBodyError( - HTTPStatus.BAD_REQUEST, "The request body must be a JSON object." - ) - return parsed - - def write_json( - self, - payload: dict[str, Any], - status: HTTPStatus = HTTPStatus.OK, - *, - content_type: str = "application/json", - ) -> None: - body = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + value = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, UnicodeDecodeError): + value = None + if not isinstance(value, dict): + self.write_problem(HTTPStatus.BAD_REQUEST, "invalid_request", "A JSON object is required.") + return None + return value + + def write_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(payload, separators=(",", ":")).encode() self.send_response(status) - self.send_header("Content-Type", f"{content_type}; charset=utf-8") + self.send_header("Content-Type", "application/json") self.send_header("Cache-Control", "no-store") - self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) - def write_problem( - self, status: HTTPStatus, code: str, title: str, detail: str - ) -> None: - self.write_json( - { - "type": f"https://id.registrystack.org/problems/solmara/{code.replace('.', '/')}", - "title": title, - "status": int(status), - "code": code, - "detail": detail, - }, - status, - content_type="application/problem+json", - ) + def write_problem(self, status: HTTPStatus, code: str, detail: str) -> None: + self.write_json({"type": f"https://id.registrystack.org/problems/solmara/{code}", "title": code.replace("_", " ").title(), "status": int(status), "code": code, "detail": detail}, status) def log_message(self, format: str, *args: Any) -> None: - if os.environ.get("CHILD_BENEFIT_FEDERATOR_ACCESS_LOG", "").lower() in { - "1", - "true", - "yes", - }: + if os.environ.get("CHILD_BENEFIT_FEDERATOR_ACCESS_LOG") == "1": super().log_message(format, *args) -def collect_evidence( - subject: str, - claims: list[str], - purpose: str, - target: Any, - variables: dict[str, str] | None = None, -) -> dict[str, Any]: - by_claim: dict[str, dict[str, Any]] = {} - source_trace: list[dict[str, Any]] = [] - for route in SOURCE_ROUTES: - route_claims = [claim for claim in claims if claim in route["claims"]] - if not route_claims: - continue - source = call_authority_notary( - route, - subject, - route_claims, - purpose, - variables or {}, - ) - by_claim.update({result["claim_id"]: result for result in source["results"]}) - source_trace.append(source["trace"]) - return { - "schema_version": API_VERSION, - "evidence_set_id": f"cbe_{ulid()}", - "orchestration": { - "service_id": FEDERATOR_SERVICE_ID, - "decision": "not_composed", - }, - "purpose": purpose, - "target": public_target(target), - "results": [by_claim[claim] for claim in claims], - "source_trace": source_trace, - } - - -def call_authority_notary( - route: dict[str, Any], - subject: str, - claims: list[str], - purpose: str, - variables: dict[str, str], -) -> dict[str, Any]: - client_id = route["client_id"] - token = service_token(client_id) - if not token: - raise AuthorityUpstreamError( - route, None, f"missing_{service_token_env(client_id).lower()}" - ) - url = service_url(client_id, "/v1/evaluations") - request_variables = None - if "child-age-under-5" in claims: - as_of_date = variables.get("as_of_date") - if not as_of_date: - raise AuthorityUpstreamError(route, None, "missing_as_of_date") - request_variables = {"as_of_date": as_of_date} - body = evaluation_body( - subject, - claims, - scheme="solmara_uin", - format=CLAIM_RESULT_FORMAT, - variables=request_variables, - ) - headers = auth_headers(token, purpose, CLAIM_RESULT_FORMAT) - response = http_json("POST", url, headers, body) - if response.status is None or not 200 <= response.status < 300: - raise AuthorityUpstreamError( - route, response.status, upstream_error_code(response) - ) - results = minimized_results(route, claims, response) - return { - "results": results, - "trace": { - "authority": route["authority"], - "service_id": authority_service_id(client_id), - "claims": claims, - "request_summary": { - "method": "POST", - "url": url, - "purpose": purpose, - "disclosure": "predicate", - "claims": claims, - }, - "response_summary": { - "status": response.status, - "headers": allowlisted_headers(response), - "results": results, - }, - }, - } - - -def minimized_results( - route: dict[str, Any], - requested: list[str], - response: StepHttpResult, -) -> list[dict[str, Any]]: - body = response.body if isinstance(response.body, dict) else {} - raw_results = body.get("results") - if not isinstance(raw_results, list): - raise AuthorityUpstreamError(route, response.status, "invalid_response_payload") - result_by_claim: dict[str, dict[str, Any]] = {} - for raw_result in raw_results: - if not isinstance(raw_result, dict): - raise AuthorityUpstreamError(route, response.status, "invalid_claim_result") - claim_id = raw_result.get("claim_id") - if not isinstance(claim_id, str) or claim_id in result_by_claim: - raise AuthorityUpstreamError(route, response.status, "invalid_claim_result") - result_by_claim[claim_id] = raw_result - if set(result_by_claim) != set(requested): - raise AuthorityUpstreamError(route, response.status, "unexpected_claim_results") - - minimized: list[dict[str, Any]] = [] - for claim_id in requested: - raw_result = result_by_claim[claim_id] - satisfied = raw_result.get("satisfied") - if ( - not isinstance(satisfied, bool) - or raw_result.get("disclosure") != "predicate" - ): - raise AuthorityUpstreamError(route, response.status, "invalid_claim_result") - minimized.append( - { - "claim_id": claim_id, - "claim_version": raw_result.get("claim_version"), - "satisfied": satisfied, - "disclosure": "predicate", - "format": CLAIM_RESULT_FORMAT, - "issued_at": raw_result.get("issued_at"), - "expires_at": raw_result.get("expires_at"), - "authority": route["authority"], - "notary_service_id": authority_service_id(route["client_id"]), - } - ) - return minimized - - -def upstream_error_code(response: StepHttpResult) -> str: - body = response.body if isinstance(response.body, dict) else {} - code = body.get("code") or body.get("error") or response.error - return str(code) if code else f"http_{response.status}" - - -def allowlisted_headers(response: StepHttpResult) -> dict[str, str]: - return { - key: value - for key, value in response.headers.items() - if key.lower() in {"content-type", "www-authenticate"} - } - - -def accepts_media_type(value: str, expected: str) -> bool: - for item in value.split(","): - media_type = item.split(";", 1)[0].strip().lower() - if media_type in {expected.lower(), "application/*", "*/*"}: - return True - return False - - def subject_id(body: dict[str, Any]) -> str: - target = body.get("target") if isinstance(body.get("target"), dict) else {} - identifiers = target.get("identifiers") if isinstance(target, dict) else [] + target = body.get("target") + identifiers = target.get("identifiers") if isinstance(target, dict) else None if not isinstance(identifiers, list): return "" for identifier in identifiers: - if not isinstance(identifier, dict): - continue - if identifier.get("scheme") == "solmara_uin" and isinstance( - identifier.get("value"), str - ): + if isinstance(identifier, dict) and identifier.get("scheme") == "solmara_uin" and isinstance(identifier.get("value"), str): return identifier["value"] return "" def requested_claims(body: dict[str, Any]) -> list[str]: - raw = body.get("claims") - if not isinstance(raw, list): - return [] - return [claim for claim in raw if isinstance(claim, str)] - - -def request_variables( - body: dict[str, Any], requested: list[str] -) -> dict[str, str] | None: - raw = body.get("variables", {}) - if not isinstance(raw, dict) or set(raw) - {"as_of_date"}: - return None - if "child-age-under-5" not in requested: - return {} - value = raw.get("as_of_date") - if not isinstance(value, str) or len(value) != 10: - return None - try: - parsed = date.fromisoformat(value) - except ValueError: - return None - if parsed.isoformat() != value: - return None - return {"as_of_date": value} - - -def public_target(target: Any) -> dict[str, Any]: - """Describe the target without echoing its raw identifier value.""" - if not isinstance(target, dict): - return {} - identifiers = target.get("identifiers") - schemes = [] - if isinstance(identifiers, list): - schemes = sorted( - { - identifier["scheme"] - for identifier in identifiers - if isinstance(identifier, dict) - and isinstance(identifier.get("scheme"), str) - } - ) - public: dict[str, Any] = {"identifier_schemes": schemes} - if isinstance(target.get("type"), str): - public["type"] = target["type"] - return public - - -def claim_catalog() -> dict[str, Any]: - claims = [ - { - "id": claim_id, - "version": "1", - "authority": route["authority"], - "notary_service_id": authority_service_id(route["client_id"]), - "disclosure": "predicate", - } - for claim_id, route in CLAIM_ROUTES.items() - ] - return { - "schema_version": API_VERSION, - "service_id": FEDERATOR_SERVICE_ID, - "response_media_type": "application/json", - "claims": claims, - "data": claims, - "composition": { - "eligible-for-child-benefit": "not_returned_by_orchestrator", - "owner": "child-benefit-programme-policy", - }, - } - - -def path_parts(path: str) -> list[str]: - parsed = urlparse(path) - return [unquote(part) for part in parsed.path.split("/") if part] - - -def ulid() -> str: - value = (int(time.time() * 1000) << 80) | secrets.randbits(80) - chars = [] - for _ in range(26): - chars.append(ULID_ALPHABET[value & 0x1F]) - value >>= 5 - return "".join(reversed(chars)) + claims = body.get("claims") + return [claim for claim in claims if isinstance(claim, str)] if isinstance(claims, list) else [] def main() -> int: - host = os.environ.get("CHILD_BENEFIT_FEDERATOR_HOST", "0.0.0.0") + host = os.environ.get("CHILD_BENEFIT_FEDERATOR_HOST", "127.0.0.1") port = int(os.environ.get("CHILD_BENEFIT_FEDERATOR_PORT", "8080")) - server = ThreadingHTTPServer((host, port), ChildBenefitFederatorHandler) - print(f"child-benefit-federator listening on http://{host}:{port}", flush=True) - server.serve_forever() + ThreadingHTTPServer((host, port), ChildBenefitFederatorHandler).serve_forever() return 0 diff --git a/scenario-runner/test_server.py b/scenario-runner/test_server.py index 46f1cbb..8fd649f 100644 --- a/scenario-runner/test_server.py +++ b/scenario-runner/test_server.py @@ -1,57 +1,114 @@ #!/usr/bin/env python3 -"""Contract tests for the scenario-runner API.""" +"""Contract tests for the Evidence-backed scenario runner and collector.""" from __future__ import annotations import base64 -import hashlib -import io import json import os -import subprocess import sys +import tempfile import threading import time import unittest import urllib.error +import urllib.parse import urllib.request +import uuid from http.server import ThreadingHTTPServer from pathlib import Path from typing import Any +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scenario-runner")) -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # noqa: E402 import child_benefit_federator # noqa: E402 from server import ScenarioRunnerHandler # noqa: E402 from scenarios import child_benefit, citizen, common, pension_survivor # noqa: E402 from scenarios.common import StepHttpResult # noqa: E402 +from scenarios import service_config # noqa: E402 def b64url_decode(segment: str) -> bytes: - padded = segment + "=" * (-len(segment) % 4) - return base64.urlsafe_b64decode(padded) + return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + + +AUTHORITY_KEYS = {authority: ec.generate_private_key(ec.SECP256R1()) for authority in service_config.AUTHORITY_DIRECTORY} + + +def public_jwk(private_key) -> dict[str, str]: + numbers = private_key.public_key().public_numbers() + jwk = { + "kty": "EC", + "alg": "ES256", + "crv": "P-256", + "x": common.b64url_nopad(numbers.x.to_bytes(32, "big")), + "y": common.b64url_nopad(numbers.y.to_bytes(32, "big")), + } + jwk["kid"] = common._jwk_thumbprint(jwk) + return jwk + + +def install_authority_keys() -> None: + common._JWKS_CACHE.clear() + for authority, private_key in AUTHORITY_KEYS.items(): + service_id = next(key for key, value in service_config.REQUIREMENT_DIRECTORY.items() if value["authority"] == authority) + url = service_config.service_url(service_id, "/.well-known/evidence/jwks.json") + common._JWKS_CACHE[url] = (time.monotonic() + 300, (public_jwk(private_key),)) + + +def signed_evidence(service_id: str, request: dict[str, Any], values: list[tuple[str, Any]], *, private_key=None, claims: dict[str, Any] | None = None) -> dict[str, str]: + config = service_config.requirement_config(service_id) + private_key = private_key or AUTHORITY_KEYS[config["authority"]] + jwk = public_jwk(private_key) + now = datetime_now = time.time() + payload = { + "schema": "registry.assertion-evidence/v1", + "assuranceProfile": "production", + "subjectBinding": "audience-scoped", + "requestNonce": request["requestNonce"], + "id": f"urn:uuid:{uuid.uuid4()}", + "type": "Evidence", + "supportsRequirement": request["requirement"], + "isConformantTo": config["evidence_type"], + "issuedBy": config["issuer"], + "providedBy": config["provider"], + "issuedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(datetime_now)), + "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(datetime_now)), + "validUntil": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + 300)), + "purpose": request["purpose"], + "audience": common.EVIDENCE_AUDIENCE, + "configurationRevision": f"sha256:{'a' * 64}", + "subjects": [{"role": "subject", "binding": f"urn:evidence:subject:v1_{'A' * 43}"}], + "supportedValues": [ + {"providesValueFor": f"https://id.registrystack.org/solmara/concept/{name}", "value": value} + for name, value in values + ], + } + payload.update(claims or {}) + protected = common.b64url_nopad(json.dumps({**common.EVIDENCE_JWS_HEADER, "kid": jwk["kid"]}, separators=(",", ":")).encode()) + payload_segment = common.b64url_nopad(json.dumps(payload, separators=(",", ":")).encode()) + der_signature = private_key.sign(f"{protected}.{payload_segment}".encode(), ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(der_signature) + return { + "protected": protected, + "payload": payload_segment, + "signature": common.b64url_nopad(r.to_bytes(32, "big") + s.to_bytes(32, "big")), + } class ScenarioRunnerServerTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: os.environ["SCENARIO_RUNNER_HOST"] = "127.0.0.1" - for name in ( - "CHILD_BENEFIT_FEDERATOR_TOKEN", - "CRA_CHILD_BENEFIT_CLIENT_TOKEN", - "NIA_CHILD_BENEFIT_CLIENT_TOKEN", - "SRO_CHILD_BENEFIT_CLIENT_TOKEN", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - "CRA_PENSION_CLIENT_TOKEN", - "SIPF_PENSION_CLIENT_TOKEN", - "NAGDI_NOTARY_TOKEN", - "CRA_CITIZEN_CLIENT_TOKEN", - "NIA_CITIZEN_CLIENT_TOKEN", - ): - os.environ.pop(name, None) + os.environ.pop("SOLMARA_EVIDENCE_ACCESS_TOKEN", None) + os.environ.pop("SOLMARA_EVIDENCE_CLIENT_KEY", None) + os.environ.pop("CHILD_BENEFIT_FEDERATOR_TOKEN", None) cls.server = ThreadingHTTPServer(("127.0.0.1", 0), ScenarioRunnerHandler) cls.port = cls.server.server_address[1] cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) @@ -63,374 +120,52 @@ def tearDownClass(cls) -> None: cls.thread.join(timeout=5) cls.server.server_close() - def test_lists_scenarios_with_default(self) -> None: + def test_lists_all_local_evidence_scenarios(self) -> None: payload = self.fetch_json("/v1/scenarios") self.assertEqual(payload["default_scenario_id"], "birth-to-child-benefit") - self.assertGreaterEqual(len(payload["scenarios"]), 3) + self.assertEqual(len(payload["scenarios"]), 4) + self.assertTrue(all(item["availability"] == "local" for item in payload["scenarios"])) self.assertTrue(all(item["runnable"] for item in payload["scenarios"])) - def test_detail_contains_request_previews(self) -> None: - payload = self.fetch_json("/v1/scenarios/birth-to-child-benefit") - steps = payload["story"]["steps"] - self.assertTrue(steps) - self.assertIn("request_preview", steps[0]) - self.assertIn("Data-Purpose", steps[0]["request_preview"]["headers"]) - - def test_run_step_is_idempotent_when_runtime_token_missing(self) -> None: - first = self.post_json( - "/v1/scenarios/birth-to-child-benefit/steps/positive/run", {} - ) - second = self.post_json( - "/v1/scenarios/birth-to-child-benefit/steps/positive/run", {} - ) + def test_detail_previews_evidence_requirements(self) -> None: + payload = self.fetch_json("/v1/scenarios/citizen-self-service") + positive = next(step for step in payload["story"]["steps"] if step["id"] == "positive") + preview = positive["request_preview"] + self.assertEqual(preview["method"], "MULTI") + self.assertEqual(len(preview["requests"]), 2) + self.assertTrue(all(item["url"].endswith("/v1/evidence") for item in preview["requests"])) + self.assertTrue(all("body" not in item for item in preview["requests"])) + self.assertNotIn("2300018263", json.dumps(preview)) + + def test_missing_mint_credentials_is_stable_and_safe(self) -> None: + first = self.post_json("/v1/scenarios/citizen-self-service/steps/positive/run", {}) + second = self.post_json("/v1/scenarios/citizen-self-service/steps/positive/run", {}) self.assertEqual(first["result"]["friendly"]["status"], "needs_attention") - self.assertEqual( - first["result"]["response_source"], second["result"]["response_source"] - ) - - def test_child_benefit_purpose_override_reaches_request_source(self) -> None: - purpose = "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" - payload = self.post_json( - "/v1/scenarios/birth-to-child-benefit/steps/positive/run", - {"config": {"purpose_override": purpose}}, - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], purpose - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Content-Type"], - "application/json", - ) - - def test_child_benefit_denial_step_ignores_override(self) -> None: - payload = self.post_json( - "/v1/scenarios/birth-to-child-benefit/steps/purpose-denial/run", - { - "config": { - "purpose_override": "https://id.registrystack.org/solmara/purpose/child-benefit-review" - } - }, - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], - "https://id.registrystack.org/solmara/purpose/unsupported-demo-purpose", - ) - - def test_child_benefit_positive_calls_application_without_credential_composition( - self, - ) -> None: - calls: list[str] = [] - original_http_json = child_benefit.http_json - os.environ["CHILD_BENEFIT_FEDERATOR_TOKEN"] = "runtime-token" - - def fake_http_json( - method: str, - url: str, - headers: dict, - body: dict | None = None, - timeout: float = 8.0, - ) -> StepHttpResult: - calls.append(url) - return StepHttpResult( - 200, - { - "orchestration": { - "service_id": "child-benefit-federator", - "decision": "not_composed", - }, - "results": [ - {"claim_id": "birth-is-registered", "satisfied": True}, - {"claim_id": "population-record-active", "satisfied": True}, - ], - "source_trace": [ - { - "authority": "Civil Registration Authority", - "service_id": "cra-notary", - } - ], - }, - {"content-type": "application/json"}, - ) - - try: - child_benefit.http_json = fake_http_json - result = child_benefit.run_step({}, "positive") - finally: - child_benefit.http_json = original_http_json - os.environ.pop("CHILD_BENEFIT_FEDERATOR_TOKEN", None) + self.assertEqual(first["result"]["response_source"], second["result"]["response_source"]) + serialized = json.dumps(first) + self.assertIn("Bearer [runtime token hidden]", serialized) + self.assertNotIn("Bearer runtime-token", serialized) - self.assertTrue(all(call.endswith("/v1/evaluations") for call in calls)) - self.assertNotIn("credential", result) - self.assertEqual( - result["request_source"]["body"]["format"], - "application/json", - ) - self.assertEqual( - result["request_source"]["body"]["variables"], - {"as_of_date": common.CHILD_BENEFIT_AS_OF_DATE}, - ) - self.assertIn( - "population-record-active", result["request_source"]["body"]["claims"] - ) - self.assertEqual( - result["source_trace"][0]["authority"], "Civil Registration Authority" - ) - - def test_pension_survivor_purpose_override_reaches_request_source(self) -> None: - purpose = ( - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" - ) - payload = self.post_json( - "/v1/scenarios/death-to-pension-survivor/steps/stop-payment/run", - {"config": {"purpose_override": purpose}}, - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], purpose - ) - - def test_pension_stop_calls_cra_and_sipf_and_derives_the_application_decision( - self, - ) -> None: - calls: list[tuple[str, dict[str, Any]]] = [] - original_http_json = pension_survivor.http_json - for name in ("CRA_PENSION_CLIENT_TOKEN", "SIPF_PENSION_CLIENT_TOKEN"): - os.environ[name] = f"token-{name.lower()}" - - def fake_http_json( - method: str, - url: str, - headers: dict, - body: dict | None = None, - timeout: float = 8.0, - ) -> StepHttpResult: - assert body is not None - calls.append((url, body)) - claim_id = body["claims"][0] - return StepHttpResult( - 200, - { - "results": [ - { - "evaluation_id": f"eval-{claim_id}", - "claim_id": claim_id, - "satisfied": True, - "disclosure": "predicate", - } - ] - }, - {"content-type": common.CLAIM_RESULT_FORMAT}, - ) - - try: - pension_survivor.http_json = fake_http_json - result = pension_survivor.run_step({}, "stop-payment") - finally: - pension_survivor.http_json = original_http_json - for name in ("CRA_PENSION_CLIENT_TOKEN", "SIPF_PENSION_CLIENT_TOKEN"): - os.environ.pop(name, None) - - self.assertEqual( - [body["claims"] for _, body in calls], - [["person-is-deceased"], ["pension-payment-active"]], - ) - self.assertTrue(calls[0][0].endswith("/v1/evaluations")) - self.assertTrue(calls[1][0].endswith("/v1/evaluations")) - self.assertEqual( - [trace["service_id"] for trace in result["source_trace"]], - ["cra-notary", "sipf-notary"], - ) - self.assertIs(result["derived_decisions"]["pension-payment-should-stop"], True) - self.assertNotIn( - "pension-payment-should-stop", [body["claims"][0] for _, body in calls] - ) - - def test_survivor_credential_is_issued_by_sipf(self) -> None: - original_http_json = pension_survivor.http_json - original_credential_attempt = pension_survivor.credential_attempt - os.environ["SIPF_PENSION_CLIENT_TOKEN"] = "sipf-token" - captured: dict[str, Any] = {} - - def fake_http_json( - method: str, - url: str, - headers: dict, - body: dict | None = None, - timeout: float = 8.0, - ) -> StepHttpResult: - return StepHttpResult( - 200, - { - "results": [ - { - "evaluation_id": "eval-survivor", - "claim_id": "survivor-is-eligible", - "satisfied": True, - "disclosure": "predicate", - } - ] - }, - {"content-type": common.CLAIM_RESULT_FORMAT}, - ) - - def fake_credential_attempt( - url, token, purpose, evaluation_result, profile, claim_ids, service_id - ): - captured.update( - url=url, - token=token, - profile=profile, - claim_ids=claim_ids, - service_id=service_id, - ) - return {"credential": {"status": "issued"}} - - try: - pension_survivor.http_json = fake_http_json - pension_survivor.credential_attempt = fake_credential_attempt - result = pension_survivor.run_step({}, "survivor-benefit") - finally: - pension_survivor.http_json = original_http_json - pension_survivor.credential_attempt = original_credential_attempt - os.environ.pop("SIPF_PENSION_CLIENT_TOKEN", None) - - self.assertEqual(captured["service_id"], "sipf-notary") - self.assertEqual( - captured["profile"], "sipf-survivor-benefit.survivor-benefit-status" - ) - self.assertEqual(captured["claim_ids"], ["survivor-is-eligible"]) - self.assertTrue(captured["url"].endswith("/v1/credentials")) - self.assertEqual( - result["request_source"]["body"]["format"], - common.CLAIM_RESULT_FORMAT, - ) - self.assertEqual(result["credential"]["status"], "issued") - - def test_farmer_voucher_purpose_override_reaches_request_source(self) -> None: - purpose = "https://id.registrystack.org/solmara/purpose/citizen-self-service" + def test_purpose_overrides_are_not_exposed_in_request_traces(self) -> None: + purpose = "pension-payment-review" payload = self.post_json( "/v1/scenarios/farmer-climate-smart-voucher/steps/positive/run", {"config": {"purpose_override": purpose}}, ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], purpose - ) - self.assertEqual( - payload["result"]["request_source"]["body"]["format"], - common.CLAIM_RESULT_FORMAT, - ) - - def test_farmer_voucher_denial_step_ignores_override(self) -> None: - payload = self.post_json( - "/v1/scenarios/farmer-climate-smart-voucher/steps/purpose-denial/run", - { - "config": { - "purpose_override": "https://id.registrystack.org/solmara/purpose/livestock-movement-control" - } - }, - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], - "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", - ) - - def test_citizen_purpose_override_reaches_request_source(self) -> None: - purpose = "https://id.registrystack.org/solmara/purpose/pension-payment-review" - payload = self.post_json( - "/v1/scenarios/citizen-self-service/steps/positive/run", - {"config": {"purpose_override": purpose}}, - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], purpose - ) + self.assertNotIn("body", payload["result"]["request_source"]) + self.assertNotIn(purpose, json.dumps(payload["result"]["request_source"])) - def test_citizen_denial_step_ignores_override(self) -> None: - payload = self.post_json( + def test_denial_steps_ignore_purpose_overrides(self) -> None: + citizen_result = self.post_json( "/v1/scenarios/citizen-self-service/steps/purpose-denial/run", - { - "config": { - "purpose_override": "https://id.registrystack.org/solmara/purpose/citizen-self-service" - } - }, - ) - self.assertEqual( - payload["result"]["request_source"]["headers"]["Data-Purpose"], - "https://id.registrystack.org/solmara/purpose/unsupported-demo-purpose", - ) - - def test_citizen_calls_cra_and_nia_and_uses_nia_for_issuance(self) -> None: - calls: list[tuple[str, dict[str, Any]]] = [] - original_http_json = citizen.http_json - original_credential_attempt = citizen.credential_attempt - for name in ("CRA_CITIZEN_CLIENT_TOKEN", "NIA_CITIZEN_CLIENT_TOKEN"): - os.environ[name] = f"token-{name.lower()}" - captured: dict[str, Any] = {} - - def fake_http_json( - method: str, - url: str, - headers: dict, - body: dict | None = None, - timeout: float = 8.0, - ) -> StepHttpResult: - assert body is not None - calls.append((url, body)) - claim_id = body["claims"][0] - return StepHttpResult( - 200, - { - "results": [ - { - "evaluation_id": f"eval-{claim_id}", - "claim_id": claim_id, - "satisfied": True, - "disclosure": "predicate", - } - ] - }, - {"content-type": common.CLAIM_RESULT_FORMAT}, - ) - - def fake_credential_attempt( - url, token, purpose, evaluation_result, profile, claim_ids, service_id - ): - captured.update( - url=url, - token=token, - profile=profile, - claim_ids=claim_ids, - service_id=service_id, - ) - return {"credential": {"status": "issued"}} - - try: - citizen.http_json = fake_http_json - citizen.credential_attempt = fake_credential_attempt - result = citizen.run_step({}, "positive") - finally: - citizen.http_json = original_http_json - citizen.credential_attempt = original_credential_attempt - for name in ("CRA_CITIZEN_CLIENT_TOKEN", "NIA_CITIZEN_CLIENT_TOKEN"): - os.environ.pop(name, None) - - self.assertEqual( - [body["claims"] for _, body in calls], - [["civil-record-linked"], ["citizen-population-record-active"]], - ) - self.assertEqual( - [body["format"] for _, body in calls], - [common.CLAIM_RESULT_FORMAT, common.CLAIM_RESULT_FORMAT], - ) - self.assertEqual( - [trace["service_id"] for trace in result["source_trace"]], - ["cra-notary", "nia-notary"], - ) - self.assertEqual(captured["service_id"], "nia-notary") - self.assertEqual( - captured["profile"], "nia-citizen-status.citizen-population-status" - ) - self.assertEqual(captured["claim_ids"], ["citizen-population-record-active"]) - self.assertTrue(captured["url"].endswith("/v1/credentials")) - self.assertNotIn("citizen-self-service-summary", json.dumps(result)) + {"config": {"purpose_override": common.PURPOSES["citizen_self_service"]}}, + )["result"] + farmer_result = self.post_json( + "/v1/scenarios/farmer-climate-smart-voucher/steps/purpose-denial/run", + {"config": {"purpose_override": common.PURPOSES["livestock"]}}, + )["result"] + self.assertEqual(citizen_result["request_source"]["purpose"], "unsupported-demo-purpose") + self.assertNotIn("body", farmer_result["request_source"]) def test_unknown_scenario_returns_404(self) -> None: with self.assertRaises(urllib.error.HTTPError) as raised: @@ -438,625 +173,465 @@ def test_unknown_scenario_returns_404(self) -> None: self.assertEqual(raised.exception.code, 404) raised.exception.close() - def fetch_json(self, path: str) -> dict: - with urllib.request.urlopen( - f"http://127.0.0.1:{self.port}{path}", timeout=5 - ) as response: - return json.loads(response.read().decode("utf-8")) + def fetch_json(self, path: str) -> dict[str, Any]: + with urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) as response: + return json.loads(response.read()) - def post_json(self, path: str, body: dict) -> dict: + def post_json(self, path: str, body: dict[str, Any]) -> dict[str, Any]: request = urllib.request.Request( f"http://127.0.0.1:{self.port}{path}", - data=json.dumps(body).encode("utf-8"), + data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=5) as response: - return json.loads(response.read().decode("utf-8")) - - -class HolderProofTest(unittest.TestCase): - """Unit coverage for the did:jwk holder key-binding proof used at credential issuance.""" - - def test_holder_keypair_produces_a_valid_did_jwk(self) -> None: - keypair = common.holder_keypair() - self.assertTrue(keypair.holder_id.startswith("did:jwk:")) - jwk = json.loads(b64url_decode(keypair.holder_id.removeprefix("did:jwk:"))) - self.assertEqual(jwk["kty"], "OKP") - self.assertEqual(jwk["crv"], "Ed25519") - public_bytes = keypair.private_key.public_key().public_bytes( - Encoding.Raw, PublicFormat.Raw - ) - self.assertEqual(jwk["x"], common.b64url_nopad(public_bytes)) - - def test_holder_proof_header_and_payload_bindings(self) -> None: - keypair = common.holder_keypair() - claim_ids = ["birth-is-registered", "child-age-under-5"] - proof = common.holder_proof( - keypair, - audience="sipf-notary", - evaluation_id="eval-123", - credential_profile="sipf-survivor-benefit.survivor-benefit-status", - disclosure="predicate", - claim_ids=claim_ids, - ) - header_b64, payload_b64, signature_b64 = proof.split(".") - header = json.loads(b64url_decode(header_b64)) - payload = json.loads(b64url_decode(payload_b64)) - - self.assertEqual( - header, {"alg": "EdDSA", "typ": "kb+jwt", "kid": keypair.holder_id} - ) - self.assertEqual(payload["sub"], keypair.holder_id) - self.assertEqual(payload["aud"], "sipf-notary") - self.assertEqual(payload["evaluation_id"], "eval-123") - self.assertEqual( - payload["credential_profile"], - "sipf-survivor-benefit.survivor-benefit-status", - ) - self.assertEqual(payload["claims"], claim_ids) - self.assertEqual( - payload["disclosure"], - common.b64url_nopad(hashlib.sha256(b"predicate").digest()), - ) - self.assertLessEqual(payload["iat"], int(time.time())) - self.assertEqual(payload["exp"] - payload["iat"], 60) - self.assertTrue(payload["jti"]) - - signing_input = f"{header_b64}.{payload_b64}".encode("ascii") - signature = b64url_decode(signature_b64) - # Raises cryptography.exceptions.InvalidSignature if the proof does not verify. - keypair.private_key.public_key().verify(signature, signing_input) - - def test_holder_proof_jti_is_unique_per_call(self) -> None: - keypair = common.holder_keypair() - kwargs = { - "audience": "a", - "evaluation_id": "e", - "credential_profile": "p", - "disclosure": "predicate", - "claim_ids": ["x"], - } - first = common.holder_proof(keypair, **kwargs) - second = common.holder_proof(keypair, **kwargs) - first_jti = json.loads(b64url_decode(first.split(".")[1]))["jti"] - second_jti = json.loads(b64url_decode(second.split(".")[1]))["jti"] - self.assertNotEqual(first_jti, second_jti) - - def test_credential_attempt_includes_holder_binding_and_matching_claims( - self, - ) -> None: - claim_ids = ["survivor-is-eligible"] - evaluation_result = StepHttpResult( - 200, {"results": [{"evaluation_id": "eval-999"}]}, {} - ) - captured: dict[str, Any] = {} - - def fake_http_json(method, url, headers, body=None, timeout=8.0): - ( - captured["method"], - captured["url"], - captured["headers"], - captured["body"], - ) = method, url, headers, body - return StepHttpResult(400, {"code": "credential.holder_proof_required"}, {}) - - original = common.http_json - common.http_json = fake_http_json - try: - result = common.credential_attempt( - "http://example.invalid/v1/credentials", - "runtime-token", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination", - evaluation_result, - "sipf-survivor-benefit.survivor-benefit-status", - claim_ids, - "sipf-notary", - ) - finally: - common.http_json = original + return json.loads(response.read()) - body = result["credential_source"]["body"] - self.assertEqual(body["claims"], claim_ids) - self.assertEqual(body["holder"]["binding"], "did") - self.assertTrue(body["holder"]["id"].startswith("did:jwk:")) - proof_payload = json.loads(b64url_decode(body["holder"]["proof"].split(".")[1])) - self.assertEqual(proof_payload["aud"], "sipf-notary") - self.assertEqual(proof_payload["claims"], claim_ids) - - # request_source redaction is unaffected by the new holder object. - self.assertEqual( - result["credential_source"]["headers"]["x-api-key"], - "[runtime token hidden]", - ) - # And the outgoing body actually carried the same holder object (nothing lost in transit). - self.assertEqual(captured["body"]["holder"], body["holder"]) - - -class ChildBenefitFederatorTest(unittest.TestCase): - TOKEN_ENVS = ( - "CRA_CHILD_BENEFIT_CLIENT_TOKEN", - "NIA_CHILD_BENEFIT_CLIENT_TOKEN", - "SRO_CHILD_BENEFIT_CLIENT_TOKEN", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - ) +class MintAndEvidenceContractTest(unittest.TestCase): def setUp(self) -> None: - for name in self.TOKEN_ENVS: - os.environ[name] = f"token-{name.lower()}" + install_authority_keys() def tearDown(self) -> None: - for name in self.TOKEN_ENVS: + common._TOKEN_CACHE = ("", 0.0) + common._JWKS_CACHE.clear() + for name in ( + "SOLMARA_EVIDENCE_ACCESS_TOKEN", + "SOLMARA_MINT_URL", + "SOLMARA_MINT_ASSERTION_AUDIENCE", + "SOLMARA_EVIDENCE_CLIENT_ID", + "SOLMARA_EVIDENCE_CLIENT_KEY", + ): os.environ.pop(name, None) - os.environ.pop(child_benefit_federator.FEDERATOR_TOKEN_ENV, None) - def test_catalog_lists_authority_predicates_without_eligibility_composition( - self, - ) -> None: - catalog = child_benefit_federator.claim_catalog() - ids = {entry["id"] for entry in catalog["claims"]} - - self.assertEqual( - ids, - { - "birth-is-registered", - "child-age-under-5", - "population-record-active", - "household-below-poverty-threshold", - "not-already-enrolled", - }, - ) - self.assertNotIn("eligible-for-child-benefit", ids) - self.assertEqual( - catalog["response_media_type"], - "application/json", - ) - self.assertEqual( - catalog["composition"]["eligible-for-child-benefit"], - "not_returned_by_orchestrator", - ) - self.assertEqual(catalog["data"], catalog["claims"]) - - def test_collection_groups_five_claims_into_four_ordinary_notary_calls( - self, - ) -> None: - calls: list[tuple[str, dict[str, str], dict[str, Any]]] = [] - original_http_json = child_benefit_federator.http_json - - def fake_http_json( - method: str, - url: str, - headers: dict[str, str], - body: dict[str, Any], - timeout: float = 8.0, - ) -> StepHttpResult: - calls.append((url, headers, body)) - return StepHttpResult( + def test_evidence_body_uses_bounded_selector_and_unique_nonce(self) -> None: + first = common.evidence_body("2300018263", "requirement", "purpose") + second = common.evidence_body("2300018263", "requirement", "purpose") + self.assertEqual(first["subjects"][0]["selector"]["values"], {"uin": "2300018263"}) + self.assertEqual(len(first["requestNonce"]), 43) + self.assertNotEqual(first["requestNonce"], second["requestNonce"]) + + def test_signed_evidence_is_preserved_and_normalized(self) -> None: + request = common.evidence_body("2300109568", service_config.requirement_id("cra-pension"), common.PURPOSES["pension_payment"]) + signed = signed_evidence("cra-pension", request, [("person-is-deceased", True)]) + result = common.normalized_evidence_result(StepHttpResult(200, signed, {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}), request=request, service_id="cra-pension") + self.assertEqual([entry["satisfied"] for entry in result.body["results"]], [True]) + self.assertEqual(result.body["signed_evidence"], signed) + self.assertEqual(result.body["presentation"]["source"], "Relay lookup") + + def test_safe_projection_omits_jws_and_assertion_payloads(self) -> None: + presentation = { + "authority": "Civil Registration Authority", + "issuer": "did:web:id.registrystack.org:solmara:authority:cra", + "provider": "https://id.registrystack.org/solmara/evidence/cra", + "source": "Relay lookup", + } + projection = common.safe_evidence_projection( + StepHttpResult( 200, { "results": [ { - "evaluation_id": f"internal-{claim_id}", - "claim_id": claim_id, - "claim_version": "1", + "claim_id": "person-is-deceased", + "concept_id": "https://id.registrystack.org/solmara/concept/person-is-deceased", "satisfied": True, - "disclosure": "predicate", - "issued_at": "2026-07-15T00:00:00Z", - "source_row": {"private": "must-not-cross"}, + "value": True, + "presentation": presentation, } - for claim_id in body["claims"] - ] + ], + "presentation": presentation, + "assertion": {"secret": "must-not-leave-server"}, + "signed_evidence": {"payload": "must-not-leave-server"}, }, - {"content-type": common.CLAIM_RESULT_FORMAT}, + {}, ) - - try: - child_benefit_federator.http_json = fake_http_json - evidence = child_benefit_federator.collect_evidence( - "2300010248", - list(child_benefit.CLAIMS), - common.PURPOSES["child_benefit"], + ) + self.assertEqual(projection["presentations"], [presentation]) + self.assertEqual(projection["results"][0]["value"], True) + rendered = json.dumps(projection) + self.assertNotIn("must-not-leave-server", rendered) + self.assertNotIn("assertion", rendered) + self.assertNotIn("signed_evidence", rendered) + + def test_safe_projection_attributes_single_authority_results(self) -> None: + presentation = { + "authority": "National Agricultural Data Institute", + "issuer": "did:web:id.registrystack.org:solmara:authority:nagdi", + "provider": "https://id.registrystack.org/solmara/evidence/nagdi", + "source": "Relay lookup", + } + projection = common.safe_evidence_projection( + StepHttpResult( + 200, { - "type": "Person", - "identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}], + "results": [ + { + "claim_id": "farmer-registered", + "concept_id": "https://id.registrystack.org/solmara/concept/farmer-registered", + "satisfied": True, + "value": True, + } + ], + "presentation": presentation, }, - {"as_of_date": common.CHILD_BENEFIT_AS_OF_DATE}, - ) - finally: - child_benefit_federator.http_json = original_http_json - - self.assertEqual(len(calls), 4) - self.assertEqual( - calls[0][2]["claims"], ["birth-is-registered", "child-age-under-5"] - ) - self.assertEqual( - calls[0][2]["variables"], - {"as_of_date": common.CHILD_BENEFIT_AS_OF_DATE}, - ) - self.assertTrue(all("variables" not in body for _, _, body in calls[1:])) - self.assertEqual(calls[1][2]["claims"], ["population-record-active"]) - self.assertEqual(calls[2][2]["claims"], ["household-below-poverty-threshold"]) - self.assertEqual(calls[3][2]["claims"], ["not-already-enrolled"]) - self.assertTrue(all(url.endswith("/v1/evaluations") for url, _, _ in calls)) - self.assertTrue( - all( - headers["Accept"] == common.CLAIM_RESULT_FORMAT - for _, headers, _ in calls + {}, ) ) - self.assertEqual( - [result["claim_id"] for result in evidence["results"]], child_benefit.CLAIMS - ) - self.assertEqual( - [trace["service_id"] for trace in evidence["source_trace"]], - ["cra-notary", "nia-notary", "sro-notary", "programme-notary"], - ) - self.assertEqual(evidence["orchestration"]["decision"], "not_composed") - serialized = json.dumps(evidence, sort_keys=True) - self.assertNotIn("2300010248", serialized) - self.assertNotIn("internal-", serialized) - self.assertNotIn("source_row", serialized) - self.assertNotIn("must-not-cross", serialized) - self.assertNotIn("token-", serialized) - - def test_unavailable_authority_is_an_error_not_a_false_predicate(self) -> None: - original_http_json = child_benefit_federator.http_json - child_benefit_federator.http_json = lambda *_args, **_kwargs: StepHttpResult( - 503, - {"code": "upstream.unavailable"}, - {"content-type": "application/problem+json"}, - ) - try: - with self.assertRaises( - child_benefit_federator.AuthorityUpstreamError - ) as raised: - child_benefit_federator.collect_evidence( - "2300010248", - ["birth-is-registered"], - common.PURPOSES["child_benefit"], - {}, - {}, - ) - finally: - child_benefit_federator.http_json = original_http_json - - self.assertEqual(raised.exception.status, 503) - self.assertEqual(raised.exception.code, "upstream.unavailable") + self.assertEqual(projection["results"][0]["presentation"], presentation) - def test_authority_must_return_exactly_the_requested_predicate_set(self) -> None: - route = child_benefit_federator.CLAIM_ROUTES["birth-is-registered"] - response = StepHttpResult( + def test_child_scenario_deduplicates_authority_presentations(self) -> None: + presentation = { + "authority": "Civil Registration Authority", + "issuer": "did:web:id.registrystack.org:solmara:authority:cra", + "provider": "https://id.registrystack.org/solmara/evidence/cra", + "source": "immutable extract", + } + original = child_benefit.http_json + child_benefit.http_json = lambda *args, **kwargs: StepHttpResult( 200, { "results": [ - { - "claim_id": "population-record-active", - "satisfied": True, - "disclosure": "predicate", - } - ] + {"claim_id": "birth-is-registered", "satisfied": True, "presentation": presentation}, + {"claim_id": "child-age-under-5", "satisfied": True, "presentation": presentation}, + ], + "source_trace": [], }, {}, ) + try: + os.environ[service_config.service_token_env(child_benefit.SERVICE_ID)] = "collector-token" + result = child_benefit.run_step({}, "positive") + finally: + child_benefit.http_json = original + os.environ.pop(service_config.service_token_env(child_benefit.SERVICE_ID), None) + self.assertEqual(result["presentations"], [presentation]) + + def test_cross_authority_key_is_rejected(self) -> None: + request = common.evidence_body("2300010248", service_config.requirement_id("cra-child-benefit"), common.PURPOSES["child_benefit"]) + signed = signed_evidence("cra-child-benefit", request, [("birth-is-registered", True), ("child-age-under-5", True)], private_key=AUTHORITY_KEYS["nia"]) + result = common.normalized_evidence_result(StepHttpResult(200, signed, {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}), request=request, service_id="cra-child-benefit") + self.assertEqual(result.status, 502) + self.assertEqual(common.source_response(result), {"status": 502, "code": "assertion_verification_failed"}) + + def test_signature_tamper_is_rejected(self) -> None: + request = common.evidence_body("2300010248", service_config.requirement_id("nia-child-benefit"), common.PURPOSES["child_benefit"]) + signed = signed_evidence("nia-child-benefit", request, [("population-record-active", True)]) + signed["signature"] = ("A" if signed["signature"][0] != "A" else "B") + signed["signature"][1:] + result = common.normalized_evidence_result(StepHttpResult(200, signed, {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}), request=request, service_id="nia-child-benefit") + self.assertEqual(result.status, 502) + self.assertNotIn("signature", json.dumps(common.source_response(result))) + + def test_claim_mismatch_is_rejected(self) -> None: + request = common.evidence_body("2300010248", service_config.requirement_id("sro-child-benefit"), common.PURPOSES["child_benefit"]) + signed = signed_evidence("sro-child-benefit", request, [("household-below-poverty-threshold", True)], claims={"requestNonce": "B" * 43}) + result = common.normalized_evidence_result(StepHttpResult(200, signed, {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}), request=request, service_id="sro-child-benefit") + self.assertEqual(result.status, 502) + self.assertNotIn(request["requestNonce"], json.dumps(result.body)) + + def test_relay_assertion_above_five_minutes_is_rejected(self) -> None: + request = common.evidence_body("2300109568", service_config.requirement_id("cra-pension"), common.PURPOSES["pension_payment"]) + issued = int(time.time()) + claims = { + "issuedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(issued)), + "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(issued)), + "validUntil": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(issued + 301)), + } + signed = signed_evidence("cra-pension", request, [("person-is-deceased", True)], claims=claims) + result = common.normalized_evidence_result(StepHttpResult(200, signed, {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}), request=request, service_id="cra-pension") + self.assertEqual(result.status, 502) + + def test_request_and_problem_traces_are_value_free(self) -> None: + selector = "2300010248" + request = common.evidence_body(selector, service_config.requirement_id("cra-child-benefit"), common.PURPOSES["child_benefit"]) + trace = common.request_source("POST", f"https://cra.example/v1/evidence?uin={selector}", {"Authorization": "Bearer secret", "X-Selector": selector}, request) + response = common.source_response(StepHttpResult(403, {"detail": selector, "source": "private"}, {}, "canary-error")) + rendered = json.dumps({"request": trace, "response": response}) + self.assertNotIn(selector, rendered) + self.assertNotIn("private", rendered) + self.assertNotIn("canary-error", rendered) + + +class AuthorityRoutingTest(unittest.TestCase): + def tearDown(self) -> None: + common._TOKEN_CACHE = ("", 0.0) + for config in service_config.AUTHORITY_DIRECTORY.values(): + os.environ.pop(config["env"], None) + for name in ( + "SOLMARA_EVIDENCE_URL", + "SOLMARA_MINT_URL", + "SOLMARA_MINT_ASSERTION_AUDIENCE", + "SOLMARA_EVIDENCE_CLIENT_ID", + "SOLMARA_EVIDENCE_CLIENT_KEY", + ): + os.environ.pop(name, None) - with self.assertRaises( - child_benefit_federator.AuthorityUpstreamError - ) as raised: - child_benefit_federator.minimized_results( - route, ["birth-is-registered"], response - ) + def test_all_requirement_aliases_route_to_their_authority_cell(self) -> None: + expected_hosts = { + "cra": "cra-evidence.solmara.registrystack.org", + "nia": "nia-evidence.solmara.registrystack.org", + "sro": "sro-evidence.solmara.registrystack.org", + "mosd-programme": "mosd-programme-evidence.solmara.registrystack.org", + "sipf": "sipf-evidence.solmara.registrystack.org", + "nagdi": "nagdi-evidence.solmara.registrystack.org", + } + self.assertEqual(len(service_config.REQUIREMENT_DIRECTORY), 11) + for service_id, route in service_config.REQUIREMENT_DIRECTORY.items(): + with self.subTest(service_id=service_id): + self.assertEqual(urllib.parse.urlsplit(service_config.service_url(service_id)).hostname, expected_hosts[route["authority"]]) + self.assertEqual(service_config.authority_service_id(service_id), f"{route['authority']}-evidence") + + def test_source_labels_match_the_requirement_acquisition_path(self) -> None: + immutable = {"cra-child-benefit", "nia-child-benefit", "nia-citizen", "sro-child-benefit"} + self.assertEqual( + {service_id for service_id, route in service_config.REQUIREMENT_DIRECTORY.items() if route["source"] == "immutable extract"}, + immutable, + ) + self.assertTrue(all(route["maximum_validity_seconds"] == (3600 if service_id in immutable else 300) for service_id, route in service_config.REQUIREMENT_DIRECTORY.items())) - self.assertEqual(raised.exception.code, "unexpected_claim_results") + def test_authority_override_is_local_and_singleton_setting_is_ignored(self) -> None: + os.environ["SOLMARA_CRA_EVIDENCE_URL"] = "http://cra-evidence:8080" + os.environ["SOLMARA_EVIDENCE_URL"] = "https://must-not-be-used.example" + self.assertEqual(service_config.service_url("cra-citizen"), "http://cra-evidence:8080/v1/evidence") + self.assertEqual(service_config.service_url("nia-citizen"), "https://nia-evidence.solmara.registrystack.org/v1/evidence") - def test_request_body_has_a_hard_size_limit(self) -> None: - handler = object.__new__(child_benefit_federator.ChildBenefitFederatorHandler) - handler.headers = { - "Content-Length": str(child_benefit_federator.MAX_REQUEST_BODY_BYTES + 1) + def test_private_key_jwt_and_mint_token_request(self) -> None: + try: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec, utils + except ModuleNotFoundError: + self.skipTest("cryptography is not installed") + + private_key = ec.generate_private_key(ec.SECP256R1()) + numbers = private_key.private_numbers() + public = numbers.public_numbers + jwk = { + "kty": "EC", "crv": "P-256", "alg": "ES256", + "kid": "A" * 43, + "x": common.b64url_nopad(public.x.to_bytes(32, "big")), + "y": common.b64url_nopad(public.y.to_bytes(32, "big")), + "d": common.b64url_nopad(numbers.private_value.to_bytes(32, "big")), } - handler.rfile = io.BytesIO(b"") + captured: dict[str, Any] = {} - with self.assertRaises(child_benefit_federator.RequestBodyError) as rejected: - handler.read_body() + class Response: + def __enter__(self): + return self - self.assertEqual(rejected.exception.status, 413) + def __exit__(self, *_args): + return None - def test_duplicate_claims_are_rejected_before_authority_calls(self) -> None: - status, content_type, payload = self.post_application( - { - "target": { - "identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}] - }, - "claims": ["birth-is-registered", "birth-is-registered"], - "disclosure": "predicate", - "format": "application/json", - } - ) + def read(self): + return json.dumps({"access_token": "minted-token", "expires_in": 120}).encode() - self.assertEqual(status, 400) - self.assertEqual(content_type, "application/problem+json") - self.assertEqual(payload["code"], "request.invalid") + original = urllib.request.urlopen - def test_raw_household_request_is_denied_without_echoing_the_claim(self) -> None: - status, content_type, payload = self.post_application( - { - "target": { - "identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}] - }, - "claims": ["household-poverty-score"], - "disclosure": "value", - "format": "application/json", - } - ) + def fake_urlopen(request, **kwargs): + captured["url"] = request.full_url + captured["form"] = urllib.parse.parse_qs(request.data.decode()) + captured["context"] = kwargs.get("context") + return Response() - self.assertEqual(status, 403) - self.assertEqual(content_type, "application/problem+json") - self.assertEqual(payload["code"], "pdp.purpose_not_permitted") - self.assertNotIn("household-poverty-score", json.dumps(payload)) - - def test_success_uses_the_child_benefit_evidence_media_type(self) -> None: - original_collect_evidence = child_benefit_federator.collect_evidence - child_benefit_federator.collect_evidence = lambda *_args: { - "schema_version": child_benefit_federator.API_VERSION, - "results": [], - "source_trace": [], - } - try: - status, content_type, _payload = self.post_application( - { - "target": { - "identifiers": [ - {"scheme": "solmara_uin", "value": "2300010248"} - ] - }, - "claims": ["birth-is-registered"], - "disclosure": "predicate", - "format": "application/json", - } + with tempfile.TemporaryDirectory() as directory: + key_path = Path(directory) / "client.jwk" + key_path.write_text(json.dumps(jwk)) + os.environ.update( + SOLMARA_MINT_URL="https://localhost:4341", + SOLMARA_MINT_ASSERTION_AUDIENCE="https://mint.evidence.solmara.invalid/token", + SOLMARA_EVIDENCE_CLIENT_ID="solmara-scenario-runner", + SOLMARA_EVIDENCE_CLIENT_KEY=str(key_path), ) - finally: - child_benefit_federator.collect_evidence = original_collect_evidence + urllib.request.urlopen = fake_urlopen + try: + token = common.evidence_access_token() + finally: + urllib.request.urlopen = original + + self.assertEqual(token, "minted-token") + self.assertEqual(captured["url"], "https://localhost:4341/token") + assertion = captured["form"]["client_assertion"][0] + header_segment, claims_segment, signature_segment = assertion.split(".") + header = json.loads(b64url_decode(header_segment)) + claims = json.loads(b64url_decode(claims_segment)) + self.assertEqual(header, {"alg": "ES256", "typ": "JWT", "kid": "A" * 43}) + self.assertEqual(claims["iss"], "solmara-scenario-runner") + self.assertEqual(claims["sub"], "solmara-scenario-runner") + self.assertEqual( + claims["aud"], "https://mint.evidence.solmara.invalid/token" + ) + self.assertEqual(claims["exp"] - claims["iat"], 120) + signature = b64url_decode(signature_segment) + self.assertEqual(len(signature), 64) + der_signature = utils.encode_dss_signature( + int.from_bytes(signature[:32], "big"), + int.from_bytes(signature[32:], "big"), + ) + private_key.public_key().verify( + der_signature, + f"{header_segment}.{claims_segment}".encode(), + ec.ECDSA(hashes.SHA256()), + ) + + def test_invalid_private_key_fails_closed_before_mint_request(self) -> None: + original = urllib.request.urlopen + + def fail_if_called(*_args, **_kwargs): + raise AssertionError("Mint must not be called with an invalid assertion key") + + with tempfile.TemporaryDirectory() as directory: + key_path = Path(directory) / "client.jwk" + key_path.write_text('{"kty":"EC","crv":"P-256","alg":"ES256","kid":"invalid"}') + os.environ.update( + SOLMARA_MINT_URL="https://localhost:4341", + SOLMARA_EVIDENCE_CLIENT_ID="solmara-scenario-runner", + SOLMARA_EVIDENCE_CLIENT_KEY=str(key_path), + ) + urllib.request.urlopen = fail_if_called + try: + self.assertEqual(common.evidence_access_token(), "") + finally: + urllib.request.urlopen = original - self.assertEqual(status, 200) - self.assertEqual(content_type, "application/json") - def test_obsolete_federation_route_is_absent(self) -> None: - token = "test-application-token" - os.environ[child_benefit_federator.FEDERATOR_TOKEN_ENV] = token - server = ThreadingHTTPServer( - ("127.0.0.1", 0), child_benefit_federator.ChildBenefitFederatorHandler - ) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - request = urllib.request.Request( - f"http://127.0.0.1:{server.server_address[1]}/federation/v1/evaluations", - data=b"{}", - headers={"x-api-key": token, "Content-Type": "application/json"}, - method="POST", - ) +class EvidenceScenarioTest(unittest.TestCase): + def setUp(self) -> None: + os.environ["SOLMARA_EVIDENCE_ACCESS_TOKEN"] = "runtime-token" + install_authority_keys() + + def tearDown(self) -> None: + os.environ.pop("SOLMARA_EVIDENCE_ACCESS_TOKEN", None) + common._JWKS_CACHE.clear() + + def test_pension_combines_two_signed_requirements_in_the_application(self) -> None: + calls: list[dict[str, Any]] = [] + original = pension_survivor.http_json + + def fake_http_json(method, url, headers, body=None, timeout=8.0): + calls.append(body) + requirement = body["requirement"] + service_id = "cra-pension" if "cra-pension-death" in requirement else "sipf-pension" + concept = "person-is-deceased" if service_id == "cra-pension" else "pension-payment-active" + return StepHttpResult(200, signed_evidence(service_id, body, [(concept, True)]), {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}) + + pension_survivor.http_json = fake_http_json try: - with self.assertRaises(urllib.error.HTTPError) as rejected: - urllib.request.urlopen(request, timeout=5) - payload = json.loads(rejected.exception.read().decode("utf-8")) - rejected.exception.close() + result = pension_survivor.run_step({}, "stop-payment") finally: - server.shutdown() - thread.join(timeout=5) - server.server_close() - - self.assertEqual(rejected.exception.code, 404) - self.assertEqual(payload["code"], "not_found") - - def post_application(self, body: dict[str, Any]) -> tuple[int, str, dict[str, Any]]: - token = "test-application-token" - os.environ[child_benefit_federator.FEDERATOR_TOKEN_ENV] = token - server = ThreadingHTTPServer( - ("127.0.0.1", 0), child_benefit_federator.ChildBenefitFederatorHandler - ) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - request = urllib.request.Request( - f"http://127.0.0.1:{server.server_address[1]}/v1/evaluations", - data=json.dumps(body).encode("utf-8"), - headers={ - "x-api-key": token, - "Data-Purpose": common.PURPOSES["child_benefit"], - "Content-Type": "application/json", - "Accept": "application/json", - }, - method="POST", - ) + pension_survivor.http_json = original + self.assertEqual(len(calls), 2) + self.assertTrue(result["derived_decisions"]["pension-payment-should-stop"]) + self.assertEqual({trace["service_id"] for trace in result["source_trace"]}, {"cra-evidence", "sipf-evidence"}) + self.assertEqual(result["response_source"], {"status": 200, "code": "ok"}) + + def test_citizen_requests_cra_and_nia_requirements(self) -> None: + calls: list[str] = [] + original = citizen.http_json + + def fake_http_json(method, url, headers, body=None, timeout=8.0): + calls.append(body["requirement"]) + service_id = "cra-citizen" if "cra-citizen" in body["requirement"] else "nia-citizen" + concept = "civil-record-linked" if service_id == "cra-citizen" else "citizen-population-record-active" + return StepHttpResult(200, signed_evidence(service_id, body, [(concept, True)]), {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}) + + citizen.http_json = fake_http_json try: - try: - with urllib.request.urlopen(request, timeout=5) as response: - return ( - response.status, - response.headers.get_content_type(), - json.loads(response.read().decode("utf-8")), - ) - except urllib.error.HTTPError as error: - return ( - error.code, - error.headers.get_content_type(), - json.loads(error.read().decode("utf-8")), - ) + result = citizen.run_step({}, "positive") finally: - server.shutdown() - thread.join(timeout=5) - server.server_close() - - -class StdlibOnlyImportTest(unittest.TestCase): - def test_scenarios_import_without_cryptography(self) -> None: - """Preview-only consumers (scripts/smoke-story-previews.py) run under the - system Python, so importing the scenario modules must not require the - cryptography package; only actually signing a holder proof may.""" - code = ( - "import builtins\n" - "real_import = builtins.__import__\n" - "def guard(name, *args, **kwargs):\n" - " if name.split('.')[0] == 'cryptography':\n" - " raise ModuleNotFoundError(name)\n" - " return real_import(name, *args, **kwargs)\n" - "builtins.__import__ = guard\n" - "import scenarios.common\n" - "import scenarios.child_benefit\n" - ) - result = subprocess.run( - [sys.executable, "-c", code], cwd=ROOT, capture_output=True, text=True - ) - self.assertEqual(result.returncode, 0, result.stderr) + citizen.http_json = original + self.assertEqual(calls, [citizen.requirement_id(client) for client in citizen.CLIENTS]) + self.assertEqual(len(result["source_trace"]), 2) -class CredentialSummaryVctTest(unittest.TestCase): - VCT = ( - "https://id.registrystack.org/solmara/vct/child-benefit-enrollment-eligibility" - ) +class ChildBenefitCollectorTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + os.environ[child_benefit_federator.FEDERATOR_TOKEN_ENV] = "collector-token" + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), child_benefit_federator.ChildBenefitFederatorHandler) + cls.port = cls.server.server_address[1] + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() - @staticmethod - def compact_sd_jwt(payload: dict[str, Any]) -> str: - def b64url(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.thread.join(timeout=5) + cls.server.server_close() + os.environ.pop(child_benefit_federator.FEDERATOR_TOKEN_ENV, None) - header = b64url(json.dumps({"alg": "EdDSA", "typ": "dc+sd-jwt"}).encode()) - body = b64url(json.dumps(payload).encode()) - return f"{header}.{body}.fake-signature~ZmFrZS1kaXNjbG9zdXJl~" + def setUp(self) -> None: + os.environ["SOLMARA_EVIDENCE_ACCESS_TOKEN"] = "runtime-token" + install_authority_keys() - def test_issued_summary_extracts_vct_from_sd_jwt_payload(self) -> None: - body = { - "credential": self.compact_sd_jwt( - {"vct": self.VCT, "iss": "did:web:child-benefit-notary"} - ), - "credential_profile": "child_benefit_eligibility_sd_jwt", - "disclosures": ["a", "b"], - } - summary = common.credential_summary( - "profile", "did:jwk:x", StepHttpResult(200, body, {}) - ) - self.assertEqual(summary["status"], "issued") - self.assertEqual(summary["vct"], self.VCT) + def tearDown(self) -> None: + os.environ.pop("SOLMARA_EVIDENCE_ACCESS_TOKEN", None) + common._JWKS_CACHE.clear() + + def request(self, path: str, *, token: str = "collector-token", body: dict[str, Any] | None = None): + headers = {"x-api-key": token} + data = None + method = "GET" + if body is not None: + headers.update({"Content-Type": "application/json"}) + data = json.dumps(body).encode() + method = "POST" + return urllib.request.urlopen( + urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", data=data, headers=headers, method=method), + timeout=5, + ) + + def test_catalog_lists_five_source_owned_concepts(self) -> None: + with self.request("/v1/claims") as response: + payload = json.loads(response.read()) + self.assertEqual({item["claim_id"] for item in payload["claims"]}, set(child_benefit.CLAIMS)) + self.assertNotIn("eligible-for-child-benefit", json.dumps(payload)) + + def test_requires_local_application_auth(self) -> None: + with self.assertRaises(urllib.error.HTTPError) as raised: + self.request("/v1/claims", token="wrong") + self.assertEqual(raised.exception.code, 401) + raised.exception.close() - def test_issued_summary_without_decodable_credential_has_no_vct(self) -> None: - body = {"credential": "not-a-jwt", "disclosures": []} - summary = common.credential_summary( - "profile", "did:jwk:x", StepHttpResult(200, body, {}) - ) - self.assertEqual(summary["status"], "issued") - self.assertIsNone(summary["vct"]) + def test_rejects_duplicate_or_unknown_concepts_before_evidence(self) -> None: + base = {"purpose": common.PURPOSES["child_benefit"], "target": {"identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}]}} + for claims in (["birth-is-registered", "birth-is-registered"], ["not-a-concept"]): + with self.subTest(claims=claims), self.assertRaises(urllib.error.HTTPError) as raised: + self.request("/v1/evaluations", body={**base, "claims": claims}) + self.assertEqual(raised.exception.code, 400) + raised.exception.close() - def test_issued_summary_with_undecodable_payload_has_no_vct(self) -> None: - body = { - "credential": "eyJhbGciOiJFZERTQSJ9.%%%not-base64%%%.sig", - "disclosures": [], - } - summary = common.credential_summary( - "profile", "did:jwk:x", StepHttpResult(200, body, {}) - ) - self.assertEqual(summary["status"], "issued") - self.assertIsNone(summary["vct"]) - - -class FriendlyResultTest(unittest.TestCase): - COPY = { - "positive": { - "met": ( - "Yes. Mateo qualifies for review.", - "All four facts came back met.", - ), - }, - "poverty-control": { - "unmet": ( - "Rejected: the household is above the threshold.", - "The caseworker never sees the income.", - ), - }, - } + def test_composes_only_four_independently_verified_assertions(self) -> None: + original = child_benefit_federator.http_json - def test_pdp_denial_reads_as_designed(self) -> None: - result = StepHttpResult( - 403, {"code": "pdp.purpose_not_permitted", "detail": "nope"}, {} - ) - friendly = common.friendly_result("positive", result, self.COPY) - self.assertEqual(friendly["status"], "done") - self.assertEqual(friendly["title"], "Refused, exactly as designed.") + def fake_http_json(method, url, headers, body=None, timeout=8.0): + config = service_config.config_for_requirement(body["requirement"]) + values = [(concept, True) for concept in config["concepts"]] + return StepHttpResult(200, signed_evidence(config["service_id"], body, values), {"content-type": common.EVIDENCE_JWS_MEDIA_TYPE}) - def test_all_claims_met_uses_step_copy(self) -> None: - body = { - "results": [ - {"claim_id": "a", "satisfied": True}, - {"claim_id": "b", "satisfied": True}, - ] - } - friendly = common.friendly_result( - "positive", StepHttpResult(200, body, {}), self.COPY - ) - self.assertEqual(friendly["status"], "done") - self.assertEqual(friendly["title"], "Yes. Mateo qualifies for review.") - self.assertIn({"label": "Claims met", "value": "2 of 2"}, friendly["facts"]) + child_benefit_federator.http_json = fake_http_json + try: + body = { + "purpose": common.PURPOSES["child_benefit"], + "target": {"identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}]}, + "claims": child_benefit.CLAIMS, + } + with self.request("/v1/evaluations", body=body) as response: + payload = json.loads(response.read()) + finally: + child_benefit_federator.http_json = original + self.assertEqual(len(payload["signed_evidence"]), 4) + self.assertEqual({item["presentation"]["source"] for item in payload["results"]}, {"immutable extract", "Relay lookup"}) + self.assertEqual({item["service_id"] for item in payload["source_trace"]}, {"cra-evidence", "nia-evidence", "sro-evidence", "mosd-programme-evidence"}) + self.assertNotIn("requirement", json.dumps(payload["source_trace"])) - def test_unmet_claim_uses_unmet_copy_and_names_claim(self) -> None: + def test_purpose_comes_from_json_body_not_header(self) -> None: body = { - "results": [ - {"claim_id": "household-below-poverty-threshold", "satisfied": False} - ] + "purpose": "unsupported-purpose", + "target": {"identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}]}, + "claims": child_benefit.CLAIMS, } - friendly = common.friendly_result( - "poverty-control", StepHttpResult(200, body, {}), self.COPY - ) - self.assertEqual(friendly["status"], "done") - self.assertEqual( - friendly["title"], "Rejected: the household is above the threshold." - ) - - def test_unmet_claim_without_copy_falls_back_to_generic_rejection(self) -> None: - body = {"results": [{"claim_id": "child-age-under-5", "satisfied": False}]} - friendly = common.friendly_result( - "deceased-control", StepHttpResult(200, body, {}), self.COPY - ) - self.assertEqual(friendly["status"], "done") - self.assertEqual(friendly["title"], "Rejected, exactly as designed.") - self.assertIn("child-age-under-5", friendly["message"]) - - def test_refused_copy_frames_intended_denial_as_done(self) -> None: - copy = { - "cause-of-death-denial": { - "refused": ( - "Refused: that question does not exist here.", - "No such claim is offered.", - ) - } - } - result = StepHttpResult( - 404, - { - "code": "claim.not_found", - "detail": "the requested claim is not available", - }, - {}, - ) - friendly = common.friendly_result("cause-of-death-denial", result, copy) - self.assertEqual(friendly["status"], "done") - self.assertEqual( - friendly["title"], "Refused: that question does not exist here." - ) - - def test_non_pdp_error_without_refused_copy_needs_attention(self) -> None: - result = StepHttpResult( - 404, {"code": "claim.not_found", "detail": "missing"}, {} - ) - friendly = common.friendly_result("positive", result, self.COPY) - self.assertEqual(friendly["status"], "needs_attention") - - def test_no_response_needs_attention(self) -> None: - friendly = common.friendly_result( - "positive", StepHttpResult(None, {}, {}, "URLError"), self.COPY - ) - self.assertEqual(friendly["status"], "needs_attention") - - def test_unexpected_error_status_needs_attention(self) -> None: - result = StepHttpResult(500, {"detail": "boom"}, {}) - friendly = common.friendly_result("positive", result, self.COPY) - self.assertEqual(friendly["status"], "needs_attention") - self.assertIn("boom", friendly["message"]) + with self.assertRaises(urllib.error.HTTPError) as raised: + self.request("/v1/evaluations", body=body) + self.assertEqual(raised.exception.code, 403) + raised.exception.close() if __name__ == "__main__": diff --git a/scenarios/child_benefit.py b/scenarios/child_benefit.py index bea583a..cadee1d 100644 --- a/scenarios/child_benefit.py +++ b/scenarios/child_benefit.py @@ -1,81 +1,30 @@ #!/usr/bin/env python3 -"""Birth to child benefit guided scenario.""" +"""Birth-to-child-benefit guided scenario through the application collector.""" from __future__ import annotations from typing import Any -from .common import ( - CHILD_BENEFIT_AS_OF_DATE, - PURPOSES, - auth_headers, - evaluation_body, - friendly_result, - http_json, - missing_runtime_token, - request_source, - source_response, - standard_error_result, -) +from .common import CHILD_BENEFIT_AS_OF_DATE, PURPOSES, friendly_result, http_json, missing_runtime_token, request_source, source_response, standard_error_result from .service_config import service_token, service_token_env, service_url SCENARIO_ID = "birth-to-child-benefit" -SERVICE_NAME = "Child Benefit Federator" +SERVICE_NAME = "Child Benefit Evidence Collector" SERVICE_ID = "child-benefit-federator" POSITIVE_SUBJECT = "2300010248" DECEASED_CONTROL = "2300091305" ABOVE_THRESHOLD_CONTROL = "2300036523" UNREGISTERED_CONTROL = "2300073046" DUPLICATE_CONTROL = "2300054788" -CLAIMS = [ - "birth-is-registered", - "population-record-active", - "child-age-under-5", - "household-below-poverty-threshold", - "not-already-enrolled", -] +CLAIMS = ["birth-is-registered", "population-record-active", "child-age-under-5", "household-below-poverty-threshold", "not-already-enrolled"] FRIENDLY = { - "discover": { - "met": ( - "The catalogue lists what may be asked.", - "Claim definitions only. No resident data has moved yet.", - ), - }, - "positive": { - "met": ( - "Mateo's source predicates are ready for review.", - "The application collected five source-owned facts. It did not make the benefit decision.", - ), - "unmet": ( - "Review cannot proceed on the facts returned.", - "One or more source-owned checks came back not met. The programme policy layer decides what happens next.", - ), - }, - "deceased-control": { - "unmet": ( - "Rejected, exactly as designed.", - "The civil predicate fails for the deceased control case. The application only returns that fact.", - ), - }, - "poverty-control": { - "unmet": ( - "Rejected: the household is above the threshold.", - "The social registry predicate came back not met. The caseworker never sees the household's actual income.", - ), - }, - "unregistered-control": { - "unmet": ( - "No birth predicate could be satisfied. Registration comes first.", - "The civil authority returns only the minimized predicate result, not a source row.", - ), - }, - "duplicate-control": { - "unmet": ( - "Rejected: already enrolled.", - "The programme MIS predicate came back not met, preventing a double payment.", - ), - }, + "positive": {"met": ("Mateo's signed source evidence is ready.", "The programme composed five concepts from four authority requirements without copying source rows.")}, + "deceased-control": {"unmet": ("Rejected, exactly as designed.", "The civil evidence says the child is not active for this review.")}, + "poverty-control": {"unmet": ("Rejected: the household is above the threshold.", "Only the reviewed poverty predicate was disclosed.")}, + "unregistered-control": {"unmet": ("Registration comes first.", "No registered-birth evidence was asserted.")}, + "duplicate-control": {"unmet": ("Rejected: already enrolled.", "The programme evidence prevents a duplicate payment.")}, + "purpose-denial": {"refused": ("Refused, exactly as designed.", "The unsupported purpose matched no Evidence grant.")}, } @@ -84,74 +33,23 @@ def story() -> dict[str, Any]: "id": SCENARIO_ID, "title": "Birth to child benefit", "short_title": "Child benefit", - "proves": "Civil, population, social registry, and beneficiary evidence can be collected as source-owned predicates without copying source rows.", + "proves": "One application can collect separately signed CRA, NIA, SRO, and MoSD evidence without copying source rows.", "domain": "Social protection", - "availability": "hosted", - "intro": "A caseworker reviews child benefit eligibility from minimized Solmara evidence.", + "availability": "local", + "intro": "A caseworker reviews minimized Registry Evidence assertions.", "actor": "MoSD child benefit caseworker", "subject": {"name": "Mateo Santos", "identifier": POSITIVE_SUBJECT}, - "requester": { - "name": "Child benefit desk", - "purpose": PURPOSES["child_benefit"], - }, + "requester": {"name": "Child benefit desk", "purpose": PURPOSES["child_benefit"]}, "steps": [ - { - "id": "discover", - "label": "Discover predicates", - "prompt": "Read the child-benefit evidence catalogue.", - "button": "Discover", - "request_summary": "GET /v1/claims", - }, - { - "id": "positive", - "label": "Collect eligible child predicates", - "prompt": "Run the positive control.", - "button": "Evaluate", - "request_summary": "POST child-benefit evidence request for the positive UIN.", - }, - { - "id": "deceased-control", - "label": "Deceased control", - "prompt": "Confirm a deceased child is rejected.", - "button": "Evaluate", - "request_summary": "POST child-benefit claims for the deceased control UIN.", - }, - { - "id": "poverty-control", - "label": "Income threshold control", - "prompt": "Confirm an above-threshold household is rejected.", - "button": "Evaluate", - "request_summary": "POST child-benefit claims for the threshold control UIN.", - }, - { - "id": "unregistered-control", - "label": "Unregistered birth control", - "prompt": "Route an unregistered birth to registration first.", - "button": "Evaluate", - "request_summary": "POST child-benefit claims for the unregistered control UIN.", - }, - { - "id": "duplicate-control", - "label": "Duplicate enrollment control", - "prompt": "Reject an already-enrolled child.", - "button": "Evaluate", - "request_summary": "POST child-benefit claims for the duplicate control UIN.", - }, - { - "id": "purpose-denial", - "label": "Purpose denial", - "prompt": "Try the same request with an unsupported purpose.", - "button": "Try denial", - "request_summary": "POST with an unsupported Data-Purpose header.", - }, - ], - "receipt": [ - { - "label": "Evidence", - "value": "Source-owned predicates, no eligibility composition", - }, - {"label": "Raw rows copied", "value": "No"}, + {"id": "discover", "label": "Discover requirements", "prompt": "Read the Evidence definitions.", "button": "Discover", "request_summary": "GET /v1/evidence-definitions"}, + {"id": "positive", "label": "Collect eligible child evidence", "prompt": "Run the positive control.", "button": "Evaluate", "request_summary": "POST five Evidence requirements."}, + {"id": "deceased-control", "label": "Deceased control", "prompt": "Confirm a deceased child is rejected.", "button": "Evaluate", "request_summary": "POST child-benefit requirements."}, + {"id": "poverty-control", "label": "Income threshold control", "prompt": "Confirm an above-threshold household is rejected.", "button": "Evaluate", "request_summary": "POST child-benefit requirements."}, + {"id": "unregistered-control", "label": "Unregistered birth control", "prompt": "Route an unregistered birth to registration first.", "button": "Evaluate", "request_summary": "POST child-benefit requirements."}, + {"id": "duplicate-control", "label": "Duplicate enrollment control", "prompt": "Reject an already-enrolled child.", "button": "Evaluate", "request_summary": "POST child-benefit requirements."}, + {"id": "purpose-denial", "label": "Purpose denial", "prompt": "Try an unsupported purpose.", "button": "Try denial", "request_summary": "POST with an unsupported purpose."}, ], + "receipt": [{"label": "Evidence", "value": "Flattened signed JWS assertions"}, {"label": "Raw rows copied", "value": "No"}], } @@ -164,57 +62,43 @@ def run_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: def _request(config: dict[str, Any], step_id: str, *, send: bool) -> dict[str, Any]: - url = service_url( - SERVICE_ID, "/v1/claims" if step_id == "discover" else "/v1/evaluations" - ) - subject = { - "positive": POSITIVE_SUBJECT, - "deceased-control": DECEASED_CONTROL, - "poverty-control": ABOVE_THRESHOLD_CONTROL, - "unregistered-control": UNREGISTERED_CONTROL, - "duplicate-control": DUPLICATE_CONTROL, - "purpose-denial": POSITIVE_SUBJECT, - }.get(step_id) - purpose = request_purpose(config, step_id) - token = service_token(SERVICE_ID) - headers = auth_headers(token, purpose, "application/json") - body = ( - None - if step_id == "discover" - else evaluation_body( - subject or "", - CLAIMS, - scheme="solmara_uin", - format="application/json", - variables={"as_of_date": CHILD_BENEFIT_AS_OF_DATE}, - ) - ) + subject = {"positive": POSITIVE_SUBJECT, "deceased-control": DECEASED_CONTROL, "poverty-control": ABOVE_THRESHOLD_CONTROL, "unregistered-control": UNREGISTERED_CONTROL, "duplicate-control": DUPLICATE_CONTROL, "purpose-denial": POSITIVE_SUBJECT}.get(step_id) if step_id != "discover" and not subject: return standard_error_result(step_id) - request = request_source( - "GET" if step_id == "discover" else "POST", url, headers, body - ) + token = service_token(SERVICE_ID) if send else "" + purpose = "unsupported-demo-purpose" if step_id == "purpose-denial" else str(config.get("purpose_override") or PURPOSES["child_benefit"]) + url = service_url(SERVICE_ID, "/v1/claims" if step_id == "discover" else "/v1/evaluations") + headers = {"x-api-key": token, "Accept": "application/json"} + body = None if step_id == "discover" else {"purpose": purpose, "target": {"type": "Person", "identifiers": [{"scheme": "solmara_uin", "value": subject}]}, "claims": CLAIMS, "disclosure": "predicate", "format": "application/json", "variables": {"as_of_date": CHILD_BENEFIT_AS_OF_DATE}} + request = request_source("GET" if step_id == "discover" else "POST", url, headers, body) + if body is not None: + request["purpose"] = purpose if not send: return {"request_source": request} if not token: - return missing_runtime_token( - step_id, SERVICE_NAME, service_token_env(SERVICE_ID), request - ) + return missing_runtime_token(step_id, SERVICE_NAME, service_token_env(SERVICE_ID), request) result = http_json("GET" if step_id == "discover" else "POST", url, headers, body) response_body = result.body if isinstance(result.body, dict) else {} - payload = { - "step_id": step_id, - "friendly": friendly_result(step_id, result, FRIENDLY), - "request_source": request, - "response_source": source_response(result), - "source_trace": response_body.get("source_trace", []), - } - return payload + results = [] + for item in response_body.get("results", []): + if not isinstance(item, dict) or not isinstance(item.get("satisfied"), bool): + continue + safe = { + "claim_id": item.get("claim_id"), + "concept_id": item.get("concept_id"), + "satisfied": item["satisfied"], + "value": item["satisfied"], + } + if isinstance(item.get("presentation"), dict): + safe["presentation"] = item["presentation"] + results.append(safe) + presentations = [] + for item in results: + presentation = item.get("presentation") + if isinstance(presentation, dict) and presentation not in presentations: + presentations.append(presentation) + return {"step_id": step_id, "friendly": friendly_result(step_id, result, FRIENDLY), "request_source": request, "response_source": source_response(result), "source_trace": response_body.get("source_trace", []), "results": results, "presentations": presentations} def request_purpose(config: dict[str, Any], step_id: str) -> str: - if step_id == "purpose-denial": - return "https://id.registrystack.org/solmara/purpose/unsupported-demo-purpose" - if isinstance(config.get("purpose_override"), str): - return config["purpose_override"] - return PURPOSES["child_benefit"] + return "unsupported-demo-purpose" if step_id == "purpose-denial" else str(config.get("purpose_override") or PURPOSES["child_benefit"]) diff --git a/scenarios/citizen.py b/scenarios/citizen.py index 2ff2593..feade1b 100644 --- a/scenarios/citizen.py +++ b/scenarios/citizen.py @@ -1,111 +1,23 @@ #!/usr/bin/env python3 -"""Citizen self-service guided scenario.""" +"""Citizen self-service Evidence scenario.""" from __future__ import annotations from typing import Any -from .common import ( - CLAIM_RESULT_FORMAT, - PURPOSES, - StepHttpResult, - auth_headers, - credential_attempt, - evaluation_body, - friendly_result, - http_json, - missing_runtime_token, - request_source, - source_response, - standard_error_result, -) -from .service_config import ( - authority_service_id, - service_token, - service_token_env, - service_url, -) +from .common import PURPOSES, StepHttpResult, evidence_body, evidence_headers, friendly_result, http_json, missing_runtime_token, normalized_evidence_result, request_source, safe_evidence_projection, source_response, standard_error_result +from .service_config import authority_service_id, requirement_config, requirement_id, service_token, service_token_env, service_url SCENARIO_ID = "citizen-self-service" -SERVICE_NAME = "Citizen self-service evidence" -CRA_CLIENT = "cra-citizen" -NIA_CLIENT = "nia-citizen" +SERVICE_NAME = "Registry Evidence" POSITIVE_SUBJECT = "2300018263" -CRA_CLAIMS = ["civil-record-linked"] -NIA_CLAIMS = ["citizen-population-record-active"] -CREDENTIAL_PROFILE = "nia-citizen-status.citizen-population-status" -CREDENTIAL_STEPS = {"positive"} -AUTHORITY_NAMES = { - CRA_CLIENT: "Civil Registration Authority", - NIA_CLIENT: "National Identity Agency", -} -FRIENDLY = { - "discover": { - "met": ( - "The catalogues list what may be asked.", - "Claim definitions only. No resident data has moved.", - ), - }, - "positive": { - "met": ( - "Elena's minimized status is ready.", - "CRA confirmed the civil link and NIA confirmed an active population record. NIA can issue the population-status credential.", - ), - "unmet": ( - "No status credential is available on the facts returned.", - "One or more self-service checks came back not met.", - ), - }, -} +CLIENTS = ("cra-citizen", "nia-citizen") +FRIENDLY = {"positive": {"met": ("Elena's signed status evidence is ready.", "CRA and NIA released separate reviewed concept values.")}, "purpose-denial": {"refused": ("Refused, exactly as designed.", "No grant permits that purpose.")}} def story() -> dict[str, Any]: - return { - "id": SCENARIO_ID, - "title": "Citizen self-service", - "short_title": "Citizen self-service", - "proves": "The portal can present separate CRA and NIA evidence while NIA remains the sole citizen-status credential issuer.", - "domain": "Citizen services", - "availability": "hosted", - "intro": "A signed-in citizen previews their own minimized status evidence.", - "actor": "Citizen Services Portal", - "subject": {"name": "Elena Dela Cruz", "identifier": POSITIVE_SUBJECT}, - "requester": { - "name": "Citizen portal BFF", - "purpose": PURPOSES["citizen_self_service"], - }, - "steps": [ - { - "id": "discover", - "label": "Discover citizen claims", - "prompt": "Read the CRA and NIA catalogues.", - "button": "Discover", - "request_summary": "GET /v1/claims from CRA and NIA.", - }, - { - "id": "positive", - "label": "Evaluate citizen status", - "prompt": "Evaluate the two source-owned predicates.", - "button": "Evaluate", - "request_summary": "POST CRA civil-link and NIA population-status claims.", - }, - { - "id": "purpose-denial", - "label": "Purpose denial", - "prompt": "Use an unsupported purpose.", - "button": "Try denial", - "request_summary": "POST the same claims with an unsupported Data-Purpose header.", - }, - ], - "receipt": [ - { - "label": "Credential", - "value": "NIA citizen population-status SD-JWT VC preview", - }, - {"label": "Raw rows copied", "value": "No"}, - ], - } + return {"id": SCENARIO_ID, "title": "Citizen self-service", "short_title": "Citizen self-service", "proves": "The portal can present separate CRA and NIA signed evidence through one Evidence service.", "domain": "Citizen services", "availability": "local", "intro": "A signed-in citizen previews minimized evidence.", "actor": "Citizen Services Portal", "subject": {"name": "Elena Dela Cruz", "identifier": POSITIVE_SUBJECT}, "requester": {"name": "Citizen portal BFF", "purpose": PURPOSES["citizen_self_service"]}, "steps": [{"id": "discover", "label": "Discover citizen requirements", "prompt": "Read the Evidence definitions.", "button": "Discover", "request_summary": "GET /v1/evidence-definitions"}, {"id": "positive", "label": "Evaluate citizen status", "prompt": "Evaluate CRA and NIA requirements.", "button": "Evaluate", "request_summary": "POST two Evidence requirements."}, {"id": "purpose-denial", "label": "Purpose denial", "prompt": "Use an unsupported purpose.", "button": "Try denial", "request_summary": "POST with an unsupported purpose."}], "receipt": [{"label": "Artifact", "value": "Two signed Evidence JWS assertions"}, {"label": "Raw rows copied", "value": "No"}]} def preview_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: @@ -117,193 +29,57 @@ def run_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: def _request(config: dict[str, Any], step_id: str, *, send: bool) -> dict[str, Any]: - subject = {"positive": POSITIVE_SUBJECT, "purpose-denial": POSITIVE_SUBJECT}.get( - step_id - ) - if step_id != "discover" and not subject: + if step_id not in {"discover", "positive", "purpose-denial"}: return standard_error_result(step_id) - purpose = request_purpose(config, step_id) - requests = authority_requests(step_id, subject, purpose) - preview = combined_request_preview(requests, purpose) + token = service_token("cra-citizen") if send else "" + purpose = "unsupported-demo-purpose" if step_id == "purpose-denial" else str(config.get("purpose_override") or PURPOSES["citizen_self_service"]) + requests = _requests(step_id, POSITIVE_SUBJECT, purpose, token) + preview = _preview(requests, purpose) if not send: return {"request_source": preview} - - missing = next( - (request for request in requests if not service_token(request["client_id"])), - None, - ) - if missing: - result = missing_runtime_token( - step_id, - SERVICE_NAME, - service_token_env(missing["client_id"]), - preview, - ) - result["request_sources"] = [request["source"] for request in requests] - return result - - responses: list[tuple[dict[str, Any], StepHttpResult]] = [] - for authority_request in requests: - result = http_json( - authority_request["method"], - authority_request["url"], - authority_request["headers"], - authority_request["body"], - ) - responses.append((authority_request, result)) - - aggregate = aggregate_response(responses) - payload: dict[str, Any] = { - "step_id": step_id, - "friendly": friendly_result(step_id, aggregate, FRIENDLY), - "request_source": preview, - "request_sources": [request["source"] for request, _ in responses], - "response_source": source_response(aggregate), - "source_trace": [ - authority_trace(request, response) for request, response in responses - ], - } - if step_id in CREDENTIAL_STEPS and aggregate.status == 200: - nia_result = next( - response - for request, response in responses - if request["client_id"] == NIA_CLIENT - ) - payload.update( - credential_attempt( - service_url(NIA_CLIENT, "/v1/credentials"), - service_token(NIA_CLIENT), - purpose, - nia_result, - CREDENTIAL_PROFILE, - NIA_CLAIMS, - authority_service_id(NIA_CLIENT), - ) - ) - return payload + if not token: + return missing_runtime_token(step_id, SERVICE_NAME, service_token_env("cra-citizen"), preview) + responses = [(item, normalized_evidence_result(http_json(item["method"], item["url"], item["headers"], item["body"]), request=item["body"], service_id=item["client_id"])) for item in requests] + aggregate = _aggregate(responses) + projection = safe_evidence_projection(aggregate) + return {"step_id": step_id, "friendly": friendly_result(step_id, aggregate, FRIENDLY), "request_source": preview, "request_sources": [item["source"] for item, _ in responses], "response_source": source_response(aggregate), "source_trace": [_source_trace(item["client_id"], response) for item, response in responses], **projection} -def authority_requests( - step_id: str, subject: str | None, purpose: str -) -> list[dict[str, Any]]: +def _requests(step_id: str, subject: str, purpose: str, token: str) -> list[dict[str, Any]]: if step_id == "discover": - return [ - build_request(CRA_CLIENT, "GET", "/v1/claims", purpose), - build_request(NIA_CLIENT, "GET", "/v1/claims", purpose), - ] - if step_id in {"positive", "purpose-denial"}: - return [ - build_request( - CRA_CLIENT, "POST", "/v1/evaluations", purpose, subject, CRA_CLAIMS - ), - build_request( - NIA_CLIENT, - "POST", - "/v1/evaluations", - purpose, - subject, - NIA_CLAIMS, - ), - ] - return [] + url = service_url("cra-citizen", "/v1/evidence-definitions") + headers = evidence_headers(token, discover=True) + return [{"client_id": "cra-citizen", "method": "GET", "url": url, "headers": headers, "body": None, "source": request_source("GET", url, headers)}] + items = [] + for client in CLIENTS: + url = service_url(client) + headers = evidence_headers(token) + body = evidence_body(subject, requirement_id(client), purpose) + items.append({"client_id": client, "method": "POST", "url": url, "headers": headers, "body": body, "source": request_source("POST", url, headers, body)}) + return items -def build_request( - client_id: str, - method: str, - path: str, - purpose: str, - subject: str | None = None, - claims: list[str] | None = None, - response_format: str = CLAIM_RESULT_FORMAT, -) -> dict[str, Any]: - token = service_token(client_id) - url = service_url(client_id, path) - headers = auth_headers( - token, purpose, response_format if method == "POST" else "application/json" - ) - body = ( - evaluation_body( - subject or "", claims or [], scheme="solmara_uin", format=response_format - ) - if method == "POST" - else None - ) - return { - "client_id": client_id, - "method": method, - "url": url, - "headers": headers, - "body": body, - "source": request_source(method, url, headers, body), - } +def _preview(requests: list[dict[str, Any]], purpose: str) -> dict[str, Any]: + return requests[0]["source"] if len(requests) == 1 else {"method": "MULTI", "url": "solmara://authority-evidence", "purpose": purpose, "requests": [item["source"] for item in requests]} -def combined_request_preview( - requests: list[dict[str, Any]], purpose: str -) -> dict[str, Any]: - return { - "method": "MULTI", - "url": "solmara://authority-notaries", - "headers": {"Data-Purpose": purpose}, - "requests": [request["source"] for request in requests], - } +def _source_trace(service_id: str, response: StepHttpResult) -> dict[str, Any]: + config = requirement_config(service_id) + return {"authority": config["name"], "service_id": authority_service_id(service_id), "issuer": config["issuer"], "provider": config["provider"], "source": config["source"], "status": response.status} -def aggregate_response( - responses: list[tuple[dict[str, Any], StepHttpResult]], -) -> StepHttpResult: - failed = next( - ( - response - for _, response in responses - if response.status is None or not 200 <= response.status < 300 - ), - None, - ) +def _aggregate(responses: list[tuple[dict[str, Any], StepHttpResult]]) -> StepHttpResult: + failed = next((response for _, response in responses if response.status is None or not 200 <= response.status < 300), None) if failed: return failed - results: list[dict[str, Any]] = [] - for request, response in responses: - body = response.body if isinstance(response.body, dict) else {} - raw_results = body.get("results") - if request["method"] == "POST" and not isinstance(raw_results, list): - return StepHttpResult( - 502, - { - "code": "authority.invalid_response", - "detail": "An authority response omitted claim results.", - }, - {}, - ) - for result in raw_results or []: - if isinstance(result, dict): - results.append( - { - **result, - "authority": AUTHORITY_NAMES[request["client_id"]], - "notary_service_id": authority_service_id(request["client_id"]), - } - ) - return StepHttpResult( - 200, {"results": results}, {"content-type": "application/json"} - ) - - -def authority_trace( - request: dict[str, Any], response: StepHttpResult -) -> dict[str, Any]: - return { - "authority": AUTHORITY_NAMES[request["client_id"]], - "service_id": authority_service_id(request["client_id"]), - "request_source": request["source"], - "response_source": source_response(response), - } - - -def request_purpose(config: dict[str, Any], step_id: str) -> str: - if step_id == "purpose-denial": - return "https://id.registrystack.org/solmara/purpose/unsupported-demo-purpose" - if isinstance(config.get("purpose_override"), str): - return config["purpose_override"] - return PURPOSES["citizen_self_service"] + results = [] + presentations = [] + for _, response in responses: + presentation = response.body.get("presentation") + if isinstance(presentation, dict): + presentations.append(presentation) + results.extend( + {**entry, "presentation": presentation} + for entry in response.body.get("results", []) + ) + return StepHttpResult(200, {"results": results, "presentations": presentations}, {"content-type": "application/json"}) diff --git a/scenarios/common.py b/scenarios/common.py index bc6ee46..bbc377d 100644 --- a/scenarios/common.py +++ b/scenarios/common.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Shared helpers for Solmara Lab guided scenarios.""" +"""Registry Evidence and Mint helpers for the guided Solmara scenarios.""" from __future__ import annotations @@ -7,33 +7,50 @@ import hashlib import json import os +import re +import secrets +import ssl +import threading import time import urllib.error +import urllib.parse import urllib.request import uuid from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any from urllib.parse import urljoin -if TYPE_CHECKING: - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - - -CLAIM_RESULT_FORMAT = "application/vnd.registry-notary.claim-result+json" +EVIDENCE_JWS_MEDIA_TYPE = "application/jose+json" +EVIDENCE_JWKS_MEDIA_TYPE = "application/jwk-set+json" +EVIDENCE_JWS_HEADER = { + "alg": "ES256", + "typ": "evidence+jws", + "cty": "application/evidence+json", +} +EVIDENCE_AUDIENCE = "https://id.registrystack.org/solmara/audience/demo-client" +MAX_JWKS_BYTES = 64 * 1024 +MAX_JWKS_KEYS = 8 +JWKS_CACHE_SECONDS = 60 +ASSERTION_CLOCK_SKEW_SECONDS = 30 CHILD_BENEFIT_AS_OF_DATE = "2026-07-14" -SD_JWT_VC_FORMAT = "application/dc+sd-jwt" -HOLDER_PROOF_TYP = "kb+jwt" -HOLDER_PROOF_ALG = "EdDSA" -HOLDER_PROOF_LIFETIME_SECONDS = 60 PURPOSES = { - "child_benefit": "https://id.registrystack.org/solmara/purpose/child-benefit-review", - "pension_payment": "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "survivor_benefit": "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination", - "voucher": "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", - "livestock": "https://id.registrystack.org/solmara/purpose/livestock-movement-control", - "citizen_self_service": "https://id.registrystack.org/solmara/purpose/citizen-self-service", + "child_benefit": "child-benefit-review", + "pension_payment": "pension-payment-review", + "survivor_benefit": "survivor-benefit-determination", + "voucher": "voucher-eligibility-review", + "livestock": "livestock-movement-control", + "citizen_self_service": "citizen-self-service", } +_TOKEN_LOCK = threading.Lock() +_TOKEN_CACHE: tuple[str, float] = ("", 0.0) +_JWKS_LOCK = threading.Lock() +_JWKS_CACHE: dict[str, tuple[float, tuple[dict[str, str], ...]]] = {} +_B64URL_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") +_KID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$") + @dataclass class StepHttpResult: @@ -41,128 +58,61 @@ class StepHttpResult: body: Any headers: dict[str, str] error: str = "" + request: Any | None = None def joined_url(base: str, path: str) -> str: return urljoin(base.rstrip("/") + "/", path.lstrip("/")) -def env_url(env_name: str, default: str, path: str) -> str: - return joined_url(os.environ.get(env_name, default), path) - - -def request_source(method: str, url: str, headers: dict[str, str], body: Any | None = None) -> dict[str, Any]: - source_headers = dict(headers) - if body is not None: - source_headers.setdefault("Content-Type", "application/json") - source: dict[str, Any] = {"method": method, "url": url, "headers": redact_headers(source_headers)} - if body is not None: - source["body"] = body - return source - - -def redact_headers(headers: dict[str, str]) -> dict[str, str]: - redacted = dict(headers) - for key in list(redacted): - if key.lower() == "authorization": - redacted[key] = "Bearer [runtime token hidden]" if redacted[key] else "Bearer [runtime token missing]" - if key.lower() == "x-api-key": - redacted[key] = "[runtime token hidden]" if redacted[key] else "[runtime token missing]" - return redacted - - -def auth_headers(token: str, purpose: str, accept: str = "application/json") -> dict[str, str]: - return {"x-api-key": token, "Accept": accept, "Data-Purpose": purpose} - - -def evaluation_body( - subject: str, - claim_ids: list[str], - *, - scheme: str, - disclosure: str = "predicate", - format: str = CLAIM_RESULT_FORMAT, - variables: dict[str, Any] | None = None, -) -> dict[str, Any]: - body = { - "target": {"type": "Person", "identifiers": [{"scheme": scheme, "value": subject}]}, - "claims": claim_ids, - "disclosure": disclosure, - "format": format, - } - if variables is not None: - body["variables"] = variables - return body - - def b64url_nopad(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") -def json_b64url(value: Any) -> str: - return b64url_nopad(json.dumps(value, separators=(",", ":")).encode("utf-8")) +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) -@dataclass -class HolderKeypair: - """An ephemeral did:jwk holder identity used to prove possession at credential issuance.""" - - holder_id: str - private_key: "Ed25519PrivateKey" - - -def holder_keypair() -> HolderKeypair: - """Generate a fresh Ed25519 keypair and derive its did:jwk holder id. +def request_source( + method: str, + url: str, + headers: dict[str, str], + body: Any | None = None, +) -> dict[str, Any]: + parsed_url = urllib.parse.urlsplit(url) + source: dict[str, Any] = { + "method": method, + "url": parsed_url._replace(query="", fragment="").geturl(), + "headers": redact_headers(headers), + } + return source - The private key never leaves this process; only the public JWK is encoded - into the did:jwk identifier that gets sent to the notary. - """ - # Imported here, not at module level: preview-only consumers (the smoke - # story previews) run under the system Python without the cryptography - # package, and only real signing needs it. - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat - private_key = Ed25519PrivateKey.generate() - public_bytes = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) - public_jwk = {"kty": "OKP", "crv": "Ed25519", "x": b64url_nopad(public_bytes)} - holder_id = f"did:jwk:{json_b64url(public_jwk)}" - return HolderKeypair(holder_id=holder_id, private_key=private_key) +def redact_headers(headers: dict[str, str]) -> dict[str, str]: + redacted: dict[str, str] = {} + for key, value in headers.items(): + lowered = key.lower() + if lowered == "authorization": + redacted[key] = "Bearer [runtime token hidden]" + elif lowered == "x-api-key": + redacted[key] = "[runtime token hidden]" + elif lowered in {"accept", "content-type"}: + redacted[key] = value + return redacted -def holder_proof( - keypair: HolderKeypair, - *, - audience: str, - evaluation_id: str, - credential_profile: str, - disclosure: str, - claim_ids: list[str], -) -> str: - """Sign a holder key-binding proof JWT for a credential issuance request. - - A fresh jti is minted on every call so repeated runs stay replay-safe. - """ - now = int(time.time()) - header = {"alg": HOLDER_PROOF_ALG, "typ": HOLDER_PROOF_TYP, "kid": keypair.holder_id} - disclosure_hash = b64url_nopad(hashlib.sha256(disclosure.encode("utf-8")).digest()) - payload = { - "sub": keypair.holder_id, - "aud": audience, - "iat": now, - "exp": now + HOLDER_PROOF_LIFETIME_SECONDS, - "jti": str(uuid.uuid4()), - "evaluation_id": evaluation_id, - "credential_profile": credential_profile, - "disclosure": disclosure_hash, - "claims": claim_ids, - } - signing_input = f"{json_b64url(header)}.{json_b64url(payload)}" - signature = keypair.private_key.sign(signing_input.encode("ascii")) - return f"{signing_input}.{b64url_nopad(signature)}" +def tls_context() -> ssl.SSLContext | None: + ca_bundle = os.environ.get("SOLMARA_EVIDENCE_CA_BUNDLE") + return ssl.create_default_context(cafile=ca_bundle) if ca_bundle else None -def http_json(method: str, url: str, headers: dict[str, str], body: Any | None = None, timeout: float = 8.0) -> StepHttpResult: +def http_json( + method: str, + url: str, + headers: dict[str, str], + body: Any | None = None, + timeout: float = 8.0, +) -> StepHttpResult: data = None request_headers = dict(headers) if body is not None: @@ -170,223 +120,574 @@ def http_json(method: str, url: str, headers: dict[str, str], body: Any | None = request_headers.setdefault("Content-Type", "application/json") request = urllib.request.Request(url, headers=request_headers, data=data, method=method) try: - with urllib.request.urlopen(request, timeout=timeout) as response: - raw = response.read() - return StepHttpResult(response.status, parse_body(raw), {key.lower(): value for key, value in response.headers.items()}) + with urllib.request.urlopen(request, timeout=timeout, context=tls_context()) as response: + return StepHttpResult( + response.status, + parse_body(response.read()), + {key.lower(): value for key, value in response.headers.items()}, + request=body, + ) except urllib.error.HTTPError as error: - return StepHttpResult(error.code, parse_body(error.read()), {key.lower(): value for key, value in error.headers.items()}) - except Exception as error: - return StepHttpResult(None, {}, {}, error.__class__.__name__) + return StepHttpResult( + error.code, + parse_body(error.read()), + {key.lower(): value for key, value in error.headers.items()}, + request=body, + ) + except Exception as error: # the guided UI reports a value-free class only + return StepHttpResult(None, {}, {}, error.__class__.__name__, body) def parse_body(raw: bytes) -> Any: if not raw: return {} try: - return json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): + return json.loads(raw.decode("utf-8"), object_pairs_hook=_closed_json_object) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError): return raw.decode("utf-8", errors="replace") +def _closed_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, member in pairs: + if key in value: + raise ValueError("duplicate JSON member") + value[key] = member + return value + + def source_response(result: StepHttpResult) -> dict[str, Any]: + if result.status is None: + code = "transport_unavailable" + elif 200 <= result.status < 300: + code = "ok" + elif result.status == 502 and isinstance(result.body, dict) and result.body.get("code") == "evidence.invalid_response": + code = "assertion_verification_failed" + elif 400 <= result.status < 500: + code = "request_refused" + else: + code = "service_unavailable" + return {"status": result.status, "code": code} + + +def _client_assertion(client_id: str, key_path: str, audience: str) -> str: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec, utils + + jwk = json.loads(Path(key_path).read_text(), object_pairs_hook=_closed_json_object) + if ( + not isinstance(jwk, dict) + or jwk.get("kty") != "EC" + or jwk.get("crv") != "P-256" + or jwk.get("alg") != "ES256" + or not isinstance(jwk.get("kid"), str) + or not _KID_PATTERN.fullmatch(jwk["kid"]) + ): + raise ValueError("invalid client assertion key") + scalar = b64url_decode(jwk.get("d", "")) + if len(scalar) != 32: + raise ValueError("invalid client assertion key") + private_key = ec.derive_private_key(int.from_bytes(scalar, "big"), ec.SECP256R1()) + numbers = private_key.public_key().public_numbers() + if ( + b64url_nopad(numbers.x.to_bytes(32, "big")) != jwk.get("x") + or b64url_nopad(numbers.y.to_bytes(32, "big")) != jwk.get("y") + ): + raise ValueError("invalid client assertion key") + now = int(time.time()) + header = {"alg": "ES256", "typ": "JWT", "kid": jwk["kid"]} + claims = { + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + 120, + "jti": str(uuid.uuid4()), + } + signing_input = ".".join( + b64url_nopad(json.dumps(value, separators=(",", ":")).encode()) + for value in (header, claims) + ) + der_signature = private_key.sign(signing_input.encode("ascii"), ec.ECDSA(hashes.SHA256())) + r_value, s_value = utils.decode_dss_signature(der_signature) + signature = r_value.to_bytes(32, "big") + s_value.to_bytes(32, "big") + return f"{signing_input}.{b64url_nopad(signature)}" + + +def evidence_access_token() -> str: + """Obtain and briefly cache a Mint token using private_key_jwt.""" + static = os.environ.get("SOLMARA_EVIDENCE_ACCESS_TOKEN", "") + if static: + return static + mint_url = os.environ.get("SOLMARA_MINT_URL", "") + assertion_audience = os.environ.get("SOLMARA_MINT_ASSERTION_AUDIENCE", "") + client_id = os.environ.get("SOLMARA_EVIDENCE_CLIENT_ID", "") + key_path = os.environ.get("SOLMARA_EVIDENCE_CLIENT_KEY", "") + if not mint_url or not client_id or not key_path or not Path(key_path).is_file(): + return "" + global _TOKEN_CACHE + with _TOKEN_LOCK: + if _TOKEN_CACHE[0] and _TOKEN_CACHE[1] > time.time() + 10: + return _TOKEN_CACHE[0] + token_url = joined_url(mint_url, "/token") + try: + assertion = _client_assertion( + client_id, key_path, assertion_audience or token_url + ) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return "" + form = urllib.parse.urlencode( + { + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + } + ).encode() + request = urllib.request.Request( + token_url, + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=8, context=tls_context()) as response: + payload = json.loads(response.read()) + except Exception: + return "" + token = payload.get("access_token", "") + lifetime = int(payload.get("expires_in", 300)) + if isinstance(token, str) and token: + _TOKEN_CACHE = (token, time.time() + lifetime) + return token + return "" + + +def evidence_headers(token: str, *, discover: bool = False) -> dict[str, str]: return { - "status": result.status, - "headers": {key: value for key, value in result.headers.items() if key in {"content-type", "www-authenticate"}}, - "body": result.body, - "error": result.error, + "Authorization": f"Bearer {token}", + "Accept": "application/json" if discover else EVIDENCE_JWS_MEDIA_TYPE, } -def credential_attempt( - url: str, - token: str, +def evidence_body( + subject: str, + requirement: str, purpose: str, - evaluation_result: StepHttpResult, - profile: str, - claim_ids: list[str], - service_id: str, *, - disclosure: str = "predicate", + selector_profile: str = "solmara-uin-v1", + selector_field: str = "uin", ) -> dict[str, Any]: - evaluation_id = first_evaluation_id(evaluation_result.body) - if not evaluation_id: - return { - "credential": { - "status": "not_attempted", - "profile": profile, - "format": SD_JWT_VC_FORMAT, - "reason": "evaluation_id_missing", - "message": "The notary did not return an evaluation id for credential issuance.", - } - } - keypair = holder_keypair() - proof = holder_proof( - keypair, - audience=service_id, - evaluation_id=evaluation_id, - credential_profile=profile, - disclosure=disclosure, - claim_ids=claim_ids, - ) - body = { - "evaluation_id": evaluation_id, - "credential_profile": profile, - "format": SD_JWT_VC_FORMAT, - "claims": claim_ids, - "disclosure": disclosure, - "purpose": purpose, - "holder": {"binding": "did", "id": keypair.holder_id, "proof": proof}, - } - headers = auth_headers(token, purpose, "application/json") - request = request_source("POST", url, headers, body) - result = http_json("POST", url, headers, body) return { - "credential_source": request, - "credential_response_source": source_response(result), - "credential": credential_summary(profile, keypair.holder_id, result), + "requestNonce": b64url_nopad(secrets.token_bytes(32)), + "requirement": requirement, + "purpose": purpose, + "subjects": [ + { + "role": "subject", + "selector": { + "profile": selector_profile, + "values": {selector_field: subject}, + }, + } + ], } -def first_evaluation_id(body: Any) -> str | None: - if not isinstance(body, dict): - return None - results = body.get("results") - if not isinstance(results, list): - return None - for result in results: - if isinstance(result, dict) and isinstance(result.get("evaluation_id"), str): - return result["evaluation_id"] - return None +def _invalid_evidence_result() -> StepHttpResult: + return StepHttpResult( + 502, + { + "code": "evidence.invalid_response", + "detail": "The Evidence assertion could not be verified.", + }, + {}, + ) -def sd_jwt_vct(credential: Any) -> str | None: - """Read the `vct` claim from a compact SD-JWT. +def _decode_segment(value: Any) -> bytes: + if not isinstance(value, str) or not value or not _B64URL_PATTERN.fullmatch(value): + raise ValueError("invalid JWS segment") + decoded = b64url_decode(value) + if b64url_nopad(decoded) != value: + raise ValueError("noncanonical JWS segment") + return decoded - The issuance response body carries no top-level vct; it only exists inside - the signed payload, so decode it here for display. Returns None when the - credential is absent or not decodable. - """ - if not isinstance(credential, str): + +def _json_segment(value: Any) -> dict[str, Any]: + decoded = _decode_segment(value) + parsed = json.loads(decoded, object_pairs_hook=_closed_json_object) + if not isinstance(parsed, dict): + raise ValueError("JWS segment is not an object") + return parsed + + +def _jwk_thumbprint(jwk: dict[str, Any]) -> str: + canonical = json.dumps( + {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"], "y": jwk["y"]}, + separators=(",", ":"), + sort_keys=True, + ).encode() + return b64url_nopad(hashlib.sha256(canonical).digest()) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): return None - segments = credential.split("~")[0].split(".") - if len(segments) != 3: + + +def _fetch_jwks(url: str) -> tuple[dict[str, str], ...]: + request = urllib.request.Request( + url, + headers={"Accept": EVIDENCE_JWKS_MEDIA_TYPE}, + method="GET", + ) + handlers: list[Any] = [_NoRedirect()] + context = tls_context() + if context is not None: + handlers.append(urllib.request.HTTPSHandler(context=context)) + opener = urllib.request.build_opener(*handlers) + with opener.open(request, timeout=5) as response: + if response.status != 200 or response.headers.get_content_type() != EVIDENCE_JWKS_MEDIA_TYPE: + raise ValueError("invalid JWKS response") + raw = response.read(MAX_JWKS_BYTES + 1) + if len(raw) > MAX_JWKS_BYTES: + raise ValueError("JWKS response is too large") + document = json.loads(raw, object_pairs_hook=_closed_json_object) + if not isinstance(document, dict) or set(document) != {"keys"}: + raise ValueError("invalid JWKS document") + keys = document["keys"] + if not isinstance(keys, list) or not 1 <= len(keys) <= MAX_JWKS_KEYS: + raise ValueError("invalid JWKS key count") + validated: list[dict[str, str]] = [] + seen: set[str] = set() + expected_members = {"kty", "kid", "alg", "crv", "x", "y"} + for key in keys: + if not isinstance(key, dict) or set(key) != expected_members: + raise ValueError("invalid JWK") + if key.get("kty") != "EC" or key.get("crv") != "P-256" or key.get("alg") != "ES256": + raise ValueError("invalid JWK type") + if not all(isinstance(key.get(member), str) for member in expected_members): + raise ValueError("invalid JWK member") + if len(_decode_segment(key["x"])) != 32 or len(_decode_segment(key["y"])) != 32: + raise ValueError("invalid JWK coordinate") + if not _KID_PATTERN.fullmatch(key["kid"]) or _jwk_thumbprint(key) != key["kid"]: + raise ValueError("invalid JWK kid") + if key["kid"] in seen: + raise ValueError("duplicate JWK kid") + seen.add(key["kid"]) + validated.append(key) + return tuple(validated) + + +def _authority_jwks(service_id: str) -> tuple[dict[str, str], ...]: + from .service_config import service_url + + url = service_url(service_id, "/.well-known/evidence/jwks.json") + now = time.monotonic() + with _JWKS_LOCK: + cached = _JWKS_CACHE.get(url) + if cached and cached[0] > now: + return cached[1] + keys = _fetch_jwks(url) + with _JWKS_LOCK: + _JWKS_CACHE[url] = (time.monotonic() + JWKS_CACHE_SECONDS, keys) + return keys + + +def _verify_es256(protected: str, payload: str, signature: str, jwk: dict[str, str]) -> None: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature + + raw_signature = _decode_segment(signature) + if len(raw_signature) != 64: + raise ValueError("invalid ES256 signature") + x = int.from_bytes(_decode_segment(jwk["x"]), "big") + y = int.from_bytes(_decode_segment(jwk["y"]), "big") + public_key = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key() + der_signature = encode_dss_signature( + int.from_bytes(raw_signature[:32], "big"), + int.from_bytes(raw_signature[32:], "big"), + ) + public_key.verify( + der_signature, + f"{protected}.{payload}".encode("ascii"), + ec.ECDSA(hashes.SHA256()), + ) + + +def _parse_evidence_time(value: Any) -> datetime: + if not isinstance(value, str) or not value: + raise ValueError("invalid Evidence time") + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("Evidence time has no timezone") + return parsed.astimezone(timezone.utc) + + +def _valid_uri(value: Any) -> bool: + return isinstance(value, str) and 1 <= len(value) <= 512 and bool(urllib.parse.urlsplit(value).scheme) + + +def _validate_evidence_payload(payload: dict[str, Any], request: dict[str, Any], config: dict[str, Any]) -> None: + required = { + "schema", "assuranceProfile", "subjectBinding", "requestNonce", "id", "type", + "supportsRequirement", "isConformantTo", "issuedBy", "providedBy", "issuedAt", + "observedAt", "validUntil", "purpose", "audience", "configurationRevision", + "subjects", "supportedValues", + } + if set(payload) != required: + raise ValueError("invalid Evidence members") + expected_audience = os.environ.get("SOLMARA_EVIDENCE_AUDIENCE", EVIDENCE_AUDIENCE) + if ( + payload.get("schema") != "registry.assertion-evidence/v1" + or payload.get("assuranceProfile") != "production" + or payload.get("subjectBinding") != "audience-scoped" + or payload.get("type") != "Evidence" + or payload.get("supportsRequirement") != request.get("requirement") + or payload.get("isConformantTo") != config["evidence_type"] + or payload.get("purpose") != request.get("purpose") + or payload.get("requestNonce") != request.get("requestNonce") + or payload.get("issuedBy") != config["issuer"] + or payload.get("providedBy") != config["provider"] + or payload.get("audience") != expected_audience + ): + raise ValueError("Evidence policy mismatch") + for member in ("id", "supportsRequirement", "isConformantTo", "issuedBy", "providedBy", "audience"): + if not _valid_uri(payload.get(member)): + raise ValueError("invalid Evidence URI") + revision = payload.get("configurationRevision") + if not isinstance(revision, str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", revision): + raise ValueError("invalid configuration revision") + purpose = payload.get("purpose") + if not isinstance(purpose, str) or not re.fullmatch(r"[a-z][a-z0-9._:-]{0,127}", purpose): + raise ValueError("invalid Evidence purpose") + nonce = payload.get("requestNonce") + if not isinstance(nonce, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", nonce): + raise ValueError("invalid Evidence nonce") + subjects = payload.get("subjects") + if not isinstance(subjects, list) or len(subjects) != 1: + raise ValueError("invalid Evidence subjects") + subject_pairs: set[tuple[str, str]] = set() + for subject in subjects: + if not isinstance(subject, dict) or set(subject) != {"role", "binding"}: + raise ValueError("invalid Evidence subject") + role, binding = subject.get("role"), subject.get("binding") + if not isinstance(role, str) or not re.fullmatch(r"[a-z][a-z0-9._-]{0,63}", role): + raise ValueError("invalid Evidence subject role") + if role != "subject": + raise ValueError("unexpected Evidence subject role") + if not isinstance(binding, str) or not re.fullmatch(r"urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}", binding): + raise ValueError("invalid Evidence subject binding") + if (role, binding) in subject_pairs: + raise ValueError("duplicate Evidence subject") + subject_pairs.add((role, binding)) + supported = payload.get("supportedValues") + if not isinstance(supported, list) or not 1 <= len(supported) <= 16: + raise ValueError("invalid supported values") + concepts: list[str] = [] + for entry in supported: + if not isinstance(entry, dict) or set(entry) != {"providesValueFor", "value"}: + raise ValueError("invalid supported value") + concept = entry.get("providesValueFor") + if not _valid_uri(concept) or not isinstance(entry.get("value"), bool): + raise ValueError("invalid supported value") + concepts.append(str(concept)) + expected_concepts = tuple( + f"https://id.registrystack.org/solmara/concept/{concept}" + for concept in config["concepts"] + ) + if tuple(concepts) != expected_concepts or len(concepts) != len(set(concepts)): + raise ValueError("Evidence output mismatch") + issued = _parse_evidence_time(payload.get("issuedAt")) + observed = _parse_evidence_time(payload.get("observedAt")) + valid_until = _parse_evidence_time(payload.get("validUntil")) + now = datetime.now(timezone.utc) + skew = timedelta(seconds=ASSERTION_CLOCK_SKEW_SECONDS) + if ( + issued < observed + or valid_until <= observed + or valid_until <= issued + or valid_until - issued > timedelta(seconds=config["maximum_validity_seconds"]) + or issued > now + skew + or observed > now + skew + or now >= valid_until + skew + ): + raise ValueError("invalid Evidence validity") + + +def decoded_evidence_payload(body: Any) -> dict[str, Any] | None: + if not isinstance(body, dict) or set(body) != {"protected", "payload", "signature"}: return None try: - payload = json.loads(base64.urlsafe_b64decode(segments[1] + "=" * (-len(segments[1]) % 4))) - except (ValueError, UnicodeDecodeError): + return _json_segment(body["payload"]) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): return None - vct = payload.get("vct") if isinstance(payload, dict) else None - return vct if isinstance(vct, str) else None -def credential_summary(profile: str, holder_id: str, result: StepHttpResult) -> dict[str, Any]: - body = result.body if isinstance(result.body, dict) else {} - if result.status and 200 <= result.status < 300: - credential = body.get("credential") - preview = f"{credential[:48]}..." if isinstance(credential, str) and len(credential) > 48 else credential - disclosures = body.get("disclosures") - return { - "status": "issued", - "profile": body.get("credential_profile", profile), - "format": body.get("format", SD_JWT_VC_FORMAT), - "vct": sd_jwt_vct(credential), - "issuer": body.get("issuer"), - "credential_id": body.get("credential_id"), - "expires_at": body.get("expires_at"), - "holder_id": holder_id, - "disclosures": len(disclosures) if isinstance(disclosures, list) else 0, - "compact_preview": preview, - } - reason = body.get("code") or body.get("error") or result.error or f"HTTP {result.status}" - message = body.get("detail") or body.get("message") or body.get("title") or "Credential issuance did not complete." - return { - "status": "not_issued", - "profile": profile, - "format": SD_JWT_VC_FORMAT, - "reason": reason, - "http_status": result.status, - "message": message, +def normalized_evidence_result( + result: StepHttpResult, + *, + request: dict[str, Any] | None = None, + service_id: str | None = None, +) -> StepHttpResult: + """Verify a signed assertion, then add the UI's small predicate summary.""" + if result.status is None or not 200 <= result.status < 300: + return result + retained_request = request if request is not None else result.request + if not isinstance(retained_request, dict): + return _invalid_evidence_result() + try: + from .service_config import config_for_requirement, requirement_config + + config = requirement_config(service_id) if service_id else config_for_requirement(str(retained_request.get("requirement", ""))) + if retained_request.get("requirement") != config["requirement"]: + raise ValueError("request requirement mismatch") + content_type = result.headers.get("content-type", "").strip().lower() + if content_type != EVIDENCE_JWS_MEDIA_TYPE: + raise ValueError("invalid Evidence media type") + body = result.body + if not isinstance(body, dict) or set(body) != {"protected", "payload", "signature"}: + raise ValueError("invalid flattened JWS") + protected = _json_segment(body["protected"]) + if set(protected) != {"alg", "kid", "typ", "cty"}: + raise ValueError("invalid protected header") + if {key: protected.get(key) for key in EVIDENCE_JWS_HEADER} != EVIDENCE_JWS_HEADER: + raise ValueError("invalid Evidence protected header") + kid = protected.get("kid") + if not isinstance(kid, str) or not _KID_PATTERN.fullmatch(kid): + raise ValueError("invalid Evidence kid") + key = next((candidate for candidate in _authority_jwks(config["service_id"]) if candidate["kid"] == kid), None) + if key is None: + raise ValueError("untrusted Evidence kid") + _verify_es256(body["protected"], body["payload"], body["signature"], key) + payload = _json_segment(body["payload"]) + _validate_evidence_payload(payload, retained_request, config) + except Exception: + return _invalid_evidence_result() + results = [] + for entry in payload["supportedValues"]: + concept = entry["providesValueFor"] + value = entry["value"] + results.append( + { + "claim_id": concept.rsplit("/", 1)[-1], + "concept_id": concept, + "satisfied": value if isinstance(value, bool) else None, + "value": value, + } + ) + presentation = { + "authority": config["name"], + "issuer": config["issuer"], + "provider": config["provider"], + "source": config["source"], } + return StepHttpResult( + result.status, + {"results": results, "assertion": payload, "signed_evidence": result.body, "presentation": presentation}, + result.headers, + result.error, + ) -def friendly_result(step_id: str, result: StepHttpResult, copy: dict[str, dict[str, tuple[str, str]]] | None = None) -> dict[str, Any]: - """Civilian-language summary of a step outcome. +def safe_evidence_projection(result: StepHttpResult) -> dict[str, Any]: + """Return only verified concepts and public authority attribution for a UI.""" + if result.status is None or not 200 <= result.status < 300: + return {"results": [], "presentations": []} + body = result.body if isinstance(result.body, dict) else {} + presentation = body.get("presentation") + presentations = body.get("presentations") + if not isinstance(presentations, list): + presentations = [presentation] if isinstance(presentation, dict) else [] + safe_presentations = [ + { + "authority": item.get("authority"), + "issuer": item.get("issuer"), + "provider": item.get("provider"), + "source": item.get("source"), + } + for item in presentations + if isinstance(item, dict) + and set(item) == {"authority", "issuer", "provider", "source"} + ] + safe_results: list[dict[str, Any]] = [] + for item in body.get("results", []): + if not isinstance(item, dict): + continue + value = item.get("value") + if not isinstance(value, bool): + continue + safe = { + "claim_id": item.get("claim_id"), + "concept_id": item.get("concept_id"), + "satisfied": value, + "value": value, + } + item_presentation = item.get("presentation") + if item_presentation is None and len(safe_presentations) == 1: + item_presentation = safe_presentations[0] + if ( + isinstance(item_presentation, dict) + and set(item_presentation) + == {"authority", "issuer", "provider", "source"} + ): + safe["presentation"] = { + key: item_presentation[key] + for key in ("authority", "issuer", "provider", "source") + } + safe_results.append(safe) + return {"results": safe_results, "presentations": safe_presentations} + - `copy` maps step_id -> {"met": (title, message), "unmet": (title, message)} - for outcomes the story wants to narrate; everything else falls back to - honest generic copy. A 403 with a pdp.* code is the boundary working, so - it reads as success, never as an error. - """ +def friendly_result( + step_id: str, + result: StepHttpResult, + copy: dict[str, dict[str, tuple[str, str]]] | None = None, +) -> dict[str, Any]: copy = copy or {} body = result.body if isinstance(result.body, dict) else {} raw_results = body.get("results") - results = raw_results if isinstance(raw_results, list) else None - unmet = ( - [entry.get("claim_id") for entry in results if isinstance(entry, dict) and entry.get("satisfied") is False] - if results is not None - else [] - ) - facts: list[dict[str, Any]] = [ - {"label": "HTTP status", "value": result.status if result.status is not None else "No response"} - ] - if results is not None: - facts.append({"label": "Claims met", "value": f"{len(results) - len(unmet)} of {len(results)}"}) + results = raw_results if isinstance(raw_results, list) else [] + unmet = [item.get("claim_id") for item in results if item.get("satisfied") is False] + facts = [{"label": "HTTP status", "value": result.status or "No response"}] + if results: + facts.append({"label": "Evidence values", "value": f"{len(results) - len(unmet)} of {len(results)} true"}) if result.status is None: - return { - "title": "No response from the service.", - "message": "The request could not be sent. Check that the lab stack is running.", - "status": "needs_attention", - "facts": facts, - } - code = str(body.get("code", "")) - if result.status >= 400 and "refused" in copy.get(step_id, {}): + return {"title": "No response from the service.", "message": "Check that Mint and Evidence are running.", "status": "needs_attention", "facts": facts} + if 400 <= result.status < 500 and "refused" in copy.get(step_id, {}): title, message = copy[step_id]["refused"] return {"title": title, "message": message, "status": "done", "facts": facts} - if result.status == 403 and code.startswith("pdp."): - return { - "title": "Refused, exactly as designed.", - "message": "That purpose does not permit this question. Nothing was disclosed, only a stable problem code.", - "status": "done", - "facts": facts, - } if 200 <= result.status < 300: - step_copy = copy.get(step_id, {}) - if unmet: - title, message = step_copy.get( - "unmet", - ( - "Rejected, exactly as designed.", - f"The check on {unmet[0]} came back not met, so the request stops there without exposing anything else.", - ), - ) - else: - title, message = step_copy.get( - "met", - ("Request completed.", "The response is minimized to claim results and denial codes."), - ) + key = "unmet" if unmet else "met" + default = ( + ("Evidence returned a false value.", "The signed response discloses only the reviewed concept value.") + if unmet + else ("Signed evidence returned.", "The source row stayed behind its authority's Records API.") + ) + title, message = copy.get(step_id, {}).get(key, default) return {"title": title, "message": message, "status": "done", "facts": facts} return { "title": "Request needs attention.", - "message": str(body.get("detail") or body.get("title") or "The service returned an unexpected status."), + "message": "Evidence could not complete the request.", "status": "needs_attention", "facts": facts, } -def missing_runtime_token(step_id: str, service: str, token_env: str, request: dict[str, Any]) -> dict[str, Any]: +def missing_runtime_token( + step_id: str, service: str, token_env: str, request: dict[str, Any] +) -> dict[str, Any]: return { "step_id": step_id, "friendly": { "title": f"{service} is not configured yet.", - "message": "The Solmara wave 1 flow is scaffolded. Set the service URL and token environment variables once the local stack endpoints are available.", + "message": "Set the local Mint URL and client key, then start the Evidence stack.", "status": "needs_attention", - "facts": [ - {"label": "Required token env", "value": token_env}, - {"label": "Runtime", "value": "Not called without a token"}, - ], + "facts": [{"label": "Required setting", "value": token_env}], }, "request_source": request, - "response_source": {"note": "No runtime token configured, so the request was not sent."}, + "response_source": {"status": None, "code": "not_configured"}, } diff --git a/scenarios/farmer_voucher.py b/scenarios/farmer_voucher.py index a2db5d2..ec6c3f9 100644 --- a/scenarios/farmer_voucher.py +++ b/scenarios/farmer_voucher.py @@ -1,89 +1,30 @@ #!/usr/bin/env python3 -"""Farmer climate-smart voucher guided scenario.""" +"""Farmer voucher and livestock Evidence scenario.""" from __future__ import annotations from typing import Any -from .common import CLAIM_RESULT_FORMAT, PURPOSES, auth_headers, credential_attempt, evaluation_body, friendly_result, http_json, missing_runtime_token, request_source, source_response, standard_error_result -from .service_config import service_token, service_token_env, service_url +from .common import PURPOSES, evidence_body, evidence_headers, friendly_result, http_json, missing_runtime_token, normalized_evidence_result, request_source, safe_evidence_projection, source_response, standard_error_result +from .service_config import authority_service_id, requirement_config, requirement_id, service_token, service_token_env, service_url SCENARIO_ID = "farmer-climate-smart-voucher" -SERVICE_NAME = "NAgDI Notary" -SERVICE_ID = "nagdi-notary" +SERVICE_NAME = "Registry Evidence" POSITIVE_FARMER = "FR-1001" AUTHORIZATION_CONTROL = "FR-1002" REDEEMED_CONTROL = "FR-1003" -CLAIMS = ["eligible-for-climate-smart-input-voucher"] -MOVEMENT_CLAIMS = ["eligible-for-livestock-movement-permit"] -VOUCHER_CREDENTIAL_PROFILE = "climate_smart_voucher_sd_jwt" -MOVEMENT_CREDENTIAL_PROFILE = "livestock_movement_sd_jwt" -CREDENTIAL_STEPS = {"positive", "movement-permit"} FRIENDLY = { - "discover": { - "met": ("The catalogue lists what may be asked.", "Claim definitions only. No workbook rows have moved."), - }, - "positive": { - "met": ( - "Yes. This farmer qualifies for the voucher.", - "The eligibility fact came back met. The workbook itself never left NAgDI.", - ), - "unmet": ( - "Not eligible on the facts returned.", - "The voucher eligibility check came back not met for this farmer.", - ), - }, - "authorization-control": { - "unmet": ( - "Rejected: no data-use authorization on file.", - "The eligibility check came back not met because Diego has not authorized his data to be used for this review, so no voucher is issued.", - ), - }, - "redeemed-control": { - "unmet": ( - "Rejected: already redeemed this season.", - "The eligibility check came back not met, preventing a double redemption.", - ), - }, - "movement-permit": { - "met": ( - "Yes. The movement permit can be issued.", - "The livestock movement fact came back met under its own purpose.", - ), - "unmet": ( - "No permit on the facts returned.", - "The movement-control check came back not met.", - ), - }, + "positive": {"met": ("Yes. This farmer qualifies for the voucher.", "The signed assertion carries reviewed concept values, not a workbook row.")}, + "authorization-control": {"unmet": ("Rejected: no data-use authorization on file.", "Evidence returned a signed false eligibility value.")}, + "redeemed-control": {"unmet": ("Rejected: already redeemed this season.", "The signed false value prevents double redemption.")}, + "movement-permit": {"met": ("Yes. The movement permit can be issued.", "The livestock requirement was evaluated under its own purpose.")}, + "purpose-denial": {"refused": ("Refused, exactly as designed.", "The voucher purpose cannot authorize the livestock requirement.")}, } def story() -> dict[str, Any]: - return { - "id": SCENARIO_ID, - "title": "Farmer climate-smart voucher", - "short_title": "Farmer voucher", - "proves": "NAgDI farmer and livestock workbooks can back governed voucher and movement-control APIs.", - "domain": "Agriculture", - "availability": "hosted", - "intro": "A supplier checks voucher eligibility without receiving farmer or livestock workbooks.", - "actor": "Voucher redemption desk", - "subject": {"name": "Amina Kone", "identifier": POSITIVE_FARMER}, - "requester": {"name": "NAgDI voucher desk", "purpose": PURPOSES["voucher"]}, - "steps": [ - {"id": "discover", "label": "Discover NAgDI claims", "prompt": "Read the NAgDI claim catalogue.", "button": "Discover", "request_summary": "GET /v1/claims"}, - {"id": "positive", "label": "Evaluate voucher eligibility", "prompt": "Run the positive farmer control.", "button": "Evaluate", "request_summary": "POST voucher claim for FR-1001."}, - {"id": "authorization-control", "label": "Missing authorization control", "prompt": "Reject a farmer who has not authorized this data use.", "button": "Evaluate", "request_summary": "POST voucher claim for FR-1002."}, - {"id": "redeemed-control", "label": "Already redeemed control", "prompt": "Reject an already-redeemed farmer.", "button": "Evaluate", "request_summary": "POST voucher claim for FR-1003."}, - {"id": "movement-permit", "label": "Livestock movement permit", "prompt": "Evaluate the companion movement-control claim.", "button": "Evaluate", "request_summary": "POST livestock movement-control claim."}, - {"id": "purpose-denial", "label": "Purpose denial", "prompt": "Use the wrong purpose for a movement-control request.", "button": "Try denial", "request_summary": "POST livestock claim with voucher purpose."}, - ], - "receipt": [ - {"label": "Credential", "value": "voucher eligibility VC preview"}, - {"label": "Workbook exported", "value": "No"}, - ], - } + return {"id": SCENARIO_ID, "title": "Farmer climate-smart voucher", "short_title": "Farmer voucher", "proves": "NAgDI workbooks can back governed Evidence requirements without workbook export.", "domain": "Agriculture", "availability": "local", "intro": "A supplier checks a minimized signed assertion.", "actor": "Voucher redemption desk", "subject": {"name": "Amina Kone", "identifier": POSITIVE_FARMER}, "requester": {"name": "NAgDI voucher desk", "purpose": PURPOSES["voucher"]}, "steps": [{"id": "discover", "label": "Discover NAgDI requirements", "prompt": "Read Evidence definitions.", "button": "Discover", "request_summary": "GET /v1/evidence-definitions"}, {"id": "positive", "label": "Evaluate voucher eligibility", "prompt": "Run the positive control.", "button": "Evaluate", "request_summary": "POST voucher Evidence requirement."}, {"id": "authorization-control", "label": "Missing authorization control", "prompt": "Reject missing data-use authorization.", "button": "Evaluate", "request_summary": "POST voucher Evidence requirement."}, {"id": "redeemed-control", "label": "Already redeemed control", "prompt": "Reject an already-redeemed farmer.", "button": "Evaluate", "request_summary": "POST voucher Evidence requirement."}, {"id": "movement-permit", "label": "Livestock movement permit", "prompt": "Evaluate movement control.", "button": "Evaluate", "request_summary": "POST livestock Evidence requirement."}, {"id": "purpose-denial", "label": "Purpose denial", "prompt": "Use the wrong purpose.", "button": "Try denial", "request_summary": "POST livestock requirement with voucher purpose."}], "receipt": [{"label": "Artifact", "value": "Signed Evidence JWS"}, {"label": "Workbook exported", "value": "No"}]} def preview_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: @@ -95,45 +36,26 @@ def run_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: def _request(config: dict[str, Any], step_id: str, *, send: bool) -> dict[str, Any]: - url = service_url(SERVICE_ID, "/v1/claims" if step_id == "discover" else "/v1/evaluations") - subject = { - "positive": POSITIVE_FARMER, - "authorization-control": AUTHORIZATION_CONTROL, - "redeemed-control": REDEEMED_CONTROL, - "movement-permit": POSITIVE_FARMER, - "purpose-denial": POSITIVE_FARMER, - }.get(step_id) - claims = MOVEMENT_CLAIMS if step_id in {"movement-permit", "purpose-denial"} else CLAIMS - purpose = request_purpose(config, step_id) - token = service_token(SERVICE_ID) - credential_profile = credential_profile_for_step(step_id) - headers = auth_headers(token, purpose, CLAIM_RESULT_FORMAT if step_id != "discover" else "application/json") - body = None if step_id == "discover" else evaluation_body(subject or "", claims, scheme="farmer_id") - if step_id != "discover" and not subject: + subjects = {"positive": POSITIVE_FARMER, "authorization-control": AUTHORIZATION_CONTROL, "redeemed-control": REDEEMED_CONTROL, "movement-permit": POSITIVE_FARMER, "purpose-denial": POSITIVE_FARMER} + if step_id != "discover" and step_id not in subjects: return standard_error_result(step_id) + client = "nagdi-livestock" if step_id in {"movement-permit", "purpose-denial"} else "nagdi-voucher" + token = service_token(client) if send else "" + purpose = request_purpose(config, step_id) + url = service_url(client, "/v1/evidence-definitions" if step_id == "discover" else "/v1/evidence") + headers = evidence_headers(token, discover=step_id == "discover") + body = None if step_id == "discover" else evidence_body(subjects[step_id], requirement_id(client), purpose, selector_profile="farmer-reference-v1", selector_field="farmer_id") request = request_source("GET" if step_id == "discover" else "POST", url, headers, body) if not send: return {"request_source": request} if not token: - return missing_runtime_token(step_id, SERVICE_NAME, service_token_env(SERVICE_ID), request) - result = http_json("GET" if step_id == "discover" else "POST", url, headers, body) - payload = { - "step_id": step_id, - "friendly": friendly_result(step_id, result, FRIENDLY), - "request_source": request, - "response_source": source_response(result), - } - if credential_profile and result.status and 200 <= result.status < 300: - payload.update(credential_attempt(service_url(SERVICE_ID, "/v1/credentials"), token, purpose, result, credential_profile, claims, SERVICE_ID)) - return payload - - -def credential_profile_for_step(step_id: str) -> str | None: - if step_id == "positive": - return VOUCHER_CREDENTIAL_PROFILE - if step_id == "movement-permit": - return MOVEMENT_CREDENTIAL_PROFILE - return None + return missing_runtime_token(step_id, SERVICE_NAME, service_token_env(client), request) + raw = http_json("GET" if step_id == "discover" else "POST", url, headers, body) + result = raw if step_id == "discover" else normalized_evidence_result(raw, request=body, service_id=client) + config = requirement_config(client) + trace = {"authority": config["name"], "service_id": authority_service_id(client), "issuer": config["issuer"], "provider": config["provider"], "source": config["source"], "status": result.status} + projection = safe_evidence_projection(result) + return {"step_id": step_id, "friendly": friendly_result(step_id, result, FRIENDLY), "request_source": request, "response_source": source_response(result), "source_trace": [trace], **projection} def request_purpose(config: dict[str, Any], step_id: str) -> str: @@ -141,6 +63,4 @@ def request_purpose(config: dict[str, Any], step_id: str) -> str: return PURPOSES["voucher"] if isinstance(config.get("purpose_override"), str): return config["purpose_override"] - if step_id == "movement-permit": - return PURPOSES["livestock"] - return PURPOSES["voucher"] + return PURPOSES["livestock"] if step_id == "movement-permit" else PURPOSES["voucher"] diff --git a/scenarios/pension_survivor.py b/scenarios/pension_survivor.py index 7b8f9af..83f2731 100644 --- a/scenarios/pension_survivor.py +++ b/scenarios/pension_survivor.py @@ -1,161 +1,31 @@ #!/usr/bin/env python3 -"""Death-to-pension-stop plus survivor-benefit guided scenario.""" +"""Death-to-pension-stop and survivor Evidence scenario.""" from __future__ import annotations from typing import Any -from .common import ( - CLAIM_RESULT_FORMAT, - PURPOSES, - StepHttpResult, - auth_headers, - credential_attempt, - evaluation_body, - friendly_result, - http_json, - missing_runtime_token, - request_source, - source_response, - standard_error_result, -) -from .service_config import ( - authority_service_id, - service_token, - service_token_env, - service_url, -) +from .common import PURPOSES, StepHttpResult, evidence_body, evidence_headers, friendly_result, http_json, missing_runtime_token, normalized_evidence_result, request_source, safe_evidence_projection, source_response, standard_error_result +from .service_config import authority_service_id, requirement_config, requirement_id, service_token, service_token_env, service_url SCENARIO_ID = "death-to-pension-survivor" -SERVICE_NAME = "Pension evidence services" -CRA_CLIENT = "cra-pension" -SIPF_CLIENT = "sipf-pension" +SERVICE_NAME = "Registry Evidence" DECEASED_PENSIONER = "2300109568" SURVIVING_SPOUSE = "2300118698" STALE_CONTROL = "2300127827" DISSOLVED_MARRIAGE_CONTROL = "2300146081" -DEATH_CLAIMS = ["person-is-deceased"] -PAYMENT_CLAIMS = ["pension-payment-active"] -SURVIVOR_CLAIMS = ["survivor-is-eligible"] -CREDENTIAL_PROFILE = "sipf-survivor-benefit.survivor-benefit-status" -CREDENTIAL_STEPS = {"survivor-benefit"} -AUTHORITY_NAMES = { - CRA_CLIENT: "Civil Registration Authority", - SIPF_CLIENT: "Social Insurance and Pensions Fund", -} FRIENDLY = { - "discover": { - "met": ( - "The catalogues list what may be asked.", - "Claim definitions only. No pension records have moved.", - ), - }, - "stop-payment": { - "met": ( - "The pension stops.", - "The application combined CRA's death predicate with SIPF's active-payment predicate. Neither authority received the other's source record.", - ), - "unmet": ( - "No stop today.", - "The application only derives a stop when death is registered and SIPF confirms an active payment.", - ), - }, - "survivor-benefit": { - "met": ( - "Yes. The surviving spouse can be offered the benefit.", - "SIPF returned only the survivor eligibility fact and can issue the corresponding credential.", - ), - "unmet": ( - "Not eligible on the facts returned.", - "The SIPF survivor eligibility check came back not met.", - ), - }, - "stale-control": { - "unmet": ( - "No death registered yet, so nothing changes.", - "CRA has no death record for this pensioner. The application does not derive a stop.", - ), - }, - "dissolved-control": { - "unmet": ( - "Rejected: the marriage was dissolved.", - "The SIPF survivor check came back not met because the marriage no longer stands.", - ), - }, - "cause-of-death-denial": { - "refused": ( - "Refused: that question does not exist here.", - "CRA offers no cause-of-death claim for this purpose, so the over-disclosing question cannot be asked.", - ), - }, + "stop-payment": {"met": ("The pension stops.", "The application combined two signed values while each authority kept its source row.")}, + "survivor-benefit": {"met": ("Yes. The surviving spouse can be offered the benefit.", "SIPF returned only the reviewed survivor concept.")}, + "stale-control": {"unmet": ("No death registered yet, so nothing changes.", "The application does not infer a death from an unresolved requirement.")}, + "dissolved-control": {"unmet": ("Rejected: the marriage was dissolved.", "SIPF returned a signed false survivor value.")}, + "cause-of-death-denial": {"refused": ("Refused: that requirement does not exist here.", "Cause of death cannot be requested through this Evidence bundle.")}, } def story() -> dict[str, Any]: - return { - "id": SCENARIO_ID, - "title": "Death to pension stop plus survivor benefit", - "short_title": "Pension stop and survivor benefit", - "proves": "CRA death evidence and SIPF payment evidence can drive an application decision without a cross-authority Notary.", - "domain": "Pensions", - "availability": "hosted", - "intro": "SIPF reviews a death registration without requesting cause of death.", - "actor": "SIPF payments reviewer", - "subject": {"name": "Rafael Nkomo", "identifier": DECEASED_PENSIONER}, - "requester": { - "name": "SIPF review desk", - "purpose": PURPOSES["pension_payment"], - }, - "steps": [ - { - "id": "discover", - "label": "Discover pension claims", - "prompt": "Read the CRA and SIPF catalogues.", - "button": "Discover", - "request_summary": "GET /v1/claims from CRA and SIPF.", - }, - { - "id": "stop-payment", - "label": "Stop pension payment", - "prompt": "Evaluate the deceased pensioner.", - "button": "Evaluate", - "request_summary": "POST the CRA death and SIPF active-payment claims.", - }, - { - "id": "survivor-benefit", - "label": "Preview survivor benefit", - "prompt": "Evaluate survivor eligibility.", - "button": "Evaluate", - "request_summary": "POST the SIPF survivor claim.", - }, - { - "id": "stale-control", - "label": "Death not yet registered", - "prompt": "Show stale-data reconciliation.", - "button": "Evaluate", - "request_summary": "POST the CRA and SIPF claims for the stale control UIN.", - }, - { - "id": "dissolved-control", - "label": "Dissolved marriage control", - "prompt": "Reject survivor eligibility when the marriage was dissolved.", - "button": "Evaluate", - "request_summary": "POST the SIPF survivor claim for the dissolved-marriage control UIN.", - }, - { - "id": "cause-of-death-denial", - "label": "Purpose denial", - "prompt": "Ask for cause of death and get denied.", - "button": "Try denial", - "request_summary": "POST an unavailable CRA cause-of-death claim.", - }, - ], - "receipt": [ - {"label": "Credential", "value": "SIPF survivor-benefit status VC preview"}, - {"label": "Cause of death disclosed", "value": "No"}, - ], - } + return {"id": SCENARIO_ID, "title": "Death to pension stop plus survivor benefit", "short_title": "Pension stop and survivor benefit", "proves": "CRA and SIPF signed evidence can drive an application decision without a cross-authority decision service.", "domain": "Pensions", "availability": "local", "intro": "SIPF reviews death evidence without requesting cause of death.", "actor": "SIPF payments reviewer", "subject": {"name": "Rafael Nkomo", "identifier": DECEASED_PENSIONER}, "requester": {"name": "SIPF review desk", "purpose": PURPOSES["pension_payment"]}, "steps": [{"id": "discover", "label": "Discover pension requirements", "prompt": "Read Evidence definitions.", "button": "Discover", "request_summary": "GET /v1/evidence-definitions"}, {"id": "stop-payment", "label": "Stop pension payment", "prompt": "Evaluate the deceased pensioner.", "button": "Evaluate", "request_summary": "POST CRA death and SIPF active-payment requirements."}, {"id": "survivor-benefit", "label": "Preview survivor benefit", "prompt": "Evaluate survivor eligibility.", "button": "Evaluate", "request_summary": "POST SIPF survivor requirement."}, {"id": "stale-control", "label": "Death not yet registered", "prompt": "Show stale-data reconciliation.", "button": "Evaluate", "request_summary": "POST CRA and SIPF requirements."}, {"id": "dissolved-control", "label": "Dissolved marriage control", "prompt": "Reject survivor eligibility.", "button": "Evaluate", "request_summary": "POST SIPF survivor requirement."}, {"id": "cause-of-death-denial", "label": "Requirement denial", "prompt": "Ask for cause of death and get denied.", "button": "Try denial", "request_summary": "POST an unconfigured requirement."}], "receipt": [{"label": "Artifact", "value": "Signed Evidence JWS"}, {"label": "Cause of death disclosed", "value": "No"}]} def preview_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: @@ -167,234 +37,67 @@ def run_step(config: dict[str, Any], step_id: str) -> dict[str, Any]: def _request(config: dict[str, Any], step_id: str, *, send: bool) -> dict[str, Any]: - subject = { - "stop-payment": DECEASED_PENSIONER, - "survivor-benefit": SURVIVING_SPOUSE, - "stale-control": STALE_CONTROL, - "dissolved-control": DISSOLVED_MARRIAGE_CONTROL, - "cause-of-death-denial": DECEASED_PENSIONER, - }.get(step_id) - if step_id != "discover" and not subject: + subject = {"stop-payment": DECEASED_PENSIONER, "survivor-benefit": SURVIVING_SPOUSE, "stale-control": STALE_CONTROL, "dissolved-control": DISSOLVED_MARRIAGE_CONTROL, "cause-of-death-denial": DECEASED_PENSIONER}.get(step_id) + if step_id != "discover" and subject is None: return standard_error_result(step_id) + token = service_token("cra-pension") if send else "" purpose = request_purpose(config, step_id) - requests = authority_requests(step_id, subject, purpose) - preview = combined_request_preview(requests, purpose) + requests = _requests(step_id, subject or "", purpose, token) + preview = requests[0]["source"] if len(requests) == 1 else {"method": "MULTI", "url": "solmara://authority-evidence", "purpose": purpose, "requests": [item["source"] for item in requests]} if not send: return {"request_source": preview} - - missing = next( - (request for request in requests if not service_token(request["client_id"])), - None, - ) - if missing: - result = missing_runtime_token( - step_id, - SERVICE_NAME, - service_token_env(missing["client_id"]), - preview, - ) - result["request_sources"] = [request["source"] for request in requests] - return result - - responses: list[tuple[dict[str, Any], StepHttpResult]] = [] - for authority_request in requests: - result = http_json( - authority_request["method"], - authority_request["url"], - authority_request["headers"], - authority_request["body"], - ) - responses.append((authority_request, result)) - - aggregate = aggregate_response(responses) - payload: dict[str, Any] = { - "step_id": step_id, - "friendly": friendly_result(step_id, aggregate, FRIENDLY), - "request_source": preview, - "request_sources": [request["source"] for request, _ in responses], - "response_source": source_response(aggregate), - "source_trace": [ - authority_trace(request, response) for request, response in responses - ], - } + if not token: + return missing_runtime_token(step_id, SERVICE_NAME, service_token_env("cra-pension"), preview) + responses = [(item, normalized_evidence_result(http_json(item["method"], item["url"], item["headers"], item["body"]), request=item["body"], service_id=item["client_id"])) for item in requests] + aggregate = _aggregate(responses) + payload: dict[str, Any] = {"step_id": step_id, "friendly": friendly_result(step_id, aggregate, FRIENDLY), "request_source": preview, "request_sources": [item["source"] for item, _ in responses], "response_source": source_response(aggregate), "source_trace": [_source_trace(item["client_id"], response) for item, response in responses], **safe_evidence_projection(aggregate)} if step_id in {"stop-payment", "stale-control"} and aggregate.status == 200: - payload["derived_decisions"] = { - "pension-payment-should-stop": derived_stop_decision(aggregate.body), - "owner": "pension-review-application", - } - if step_id in CREDENTIAL_STEPS and aggregate.status == 200: - sipf_result = responses[0][1] - payload.update( - credential_attempt( - service_url(SIPF_CLIENT, "/v1/credentials"), - service_token(SIPF_CLIENT), - purpose, - sipf_result, - CREDENTIAL_PROFILE, - SURVIVOR_CLAIMS, - authority_service_id(SIPF_CLIENT), - ) - ) + values = {item.get("claim_id"): item.get("satisfied") for item in aggregate.body.get("results", [])} + payload["derived_decisions"] = {"pension-payment-should-stop": values.get("person-is-deceased") is True and values.get("pension-payment-active") is True, "owner": "pension-review-application"} return payload -def authority_requests( - step_id: str, subject: str | None, purpose: str -) -> list[dict[str, Any]]: +def _requests(step_id: str, subject: str, purpose: str, token: str) -> list[dict[str, Any]]: if step_id == "discover": - return [ - build_request(CRA_CLIENT, "GET", "/v1/claims", purpose), - build_request(SIPF_CLIENT, "GET", "/v1/claims", purpose), - ] - if step_id in {"stop-payment", "stale-control"}: - return [ - build_request( - CRA_CLIENT, "POST", "/v1/evaluations", purpose, subject, DEATH_CLAIMS - ), - build_request( - SIPF_CLIENT, "POST", "/v1/evaluations", purpose, subject, PAYMENT_CLAIMS - ), - ] - if step_id in {"survivor-benefit", "dissolved-control"}: - return [ - build_request( - SIPF_CLIENT, - "POST", - "/v1/evaluations", - purpose, - subject, - SURVIVOR_CLAIMS, - ) - ] + url = service_url("cra-pension", "/v1/evidence-definitions") + headers = evidence_headers(token, discover=True) + return [{"client_id": "cra-pension", "method": "GET", "url": url, "headers": headers, "body": None, "source": request_source("GET", url, headers)}] + clients = ["cra-pension", "sipf-pension"] if step_id in {"stop-payment", "stale-control"} else ["sipf-survivor"] if step_id == "cause-of-death-denial": - return [ - build_request( - CRA_CLIENT, - "POST", - "/v1/evaluations", - purpose, - subject, - ["cause-of-death"], - ) - ] - return [] - - -def build_request( - client_id: str, - method: str, - path: str, - purpose: str, - subject: str | None = None, - claims: list[str] | None = None, - response_format: str = CLAIM_RESULT_FORMAT, -) -> dict[str, Any]: - token = service_token(client_id) - url = service_url(client_id, path) - headers = auth_headers( - token, purpose, response_format if method == "POST" else "application/json" - ) - body = ( - evaluation_body( - subject or "", claims or [], scheme="solmara_uin", format=response_format - ) - if method == "POST" - else None - ) - return { - "client_id": client_id, - "method": method, - "url": url, - "headers": headers, - "body": body, - "source": request_source(method, url, headers, body), - } - - -def combined_request_preview( - requests: list[dict[str, Any]], purpose: str -) -> dict[str, Any]: - if len(requests) == 1: - return requests[0]["source"] - return { - "method": "MULTI", - "url": "solmara://authority-notaries", - "headers": {"Data-Purpose": purpose}, - "requests": [request["source"] for request in requests], - } - - -def aggregate_response( - responses: list[tuple[dict[str, Any], StepHttpResult]], -) -> StepHttpResult: - failed = next( - ( - response - for _, response in responses - if response.status is None or not 200 <= response.status < 300 - ), - None, - ) + clients = ["cra-pension"] + items = [] + for client in clients: + url = service_url(client) + headers = evidence_headers(token) + requirement = "https://id.registrystack.org/solmara/requirement/cra-cause-of-death/v1" if step_id == "cause-of-death-denial" else requirement_id(client) + body = evidence_body(subject, requirement, purpose) + items.append({"client_id": client, "method": "POST", "url": url, "headers": headers, "body": body, "source": request_source("POST", url, headers, body)}) + return items + + +def _aggregate(responses: list[tuple[dict[str, Any], StepHttpResult]]) -> StepHttpResult: + failed = next((response for _, response in responses if response.status is None or not 200 <= response.status < 300), None) if failed: return failed - results: list[dict[str, Any]] = [] - for request, response in responses: - body = response.body if isinstance(response.body, dict) else {} - raw_results = body.get("results") - if request["method"] == "POST" and not isinstance(raw_results, list): - return StepHttpResult( - 502, - { - "code": "authority.invalid_response", - "detail": "An authority response omitted claim results.", - }, - {}, - ) - for result in raw_results or []: - if isinstance(result, dict): - results.append( - { - **result, - "authority": AUTHORITY_NAMES[request["client_id"]], - "notary_service_id": authority_service_id(request["client_id"]), - } - ) - return StepHttpResult( - 200, {"results": results}, {"content-type": "application/json"} - ) - - -def derived_stop_decision(body: Any) -> bool | None: - if not isinstance(body, dict) or not isinstance(body.get("results"), list): - return None - satisfied = { - result.get("claim_id"): result.get("satisfied") - for result in body["results"] - if isinstance(result, dict) - } - death = satisfied.get("person-is-deceased") - active = satisfied.get("pension-payment-active") - return ( - death and active - if isinstance(death, bool) and isinstance(active, bool) - else None - ) - - -def authority_trace( - request: dict[str, Any], response: StepHttpResult -) -> dict[str, Any]: - return { - "authority": AUTHORITY_NAMES[request["client_id"]], - "service_id": authority_service_id(request["client_id"]), - "request_source": request["source"], - "response_source": source_response(response), - } + results = [] + presentations = [] + for _, response in responses: + presentation = response.body.get("presentation") + if isinstance(presentation, dict): + presentations.append(presentation) + results.extend( + {**entry, "presentation": presentation} + for entry in response.body.get("results", []) + ) + return StepHttpResult(200, {"results": results, "presentations": presentations}, {"content-type": "application/json"}) def request_purpose(config: dict[str, Any], step_id: str) -> str: if isinstance(config.get("purpose_override"), str): return config["purpose_override"] - if step_id in {"survivor-benefit", "dissolved-control"}: - return PURPOSES["survivor_benefit"] - return PURPOSES["pension_payment"] + return PURPOSES["survivor_benefit"] if step_id in {"survivor-benefit", "dissolved-control"} else PURPOSES["pension_payment"] + + +def _source_trace(service_id: str, response: StepHttpResult) -> dict[str, Any]: + config = requirement_config(service_id) + return {"authority": config["name"], "service_id": authority_service_id(service_id), "issuer": config["issuer"], "provider": config["provider"], "source": config["source"], "status": response.status} diff --git a/scenarios/service_config.py b/scenarios/service_config.py index e656e59..3bb1624 100644 --- a/scenarios/service_config.py +++ b/scenarios/service_config.py @@ -1,96 +1,124 @@ #!/usr/bin/env python3 -"""Runtime endpoint lookup for guided Solmara scenarios.""" +"""Authority-owned Registry Evidence endpoints and requirement identifiers.""" from __future__ import annotations import os -from urllib.parse import urljoin +from typing import Any +from .common import evidence_access_token, joined_url -SERVICE_ENDPOINTS = { - "child-benefit-federator": { - "url_env": "CHILD_BENEFIT_FEDERATOR_URL", - "token_env": "CHILD_BENEFIT_FEDERATOR_TOKEN", - "default_url": "http://127.0.0.1:4321", - }, - "cra-child-benefit": { - "service_id": "cra-notary", - "url_env": "CRA_NOTARY_URL", - "token_env": "CRA_CHILD_BENEFIT_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4325", - }, - "nia-child-benefit": { - "service_id": "nia-notary", - "url_env": "NIA_NOTARY_URL", - "token_env": "NIA_CHILD_BENEFIT_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4326", - }, - "sro-child-benefit": { - "service_id": "sro-notary", - "url_env": "SRO_NOTARY_URL", - "token_env": "SRO_CHILD_BENEFIT_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4327", - }, - "programme-child-benefit": { - "service_id": "programme-notary", - "url_env": "PROGRAMME_NOTARY_URL", - "token_env": "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4328", + +BASE = "https://id.registrystack.org/solmara/requirement" +PROVIDER_BASE = "https://id.registrystack.org/solmara/evidence" +EVIDENCE_TYPE_BASE = "https://id.registrystack.org/solmara/evidence-type" +AUTHORITY_DID_BASE = "did:web:id.registrystack.org:solmara:authority" +AUTHORITY_DIRECTORY: dict[str, dict[str, str]] = { + "cra": { + "name": "Civil Registration Authority", + "base_url": "https://cra-evidence.solmara.registrystack.org", + "env": "SOLMARA_CRA_EVIDENCE_URL", + "issuer": f"{AUTHORITY_DID_BASE}:cra", + "provider": f"{PROVIDER_BASE}/cra", }, - "cra-pension": { - "service_id": "cra-notary", - "url_env": "CRA_NOTARY_URL", - "token_env": "CRA_PENSION_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4325", + "nia": { + "name": "National Identity Agency", + "base_url": "https://nia-evidence.solmara.registrystack.org", + "env": "SOLMARA_NIA_EVIDENCE_URL", + "issuer": f"{AUTHORITY_DID_BASE}:nia", + "provider": f"{PROVIDER_BASE}/nia", }, - "sipf-pension": { - "service_id": "sipf-notary", - "url_env": "SIPF_NOTARY_URL", - "token_env": "SIPF_PENSION_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4322", + "sro": { + "name": "Social Registry Office", + "base_url": "https://sro-evidence.solmara.registrystack.org", + "env": "SOLMARA_SRO_EVIDENCE_URL", + "issuer": f"{AUTHORITY_DID_BASE}:sro", + "provider": f"{PROVIDER_BASE}/sro", }, - "cra-citizen": { - "service_id": "cra-notary", - "url_env": "CRA_NOTARY_URL", - "token_env": "CRA_CITIZEN_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4325", + "mosd-programme": { + "name": "Ministry of Social Development Programme MIS", + "base_url": "https://mosd-programme-evidence.solmara.registrystack.org", + "env": "SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL", + "issuer": f"{AUTHORITY_DID_BASE}:mosd-programme-mis", + "provider": f"{PROVIDER_BASE}/mosd-programme", }, - "nia-citizen": { - "service_id": "nia-notary", - "url_env": "NIA_NOTARY_URL", - "token_env": "NIA_CITIZEN_CLIENT_TOKEN", - "default_url": "http://127.0.0.1:4326", + "sipf": { + "name": "Social Insurance and Pensions Fund", + "base_url": "https://sipf-evidence.solmara.registrystack.org", + "env": "SOLMARA_SIPF_EVIDENCE_URL", + "issuer": f"{AUTHORITY_DID_BASE}:sipf", + "provider": f"{PROVIDER_BASE}/sipf", }, - "nagdi-notary": { - "service_id": "nagdi-notary", - "url_env": "NAGDI_NOTARY_URL", - "token_env": "NAGDI_NOTARY_TOKEN", - "default_url": "http://127.0.0.1:4323", + "nagdi": { + "name": "National Agricultural Data Institute", + "base_url": "https://nagdi-evidence.solmara.registrystack.org", + "env": "SOLMARA_NAGDI_EVIDENCE_URL", + "issuer": f"{AUTHORITY_DID_BASE}:nagdi", + "provider": f"{PROVIDER_BASE}/nagdi", }, } +REQUIREMENT_DIRECTORY: dict[str, dict[str, Any]] = { + "cra-child-benefit": {"authority": "cra", "requirement": f"{BASE}/cra-child-benefit/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/cra-child-benefit/v1", "source": "immutable extract", "maximum_validity_seconds": 3600, "concepts": ("birth-is-registered", "child-age-under-5")}, + "nia-child-benefit": {"authority": "nia", "requirement": f"{BASE}/nia-child-benefit/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/population-active/v1", "source": "immutable extract", "maximum_validity_seconds": 3600, "concepts": ("population-record-active",)}, + "sro-child-benefit": {"authority": "sro", "requirement": f"{BASE}/sro-child-benefit/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/poverty-priority/v1", "source": "immutable extract", "maximum_validity_seconds": 3600, "concepts": ("household-below-poverty-threshold",)}, + "programme-child-benefit": {"authority": "mosd-programme", "requirement": f"{BASE}/mosd-child-benefit/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/not-enrolled/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("not-already-enrolled",)}, + "cra-pension": {"authority": "cra", "requirement": f"{BASE}/cra-pension-death/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/cra-death-status/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("person-is-deceased",)}, + "sipf-pension": {"authority": "sipf", "requirement": f"{BASE}/sipf-pension-payment/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/pension-payment-active/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("pension-payment-active",)}, + "sipf-survivor": {"authority": "sipf", "requirement": f"{BASE}/sipf-survivor-benefit/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/survivor-benefit/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("survivor-is-eligible",)}, + "cra-citizen": {"authority": "cra", "requirement": f"{BASE}/cra-citizen-record/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/civil-record-linked/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("civil-record-linked",)}, + "nia-citizen": {"authority": "nia", "requirement": f"{BASE}/nia-citizen-status/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/citizen-population-active/v1", "source": "immutable extract", "maximum_validity_seconds": 3600, "concepts": ("citizen-population-record-active",)}, + "nagdi-voucher": {"authority": "nagdi", "requirement": f"{BASE}/nagdi-voucher/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/climate-smart-voucher/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("farmer-registered", "data-use-authorized-for-purpose", "eligible-for-climate-smart-input-voucher")}, + "nagdi-livestock": {"authority": "nagdi", "requirement": f"{BASE}/nagdi-livestock/v1", "evidence_type": f"{EVIDENCE_TYPE_BASE}/livestock-movement/v1", "source": "Relay lookup", "maximum_validity_seconds": 300, "concepts": ("registered-herd", "origin-district-not-quarantined-for-species", "eligible-for-livestock-movement-permit")}, +} +REQUIREMENTS = {key: value["requirement"] for key, value in REQUIREMENT_DIRECTORY.items()} +_REQUIREMENT_ALIASES = {value: key for key, value in REQUIREMENTS.items()} -def service_url(service_id: str, path: str) -> str: - entry = service_entry(service_id) - base_url = os.environ.get(entry["url_env"], entry["default_url"]) - return urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + +def requirement_config(service_id: str) -> dict[str, Any]: + try: + requirement = REQUIREMENT_DIRECTORY[service_id] + authority = AUTHORITY_DIRECTORY[requirement["authority"]] + except KeyError as error: + raise ValueError(f"unknown Evidence requirement: {service_id}") from error + return {**requirement, **authority, "service_id": service_id} + + +def config_for_requirement(requirement: str) -> dict[str, Any]: + try: + return requirement_config(_REQUIREMENT_ALIASES[requirement]) + except KeyError as error: + raise ValueError("unknown Evidence requirement") from error + + +def service_url(service_id: str, path: str = "/v1/evidence") -> str: + if service_id == "child-benefit-federator": + return joined_url( + os.environ.get("CHILD_BENEFIT_FEDERATOR_URL", "http://127.0.0.1:4321"), + path, + ) + config = requirement_config(service_id) + return joined_url(os.environ.get(config["env"], config["base_url"]), path) def service_token(service_id: str) -> str: - return os.environ.get(service_token_env(service_id), "") + if service_id == "child-benefit-federator": + return os.environ.get("CHILD_BENEFIT_FEDERATOR_TOKEN", "") + requirement_config(service_id) + return evidence_access_token() def service_token_env(service_id: str) -> str: - return service_entry(service_id)["token_env"] + return ( + "CHILD_BENEFIT_FEDERATOR_TOKEN" + if service_id == "child-benefit-federator" + else "SOLMARA_EVIDENCE_CLIENT_KEY" + ) -def authority_service_id(service_id: str) -> str: - entry = service_entry(service_id) - return entry.get("service_id", service_id) +def requirement_id(service_id: str) -> str: + return str(requirement_config(service_id)["requirement"]) -def service_entry(service_id: str) -> dict[str, str]: - try: - return SERVICE_ENDPOINTS[service_id] - except KeyError as error: - raise ValueError(f"unknown service endpoint: {service_id}") from error +def authority_service_id(service_id: str) -> str: + return f"{requirement_config(service_id)['authority']}-evidence" diff --git a/scripts/build-registry-stack-runtime.sh b/scripts/build-registry-stack-runtime.sh new file mode 100755 index 0000000..1ae3abc --- /dev/null +++ b/scripts/build-registry-stack-runtime.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +set -a +# shellcheck disable=SC1091 +. "$root/versions.env" +set +a + +"$root/scripts/check-registry-stack-release-pin.py" --require-public + +version=${REGISTRY_STACK_REQUIRED_VERSION:?missing REGISTRY_STACK_REQUIRED_VERSION} +source_commit=${REGISTRY_STACK_SOURCE_COMMIT:?missing REGISTRY_STACK_SOURCE_COMMIT} +relay_digest=${REGISTRY_STACK_RELEASE_RELAY_DIGEST:?missing REGISTRY_STACK_RELEASE_RELAY_DIGEST} +relay_image=${REGISTRY_RELAY_IMAGE:?missing REGISTRY_RELAY_IMAGE} +relayctl_image=${REGISTRY_RELAYCTL_IMAGE:?missing REGISTRY_RELAYCTL_IMAGE} +evidence_image=${SOLMARA_EVIDENCE_IMAGE:?missing SOLMARA_EVIDENCE_IMAGE} +mint_image=${SOLMARA_MINT_IMAGE:?missing SOLMARA_MINT_IMAGE} + +expected_relay="ghcr.io/registrystack/relay@sha256:$relay_digest" +if [ "$relay_image" != "$expected_relay" ]; then + echo "REGISTRY_RELAY_IMAGE must bind the published Relay digest" >&2 + exit 1 +fi + +registry_stack_platform=${REGISTRY_STACK_PLATFORM:-linux/amd64} +if [ "$registry_stack_platform" != "linux/amd64" ]; then + echo "published Registry Stack runtime assets require linux/amd64" >&2 + exit 1 +fi +platform_args="--platform $registry_stack_platform" + +verify_official_runtime() { + component=$1 + image=$2 + expected_prefix="ghcr.io/registrystack/$component@sha256:" + if ! printf '%s\n' "$image" | grep -Eq "^ghcr\\.io/registrystack/${component}@sha256:[0-9a-f]{64}$"; then + echo "${component} runtime must use ${expected_prefix}<64 lowercase hex>" >&2 + exit 1 + fi + + pinned_digest=${image##*@} + published_digest=$( + docker buildx imagetools inspect \ + "ghcr.io/registrystack/${component}:v$version" \ + --format '{{.Manifest.Digest}}' + ) + if [ "$published_digest" != "$pinned_digest" ]; then + echo "published ${component} tag does not match the pinned digest" >&2 + exit 1 + fi + + docker pull "$image" >/dev/null + revision=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image") + image_version=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image") + source=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.source" }}' "$image") + if [ "$revision" != "$source_commit" ] || [ "$image_version" != "$version" ] || [ "$source" != "https://github.com/registrystack/registry-stack" ]; then + echo "${component} runtime labels do not match the pinned Registry Stack release" >&2 + exit 1 + fi +} + +verify_official_runtime relay "$relay_image" +verify_official_runtime evidence "$evidence_image" +verify_official_runtime mint "$mint_image" + +build_relayctl() { + image=$1 + current_revision=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image" 2>/dev/null || true) + current_version=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image" 2>/dev/null || true) + current_architecture=$(docker image inspect --format '{{.Architecture}}' "$image" 2>/dev/null || true) + if [ "$current_revision" = "$source_commit" ] && [ "$current_version" = "$version" ] && [ "$current_architecture" = "amd64" ]; then + echo "relayctl image already matches Registry Stack v$version" + return + fi + + relayctl_context=$(mktemp -d) + trap 'rm -rf -- "$relayctl_context"' EXIT HUP INT TERM + relayctl_asset_file=${REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_FILE:-} + if [ -n "$relayctl_asset_file" ]; then + if [ -L "$relayctl_asset_file" ] || [ ! -f "$relayctl_asset_file" ]; then + echo "provided Relayctl asset must be a regular file" >&2 + exit 1 + fi + cp -- "$relayctl_asset_file" "$relayctl_context/relayctl" + else + curl --fail --location --silent --show-error \ + --retry 5 --retry-all-errors --connect-timeout 30 \ + --output "$relayctl_context/relayctl" \ + "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL" + fi + if [ -L "$relayctl_context/relayctl" ] || [ ! -f "$relayctl_context/relayctl" ]; then + echo "downloaded Relayctl asset must be a regular file" >&2 + exit 1 + fi + relayctl_sha256=$(sha256sum "$relayctl_context/relayctl" | awk '{print $1}') + if [ "$relayctl_sha256" != "$REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256" ]; then + echo "downloaded Relayctl asset checksum does not match the release pin" >&2 + exit 1 + fi + chmod 0755 "$relayctl_context/relayctl" + + # shellcheck disable=SC2086 + docker buildx build --load $platform_args \ + --label "org.opencontainers.image.source=https://github.com/registrystack/registry-stack" \ + --label "org.opencontainers.image.revision=$source_commit" \ + --label "org.opencontainers.image.version=$version" \ + --tag "$image" \ + --file "$root/docker/registry-stack-release-binary/Dockerfile" \ + --target relayctl \ + "$relayctl_context" + rm -rf -- "$relayctl_context" + trap - EXIT HUP INT TERM +} + +build_relayctl "$relayctl_image" diff --git a/scripts/build-relay-runtime.sh b/scripts/build-relay-runtime.sh deleted file mode 100755 index 92b85f9..0000000 --- a/scripts/build-relay-runtime.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env sh -set -eu - -root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) - -set -a -# shellcheck disable=SC1091 -. "$root/versions.env" -set +a - -source_ref=${REGISTRY_STACK_SOURCE_REF:?missing REGISTRY_STACK_SOURCE_REF} -source_commit=${REGISTRY_STACK_SOURCE_COMMIT:?missing REGISTRY_STACK_SOURCE_COMMIT} -runtime_image=${SOLMARA_RELAY_DEV_IMAGE:?missing SOLMARA_RELAY_DEV_IMAGE} -release_image=${REGISTRY_RELAY_IMAGE:?missing REGISTRY_RELAY_IMAGE} -platform=${REGISTRY_STACK_PLATFORM:-linux/amd64} - -case "$source_ref" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) - echo "REGISTRY_STACK_SOURCE_REF must be a stable vMAJOR.MINOR.PATCH tag" >&2 - exit 1 - ;; -esac -case "$source_commit" in - *[!0-9a-f]*) - echo "REGISTRY_STACK_SOURCE_COMMIT must be exactly 40 lowercase hex characters" >&2 - exit 1 - ;; -esac -if [ "${#source_commit}" -ne 40 ]; then - echo "REGISTRY_STACK_SOURCE_COMMIT must be exactly 40 lowercase hex characters" >&2 - exit 1 -fi - -image_revision=$( - docker image inspect \ - --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \ - "$runtime_image" 2>/dev/null || true -) -image_base=$( - docker image inspect \ - --format '{{ index .Config.Labels "org.opencontainers.image.base.name" }}' \ - "$runtime_image" 2>/dev/null || true -) -if [ "$image_revision" = "$source_commit" ] \ - && [ "$image_base" = "$release_image" ]; then - echo "Solmara Relay development image already matches $source_ref" - exit 0 -fi - -if [ -n "${REGISTRY_STACK_SOURCE_DIR:-}" ]; then - source_dir=$REGISTRY_STACK_SOURCE_DIR -else - source_dir="$root/.cache/registry-stack/$source_commit" - if [ ! -d "$source_dir/.git" ]; then - mkdir -p "$(dirname -- "$source_dir")" - git clone --filter=blob:none --depth 1 --branch "$source_ref" \ - https://github.com/registrystack/registry-stack.git "$source_dir" - fi -fi - -actual_commit=$(git -C "$source_dir" rev-parse HEAD) -if [ "$actual_commit" != "$source_commit" ]; then - echo "Registry Stack source mismatch: expected $source_commit, found $actual_commit" >&2 - exit 1 -fi -if [ -n "$(git -C "$source_dir" status --porcelain)" ]; then - echo "Registry Stack source checkout must be clean: $source_dir" >&2 - exit 1 -fi - -docker buildx build \ - --load \ - --platform "$platform" \ - --build-arg "REGISTRY_RELAY_IMAGE=$release_image" \ - --build-arg "REGISTRY_STACK_SOURCE_COMMIT=$source_commit" \ - --tag "$runtime_image" \ - --file "$root/docker/relay-runtime/Dockerfile" \ - "$source_dir" diff --git a/scripts/check-config-secrets.py b/scripts/check-config-secrets.py index 1d9d819..59e9b1a 100755 --- a/scripts/check-config-secrets.py +++ b/scripts/check-config-secrets.py @@ -11,8 +11,8 @@ SCAN_DIRS = [ "ministries", "metadata", - "projects", - "runtime/registry-projects", + "relays", + "evidence", "config/hosted-relay-bundles", "compose.yaml", "compose.hosted.yaml", @@ -25,8 +25,9 @@ "token_env:", "private_jwk_env:", "hash_secret_env:", + "secret:file/", + "secret:env/", "POSTGRES_PASSWORD:", - "REGISTRY_NOTARY_REPLAY_REDIS_URL:", ) WORKLOAD_TOKEN_VOLUME = re.compile( r"^\s*-\s*[a-z0-9-]+-workload-token:/run/(?:esignet-)?secrets(?::ro)?\s*$" diff --git a/scripts/check-coolify-compose.sh b/scripts/check-coolify-compose.sh index c84da3b..507b2ff 100755 --- a/scripts/check-coolify-compose.sh +++ b/scripts/check-coolify-compose.sh @@ -1,31 +1,66 @@ #!/usr/bin/env sh set -eu -if [ ! -f .env ]; then - echo ".env is missing; run 'just gen-secrets' first" >&2 - exit 1 -fi +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_digest=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +test_image="ghcr.io/registrystack/solmara-test@sha256:$test_digest" +test_private_jwk='{"kty":"EC","crv":"P-384","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","y":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","d":"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC","kid":"solmara-test","alg":"ES384"}' +test_public_jwk='{"kty":"EC","crv":"P-384","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","y":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","kid":"solmara-test","alg":"ES384"}' +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT HUP INT TERM -compose_project_name="$(python3 scripts/compose_project_name.py)" -for compose in $(find . -maxdepth 1 -name 'compose.coolify*.yaml' -print | sort); do - COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$compose_project_name}" \ - SOLMARA_RELAY_IMAGE="${SOLMARA_RELAY_IMAGE:-ghcr.io/registrystack/solmara-lab-relay@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_NOTARY_IMAGE="${SOLMARA_NOTARY_IMAGE:-ghcr.io/registrystack/solmara-lab-notary@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_POSTGRES_IMAGE="${SOLMARA_POSTGRES_IMAGE:-ghcr.io/registrystack/solmara-lab-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_STATIC_METADATA_IMAGE="${SOLMARA_STATIC_METADATA_IMAGE:-ghcr.io/registrystack/solmara-lab-static-metadata@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_HOME_IMAGE="${SOLMARA_HOME_IMAGE:-ghcr.io/registrystack/solmara-lab-home@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_PORTAL_IMAGE="${SOLMARA_PORTAL_IMAGE:-ghcr.io/registrystack/solmara-lab-portal@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_SCENARIO_RUNNER_IMAGE="${SOLMARA_SCENARIO_RUNNER_IMAGE:-ghcr.io/registrystack/solmara-lab-scenario-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_ESIGNET_RELAY_IMAGE="${SOLMARA_ESIGNET_RELAY_IMAGE:-ghcr.io/registrystack/solmara-lab-esignet-relay@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_ESIGNET_SEED_IMAGE="${SOLMARA_ESIGNET_SEED_IMAGE:-ghcr.io/registrystack/solmara-lab-esignet-seed@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_ESIGNET_POSTGRES_IMAGE="${SOLMARA_ESIGNET_POSTGRES_IMAGE:-ghcr.io/registrystack/solmara-lab-esignet-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - SOLMARA_ESIGNET_UI_IMAGE="${SOLMARA_ESIGNET_UI_IMAGE:-ghcr.io/registrystack/solmara-lab-esignet-ui@sha256:0000000000000000000000000000000000000000000000000000000000000000}" \ - CONFIG_REPO_REF="${CONFIG_REPO_REF:-local-compose-check}" \ - WALT_DB_PASSWORD="${WALT_DB_PASSWORD:-local-compose-check}" \ - WALT_AUTH_ENCRYPTION_KEY="${WALT_AUTH_ENCRYPTION_KEY:-local-compose-check}" \ - WALT_AUTH_SIGN_KEY="${WALT_AUTH_SIGN_KEY:-local-compose-check}" \ - WALT_AUTH_TOKEN_KEY="${WALT_AUTH_TOKEN_KEY:-local-compose-check}" \ - WALT_KTOR_SIGNING_KEY="${WALT_KTOR_SIGNING_KEY:-local-compose-check}" \ - WALT_KTOR_VERIFICATION_KEY="${WALT_KTOR_VERIFICATION_KEY:-local-compose-check}" \ - docker compose --env-file versions.env --env-file .env -f "$compose" config >/dev/null +check_compose() { + REGISTRY_RELAY_IMAGE="ghcr.io/registrystack/relay@sha256:$test_digest" \ + SOLMARA_EVIDENCE_IMAGE="$test_image" \ + SOLMARA_MINT_IMAGE="$test_image" \ + SOLMARA_STATIC_METADATA_IMAGE="$test_image" \ + SOLMARA_SCENARIO_RUNNER_IMAGE="$test_image" \ + SOLMARA_AUTHORITY_PROVISIONER_IMAGE="$test_image" \ + SOLMARA_TRANSIT_SIGNER_IMAGE="$test_image" \ + SOLMARA_HOME_IMAGE="$test_image" \ + SOLMARA_PORTAL_IMAGE="$test_image" \ + SOLMARA_ESIGNET_RELAY_IMAGE="$test_image" \ + SOLMARA_ESIGNET_SEED_IMAGE="$test_image" \ + SOLMARA_ESIGNET_POSTGRES_IMAGE="$test_image" \ + SOLMARA_ESIGNET_UI_IMAGE="$test_image" \ + ESIGNET_REDIS_IMAGE="redis@sha256:b99ffd0554dc8d300230b9d1b9f2a129a6abf595bf8589883beb980ed1feae3d" \ + SOLMARA_ESIGNET_POSTGRES_PASSWORD=test \ + ESIGNET_DEMO_OTP=111111 \ + REGISTRY_ESIGNET_KYC_TOKEN_SECRET=test \ + REGISTRY_ESIGNET_PSUT_SECRET=test \ + REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD=test \ + PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64=test \ + NIA_ESIGNET_CLIENT_PRIVATE_JWK="$test_private_jwk" \ + SOLMARA_DEMO_CLIENT_PRIVATE_JWK="$test_private_jwk" \ + CHILD_BENEFIT_FEDERATOR_TOKEN=test \ + CRA_RELAY_AUDIT_KEY=test NIA_RELAY_AUDIT_KEY=test MOSD_RELAY_AUDIT_KEY=test \ + SIPF_RELAY_AUDIT_KEY=test SIPF_RELAY_CURSOR_KEY=test \ + NAGDI_RELAY_AUDIT_KEY=test NAGDI_RELAY_CURSOR_KEY=test \ + MINT_SIGNING_JWK="$test_private_jwk" MINT_SIGNING_PUBLIC_JWK="$test_public_jwk" MINT_AUDIT_HMAC_KEY=test \ + CRA_EVIDENCE_SIGNING_JWK="$test_private_jwk" CRA_EVIDENCE_SIGNING_PUBLIC_JWK="$test_public_jwk" CRA_EVIDENCE_AUDIT_HMAC_KEY=test CRA_EVIDENCE_SUBJECT_BINDING_HMAC_KEY=test \ + NIA_EVIDENCE_SIGNING_JWK="$test_private_jwk" NIA_EVIDENCE_SIGNING_PUBLIC_JWK="$test_public_jwk" NIA_EVIDENCE_AUDIT_HMAC_KEY=test NIA_EVIDENCE_SUBJECT_BINDING_HMAC_KEY=test \ + SRO_EVIDENCE_SIGNING_JWK="$test_private_jwk" SRO_EVIDENCE_SIGNING_PUBLIC_JWK="$test_public_jwk" SRO_EVIDENCE_AUDIT_HMAC_KEY=test SRO_EVIDENCE_SUBJECT_BINDING_HMAC_KEY=test \ + MOSD_EVIDENCE_SIGNING_JWK="$test_private_jwk" MOSD_EVIDENCE_SIGNING_PUBLIC_JWK="$test_public_jwk" MOSD_EVIDENCE_AUDIT_HMAC_KEY=test MOSD_EVIDENCE_SUBJECT_BINDING_HMAC_KEY=test \ + SIPF_EVIDENCE_SIGNING_JWK="$test_private_jwk" SIPF_EVIDENCE_SIGNING_PUBLIC_JWK="$test_public_jwk" SIPF_EVIDENCE_AUDIT_HMAC_KEY=test SIPF_EVIDENCE_SUBJECT_BINDING_HMAC_KEY=test \ + NAGDI_EVIDENCE_SIGNING_JWK="$test_private_jwk" NAGDI_EVIDENCE_SIGNING_PUBLIC_JWK="$test_public_jwk" NAGDI_EVIDENCE_AUDIT_HMAC_KEY=test NAGDI_EVIDENCE_SUBJECT_BINDING_HMAC_KEY=test \ + CRA_PENSION_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" CRA_CITIZEN_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" \ + MOSD_CHILD_BENEFIT_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" \ + SIPF_PENSION_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" SIPF_SURVIVOR_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" \ + NAGDI_VOUCHER_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" NAGDI_LIVESTOCK_EVIDENCE_CLIENT_PRIVATE_JWK="$test_private_jwk" \ + CRA_PENSION_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" CRA_CITIZEN_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ + MOSD_CHILD_BENEFIT_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ + SIPF_PENSION_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" SIPF_SURVIVOR_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ + NAGDI_VOUCHER_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" NAGDI_LIVESTOCK_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ + NIA_ESIGNET_CLIENT_PUBLIC_JWK="$test_public_jwk" SOLMARA_DEMO_CLIENT_PUBLIC_JWK="$test_public_jwk" \ + docker compose "$@" config >"$scratch/rendered.yaml" + + rendered_bytes=$(wc -c <"$scratch/rendered.yaml" | tr -d ' ') + if [ "$rendered_bytes" -ge 65536 ]; then + echo "rendered Coolify Compose exceeds 65,535 bytes: $2 ($rendered_bytes bytes)" >&2 + return 1 + fi +} + +for compose in "$root"/compose.coolify*.yaml; do + check_compose -f "$compose" done diff --git a/scripts/check-evidence-cells.sh b/scripts/check-evidence-cells.sh new file mode 100755 index 0000000..2d1a360 --- /dev/null +++ b/scripts/check-evidence-cells.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +project_name=${COMPOSE_PROJECT_NAME:-$(python3 "$root/scripts/compose_project_name.py")} + +compose() { + COMPOSE_PROJECT_NAME="$project_name" docker compose \ + --env-file "$root/versions.env" \ + --env-file "$root/.env" \ + -f "$root/compose.yaml" "$@" +} + +for cell in cra nia sro mosd-programme sipf nagdi; do + compose exec -T "$cell-evidence" evidence check \ + --runtime "/etc/registry-evidence/$cell/runtime.yaml" + for fixture in "$root/runtime/evidence-cells/cells/$cell/bundle/fixtures/"*.yaml; do + compose exec -T "$cell-evidence" evidence evaluate \ + --runtime "/etc/registry-evidence/$cell/runtime.yaml" \ + --fixture "fixtures/$(basename "$fixture")" + done +done + +printf '%s\n' 'evidence-check: six authority cells and all eleven requirement fixtures passed' diff --git a/scripts/check-hosted-compose.sh b/scripts/check-hosted-compose.sh new file mode 100755 index 0000000..d7cc2aa --- /dev/null +++ b/scripts/check-hosted-compose.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_digest=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +test_image="ghcr.io/registrystack/solmara-compose-check@sha256:$test_digest" + +REGISTRY_RELAY_IMAGE="ghcr.io/registrystack/relay@sha256:$test_digest" \ +SOLMARA_EVIDENCE_IMAGE="$test_image" \ +SOLMARA_MINT_IMAGE="$test_image" \ +SOLMARA_STATIC_METADATA_IMAGE="$test_image" \ +SOLMARA_SCENARIO_RUNNER_IMAGE="$test_image" \ +SOLMARA_HOME_IMAGE="$test_image" \ +SOLMARA_PORTAL_IMAGE="$test_image" \ +docker compose \ + --env-file "$root/versions.env" \ + --env-file "$root/.env" \ + -f "$root/compose.yaml" \ + -f "$root/compose.hosted.yaml" \ + config >/dev/null diff --git a/scripts/check-hosted-relay-bundles.py b/scripts/check-hosted-relay-bundles.py deleted file mode 100755 index 735019e..0000000 --- a/scripts/check-hosted-relay-bundles.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Verify committed hosted Relay bundles and their generated source closure.""" - -from __future__ import annotations - -import copy -import json -import subprocess -from pathlib import Path - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] -PROJECTS = ( - "cra-civil", - "nia-population", - "sro-social", - "mosd-programme", - "sipf-pensions", - "nagdi-agriculture", -) -BUNDLE_VARIANTS = ( - ("public", "relay.yaml", "", False), - ("consultation", "relay-consultation.yaml", "consultation", True), -) -BUNDLE_ROOT = ROOT / "config" / "hosted-relay-bundles" -CONTAINER_ROOT = Path("/etc/solmara/hosted-relay-bundles") -ANTIROLLBACK_PATH = ( - "/var/lib/registry-relay/cache/config-bundle-antirollback.json" -) - - -def fail(message: str) -> None: - raise SystemExit(f"check-hosted-relay-bundles: {message}") - - -def registryctl_path() -> str: - result = subprocess.run( - [str(ROOT / "scripts" / "registryctl-pinned.sh"), "path"], - check=True, - stdout=subprocess.PIPE, - text=True, - ) - return result.stdout.strip() - - -def expected_bundle_sequence() -> int: - for raw_line in (ROOT / "versions.env").read_text(encoding="utf-8").splitlines(): - if raw_line.startswith("SOLMARA_RELAY_BUNDLE_SEQUENCE="): - raw_sequence = raw_line.split("=", 1)[1] - break - else: - fail("versions.env must set SOLMARA_RELAY_BUNDLE_SEQUENCE") - try: - sequence = int(raw_sequence) - except ValueError: - fail("SOLMARA_RELAY_BUNDLE_SEQUENCE must be a positive integer") - if sequence < 1: - fail("SOLMARA_RELAY_BUNDLE_SEQUENCE must be a positive integer") - return sequence - - -def load_yaml(path: Path) -> dict[str, object]: - value = yaml.safe_load(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - fail(f"{path.relative_to(ROOT)} must contain a mapping") - return value - - -def regular_file_tree(root: Path) -> dict[str, bytes]: - if not root.is_dir(): - fail(f"missing artifact directory {root}") - files: dict[str, bytes] = {} - for path in sorted(root.rglob("*")): - relative = path.relative_to(root).as_posix() - if path.is_symlink(): - fail(f"artifact tree contains symlink {relative}") - if path.is_dir(): - continue - if not path.is_file(): - fail(f"artifact tree contains non-file {relative}") - files[relative] = path.read_bytes() - return files - - -def verify_artifact_closure( - project: str, bundled_artifacts: Path, source_artifacts: Path -) -> None: - bundled_files = regular_file_tree(bundled_artifacts) - source_files = regular_file_tree(source_artifacts) - if bundled_files.keys() != source_files.keys(): - fail(f"{project} signed artifact paths differ from compiler output") - for relative, bundled_content in bundled_files.items(): - if bundled_content != source_files[relative]: - fail( - f"{project} signed artifact {relative} differs from compiler output" - ) - - -def main() -> int: - registryctl = registryctl_path() - expected_sequence = expected_bundle_sequence() - for project in PROJECTS: - for variant, source_name, output_subdirectory, include_artifacts in BUNDLE_VARIANTS: - project_dir = BUNDLE_ROOT / project - container_dir = CONTAINER_ROOT / project - if output_subdirectory: - project_dir /= output_subdirectory - container_dir /= output_subdirectory - bootstrap_path = project_dir / "bootstrap.yaml" - anchor_path = project_dir / "trust-anchor.json" - seed_path = project_dir / "antirollback-seed.json" - bundle_dir = project_dir / "bundle" - bundle_config_path = bundle_dir / "config" / "relay.yaml" - source_config_path = ( - ROOT - / "runtime" - / "registry-projects" - / "hosted" - / project - / "relay" - / source_name - ) - bundled_artifacts = bundle_dir / "config" / "artifacts" - source_artifacts = source_config_path.parent / "artifacts" - for required in ( - bootstrap_path, - anchor_path, - seed_path, - bundle_dir / "manifest.json", - bundle_dir / "manifest.sig.json", - bundle_config_path, - source_config_path, - ): - if not required.is_file(): - fail(f"missing {required.relative_to(ROOT)}") - - bootstrap = load_yaml(bootstrap_path) - bundled = load_yaml(bundle_config_path) - source = load_yaml(source_config_path) - if variant == "public" and "consultation" in source: - fail(f"{project} public Relay config contains consultation authority") - if variant == "consultation" and "consultation" not in source: - fail(f"{project} consultation Relay config omits consultation authority") - if bootstrap != bundled: - fail( - f"{project} {variant} bootstrap config differs from signed config" - ) - - expected_trust = { - "trust_anchor_path": str(container_dir / "trust-anchor.json"), - "bundle_path": str(container_dir / "bundle"), - "antirollback_state_path": ANTIROLLBACK_PATH, - } - if bundled.get("config_trust") != expected_trust: - fail( - f"{project} {variant} config trust paths are not deployment-bound" - ) - unsigned_projection = copy.deepcopy(bundled) - unsigned_projection.pop("config_trust", None) - if unsigned_projection != source: - fail(f"{project} {variant} signed config differs from compiler output") - if include_artifacts: - verify_artifact_closure( - f"{project} {variant}", bundled_artifacts, source_artifacts - ) - elif bundled_artifacts.exists(): - fail(f"{project} public bundle contains private consultation artifacts") - - anchor = json.loads(anchor_path.read_text(encoding="utf-8")) - manifest = json.loads( - (bundle_dir / "manifest.json").read_text(encoding="utf-8") - ) - instance_id = source["instance"]["id"] - stream_suffix = "" if variant == "public" else "-consultation" - stream_id = f"solmara-hosted-{project}{stream_suffix}" - expected_binding = { - "product": "registry-relay", - "environment": "hosted", - "stream_id": stream_id, - "instance_id": instance_id, - } - for key, expected in expected_binding.items(): - if anchor.get(key) != expected or manifest.get(key) != expected: - fail(f"{project} {variant} has an incorrect {key} binding") - if manifest.get("sequence") != expected_sequence: - fail( - f"{project} {variant} bundle sequence must match " - "SOLMARA_RELAY_BUNDLE_SEQUENCE" - ) - if any( - "d" in signer.get("jwk", {}) for signer in anchor.get("signers", []) - ): - fail(f"{project} {variant} trust anchor contains private key material") - seed = json.loads(seed_path.read_text(encoding="utf-8")) - expected_seed = { - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": stream_id, - }, - "last_sequence": 0, - "last_config_hash": f"sha256:{'0' * 64}", - } - if seed != expected_seed: - fail( - f"{project} {variant} anti-rollback seed is not " - "the sequence-zero baseline" - ) - - subprocess.run( - [ - registryctl, - "bundle", - "verify", - "--bundle-dir", - str(bundle_dir), - "--anchor-path", - str(anchor_path), - ], - check=True, - stdout=subprocess.DEVNULL, - ) - print("check-hosted-relay-bundles: ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check-image-pins.py b/scripts/check-image-pins.py index d781465..e0fb5b6 100755 --- a/scripts/check-image-pins.py +++ b/scripts/check-image-pins.py @@ -8,15 +8,20 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -LINE_RE = re.compile(r"^([A-Z0-9_]+)=([^#\s]+)$") +LINE_RE = re.compile(r"^([A-Z0-9_]+)=([^#\s]*)$") PIN_RE = re.compile(r"^[^#\s]+@sha256:[0-9a-f]{64}$") -REGISTRY_STACK_IMAGE_KEYS = {"REGISTRY_RELAY_IMAGE", "REGISTRY_NOTARY_IMAGE"} -PINNED_IMAGE_KEYS = REGISTRY_STACK_IMAGE_KEYS | { - "VOLUME_INIT_IMAGE", +OFFICIAL_RUNTIME_REPOSITORIES = { + "REGISTRY_RELAY_IMAGE": "relay", + "SOLMARA_EVIDENCE_IMAGE": "evidence", + "SOLMARA_MINT_IMAGE": "mint", +} +SOURCE_IMAGE_KEYS = set(OFFICIAL_RUNTIME_REPOSITORIES) +PINNED_IMAGE_KEYS = { + "VOLUME_INIT_IMAGE", "EVIDENCE_GATEWAY_IMAGE", "PYTHON_STATIC_IMAGE", + "NODE_BUILD_IMAGE", "UV_BUILD_IMAGE", + "ESIGNET_REDIS_IMAGE", "ESIGNET_BASE_IMAGE", "ESIGNET_UI_IMAGE", + "ESIGNET_POSTGRES_IMAGE", } -COMPOSE_FALLBACK_RE = re.compile( - r"\$\{(?PREGISTRY_(?:RELAY|NOTARY)_IMAGE):-(?P[^}]+)\}" -) def main() -> int: @@ -37,36 +42,37 @@ def main() -> int: continue key, value = match.groups() values[key] = value + if key in OFFICIAL_RUNTIME_REPOSITORIES: + repository = OFFICIAL_RUNTIME_REPOSITORIES[key] + expected = f"ghcr.io/registrystack/{repository}@sha256:" + if not value.startswith(expected) or not PIN_RE.match(value): + failures.append( + f"versions.env:{line_no}: {key} must use " + f"{expected}<64 lowercase hex>" + ) if key in PINNED_IMAGE_KEYS and not PIN_RE.match(value): failures.append(f"versions.env:{line_no}: {key} must use image@sha256:<64 hex>") if "@latest" in value or ":latest" in value: failures.append(f"versions.env:{line_no}: latest tags are not allowed") - for key in PINNED_IMAGE_KEYS: + for key in PINNED_IMAGE_KEYS | SOURCE_IMAGE_KEYS: if key not in values: failures.append(f"versions.env: {key} is required") - compose_files = [ROOT / "compose.yaml", ROOT / "compose.hosted.yaml"] - compose_files.extend(sorted(ROOT.glob("compose.coolify*.yaml"))) - fallback_counts = {key: 0 for key in REGISTRY_STACK_IMAGE_KEYS} + compose_files = [ROOT / "compose.yaml", ROOT / "compose.esignet.yaml"] + required_counts = {key: 0 for key in SOURCE_IMAGE_KEYS} for compose in compose_files: if not compose.exists(): continue text = compose.read_text() if "@latest" in text or ":latest" in text: failures.append(f"{compose.name}: latest tags are not allowed") - for fallback in COMPOSE_FALLBACK_RE.finditer(text): - key = fallback.group("key") - fallback_counts[key] += 1 - expected = values.get(key) - if expected and fallback.group("value") != expected: - failures.append( - f"{compose.name}: {key} fallback must match versions.env" - ) + for key in SOURCE_IMAGE_KEYS: + required_counts[key] += text.count(f"${{{key}:?") - for key, count in fallback_counts.items(): + for key, count in required_counts.items(): if count == 0: - failures.append(f"compose files: expected a {key} fallback") + failures.append(f"compose files: expected a required {key} reference") if failures: for failure in failures: diff --git a/scripts/check-local-transit-providers.py b/scripts/check-local-transit-providers.py new file mode 100755 index 0000000..a603710 --- /dev/null +++ b/scripts/check-local-transit-providers.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Require all external local Transit proxies before starting runtimes.""" + +from __future__ import annotations + +import stat +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROVIDERS = ("mint", "cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") + + +def failures(root: Path = ROOT) -> list[str]: + result = [] + for provider in PROVIDERS: + socket = root / "config/evidence/local/cells" / provider / "transit/transit-proxy.sock" + try: + mode = socket.stat().st_mode + except FileNotFoundError: + result.append(f"{provider}: Transit proxy socket is missing") + continue + if not stat.S_ISSOCK(mode): + result.append(f"{provider}: Transit provider path is not a Unix socket") + return result + + +def main() -> int: + found = failures() + if found: + print("\n".join(found), file=sys.stderr) + print("Start the seven external signer providers; private keys are never mounted into Evidence or Mint.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-registry-stack-release-pin.py b/scripts/check-registry-stack-release-pin.py new file mode 100755 index 0000000..c20ae0d --- /dev/null +++ b/scripts/check-registry-stack-release-pin.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Validate the coherent immutable Registry Stack release pins.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DIGEST = re.compile(r"^[0-9a-f]{64}$") +HTTPS = re.compile(r"^https://[^\s]+$") +REQUIRED_VERSION = "0.22.0" + + +def read_versions(path: Path) -> dict[str, str]: + result: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + if raw and not raw.startswith("#") and "=" in raw: + key, value = raw.split("=", 1) + result[key] = value + return result + + +def validate(values: dict[str, str], *, require_public: bool) -> list[str]: + failures: list[str] = [] + if values.get("REGISTRY_STACK_REQUIRED_VERSION") != REQUIRED_VERSION: + failures.append(f"REGISTRY_STACK_REQUIRED_VERSION must be {REQUIRED_VERSION}") + source_ref = values.get("REGISTRY_STACK_SOURCE_REF", "") + expected_source_ref = f"v{REQUIRED_VERSION}" + if source_ref and source_ref != expected_source_ref: + failures.append(f"REGISTRY_STACK_SOURCE_REF must be {expected_source_ref}") + if require_public and not source_ref: + failures.append(f"REGISTRY_STACK_SOURCE_REF must bind the published {expected_source_ref} tag") + source_commit = values.get("REGISTRY_STACK_SOURCE_COMMIT", "") + if source_commit and not re.fullmatch(r"[0-9a-f]{40}", source_commit): + failures.append("REGISTRY_STACK_SOURCE_COMMIT must be 40 lowercase hex characters") + if require_public and not source_commit: + failures.append(f"REGISTRY_STACK_SOURCE_COMMIT is not final; v{REQUIRED_VERSION} promotion is blocked") + for key in ( + "REGISTRY_STACK_RELEASE_RELAY_DIGEST", + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256", + ): + value = values.get(key, "") + if value and not DIGEST.fullmatch(value): + failures.append(f"{key} must be 64 lowercase hex characters") + if require_public and not value: + failures.append(f"{key} is not published; v{REQUIRED_VERSION} promotion is blocked") + asset_names = { + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL": f"relayctl-v{REQUIRED_VERSION}-linux-amd64", + } + release_base = f"https://github.com/registrystack/registry-stack/releases/download/v{REQUIRED_VERSION}/" + for key, asset_name in asset_names.items(): + asset_url = values.get(key, "") + if asset_url and not HTTPS.fullmatch(asset_url): + failures.append(f"{key} must be an exact HTTPS URL") + elif asset_url and asset_url != release_base + asset_name: + failures.append(f"{key} must bind the exact v{REQUIRED_VERSION} linux-amd64 asset") + if require_public and not asset_url: + failures.append(f"{key} is not published; v{REQUIRED_VERSION} promotion is blocked") + if values.get("ESIGNET_AUTHENTICATOR_VERSION") != "0.2.0": + failures.append("ESIGNET_AUTHENTICATOR_VERSION must be 0.2.0") + release_url = "https://github.com/jeremi/esignet-relay-authenticator/releases/tag/v0.2.0" + if values.get("ESIGNET_AUTHENTICATOR_RELEASE_URL") != release_url: + failures.append("ESIGNET_AUTHENTICATOR_RELEASE_URL must bind the exact v0.2.0 release") + jar_url = values.get("ESIGNET_AUTHENTICATOR_JAR_URL", "") + jar_sha = values.get("ESIGNET_AUTHENTICATOR_JAR_SHA256", "") + if jar_url and not HTTPS.fullmatch(jar_url): + failures.append("ESIGNET_AUTHENTICATOR_JAR_URL must be an exact HTTPS URL") + if jar_sha and not DIGEST.fullmatch(jar_sha): + failures.append("ESIGNET_AUTHENTICATOR_JAR_SHA256 must be 64 lowercase hex characters") + expected_base = "https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/" + if jar_url and jar_url != expected_base + "esignet-relay-authenticator-0.2.0.jar": + failures.append("ESIGNET_AUTHENTICATOR_JAR_URL must bind the exact v0.2.0 release asset") + if values.get("ESIGNET_AUTHENTICATOR_CHECKSUM_URL", "") != expected_base + "esignet-relay-authenticator-0.2.0.jar.sha256": + failures.append("ESIGNET_AUTHENTICATOR_CHECKSUM_URL must bind the exact v0.2.0 checksum asset") + if require_public and (not jar_url or not jar_sha): + failures.append("eSignet authenticator v0.2.0 JAR URL/checksum is not published; promotion is blocked") + return failures + + +def main() -> int: + failures = validate(read_versions(ROOT / "versions.env"), require_public="--require-public" in sys.argv[1:]) + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-relay-projects.sh b/scripts/check-relay-projects.sh new file mode 100755 index 0000000..6964f49 --- /dev/null +++ b/scripts/check-relay-projects.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +set -a +. "$root/versions.env" +set +a + +"$root/scripts/publish-relay-sources.sh" + +temporary_root=$(mktemp -d "$root/output/relay-check.XXXXXX") +cleanup() { + rm -rf -- "$temporary_root" +} +trap cleanup EXIT HUP INT TERM + +cd "$root" +run_relayctl() { + authority=$1 + database=$2 + stage=$3 + shift 3 + report="$temporary_root/$authority-$stage.json" + if ! docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + --volume "$root:/workspace" \ + --volume "$database:/var/lib/relay/source/$authority.sqlite:ro" \ + --workdir /workspace \ + "$REGISTRY_RELAYCTL_IMAGE" \ + --json "$@" >"$report"; then + printf 'relay-check: %s %s failed\n' "$authority" "$stage" >&2 + # relayctl diagnostics contain governed contract paths and error codes, not + # selectors or source values. Preserve them so CI failures are actionable. + cat "$report" >&2 + return 1 + fi +} + +for authority in cra nia mosd sipf nagdi; do + project="relays/$authority" + database="$root/output/sqlite/relay/$authority.sqlite" + generated="$temporary_root/$authority-generated" + package="$temporary_root/$authority-package" + run_relayctl "$authority" "$database" check \ + check "$project" --production + run_relayctl "$authority" "$database" generate \ + generate "$project" --output "${generated#"$root/"}" + run_relayctl "$authority" "$database" test \ + test "$project" + run_relayctl "$authority" "$database" package \ + package "$project" --output "${package#"$root/"}" +done + +printf '%s\n' 'relay-check: five production Relay projects passed check, generate, test, and package' diff --git a/scripts/check-release-pins.py b/scripts/check-release-pins.py deleted file mode 100755 index 0120e3d..0000000 --- a/scripts/check-release-pins.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 -"""Verify Registry Stack release inputs match a published release tag.""" - -from __future__ import annotations - -import os -import re -import subprocess -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -IMAGE_KEYS = { - "REGISTRY_RELAY_IMAGE": "ghcr.io/registrystack/registry-relay", - "REGISTRY_NOTARY_IMAGE": "ghcr.io/registrystack/registry-notary", -} -REGISTRY_STACK_REMOTE = "https://github.com/registrystack/registry-stack.git" -PIN_RE = re.compile(r"^(?P[^@\s]+)@(?Psha256:[0-9a-f]{64})$") -DIGEST_RE = re.compile(r"^Digest:\s+(sha256:[0-9a-f]{64})$", re.MULTILINE) -TAG_RE = re.compile( - r"^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" - r"(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$" -) -COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") - - -def main(argv: list[str]) -> int: - if len(argv) != 2 or not argv[1]: - print("usage: check-release-pins.py ", file=sys.stderr) - return 2 - - tag = argv[1] - if not TAG_RE.fullmatch(tag): - print( - "check-release-pins: tag must match " - "vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-PRERELEASE", - file=sys.stderr, - ) - return 2 - - versions = read_versions(ROOT / "versions.env") - failures: list[str] = [] - release_version = tag.removeprefix("v") - - registryctl_version = versions.get("REGISTRYCTL_VERSION") - if registryctl_version != release_version: - failures.append( - "REGISTRYCTL_VERSION from versions.env " - f"is {registryctl_version or 'missing'}, expected {release_version}" - ) - - source_ref = versions.get("REGISTRY_STACK_SOURCE_REF") - if source_ref != tag: - failures.append( - "REGISTRY_STACK_SOURCE_REF from versions.env " - f"is {source_ref or 'missing'}, expected {tag}" - ) - source_commit = versions.get("REGISTRY_STACK_SOURCE_COMMIT") - if not source_commit or not COMMIT_RE.fullmatch(source_commit): - failures.append( - "REGISTRY_STACK_SOURCE_COMMIT must be exactly 40 lowercase hex characters" - ) - - for key in IMAGE_KEYS: - pinned = versions.get(key) - override = os.environ.get(key) - if pinned and override and override != pinned: - failures.append(f"{key} environment override must match versions.env") - - if failures: - for failure in failures: - print(f"check-release-pins: {failure}", file=sys.stderr) - return 1 - - try: - tag_commit = resolve_tag_commit(tag) - except (RuntimeError, subprocess.CalledProcessError): - print( - f"check-release-pins: could not resolve Registry Stack tag {tag}", - file=sys.stderr, - ) - return 1 - if source_commit != tag_commit: - print( - "check-release-pins: REGISTRY_STACK_SOURCE_COMMIT from versions.env " - f"is {source_commit}, but {tag} resolves to {tag_commit}", - file=sys.stderr, - ) - return 1 - - for key, image in IMAGE_KEYS.items(): - pinned = versions.get(key) - if not pinned: - failures.append(f"{key} is missing from versions.env") - continue - match = PIN_RE.match(pinned) - if not match: - failures.append(f"{key} must be image@sha256:") - continue - if match.group("image") != image: - failures.append(f"{key} points at {match.group('image')}, expected {image}") - continue - release_digest = inspect_tag_digest(f"{image}:{tag}") - if release_digest != match.group("digest"): - failures.append( - f"{key} from versions.env pins {match.group('digest')}, " - f"but {image}:{tag} resolves to {release_digest}" - ) - - if failures: - for failure in failures: - print(f"check-release-pins: {failure}", file=sys.stderr) - return 1 - - print(f"check-release-pins: Registry Stack release inputs match {tag}") - return 0 - - -def read_versions(path: Path) -> dict[str, str]: - values: dict[str, str] = {} - for raw in path.read_text().splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - values[key] = value - return values - - -def inspect_tag_digest(ref: str) -> str: - result = subprocess.run( - ["docker", "buildx", "imagetools", "inspect", ref], - cwd=ROOT, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - match = DIGEST_RE.search(result.stdout) - if not match: - raise RuntimeError(f"could not find digest in `docker buildx imagetools inspect {ref}` output") - return match.group(1) - - -def resolve_tag_commit(tag: str) -> str: - direct_ref = f"refs/tags/{tag}" - peeled_ref = f"{direct_ref}^{{}}" - result = subprocess.run( - ["git", "ls-remote", REGISTRY_STACK_REMOTE, direct_ref, peeled_ref], - cwd=ROOT, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - refs: dict[str, str] = {} - for raw_line in result.stdout.splitlines(): - fields = raw_line.split() - if len(fields) != 2 or fields[1] not in {direct_ref, peeled_ref}: - raise RuntimeError("Registry Stack tag lookup returned an invalid ref") - commit, ref = fields - if ref in refs or not COMMIT_RE.fullmatch(commit): - raise RuntimeError("Registry Stack tag lookup returned an invalid commit") - refs[ref] = commit - - commit = refs.get(peeled_ref) or refs.get(direct_ref) - if not commit: - raise RuntimeError("Registry Stack tag lookup returned no matching tag") - return commit - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/scripts/check-runtime-topology.py b/scripts/check-runtime-topology.py new file mode 100755 index 0000000..29400f6 --- /dev/null +++ b/scripts/check-runtime-topology.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Fail closed when the active deployment regresses to retired topology.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ACTIVE = [ + ROOT / "compose.yaml", + ROOT / "compose.hosted.yaml", + ROOT / "compose.esignet.yaml", + ROOT / "justfile", + ROOT / "config/evidence/Caddyfile", + ROOT / "config/walt/Caddyfile", + ROOT / "config/walt/README.md", + ROOT / "config/walt/registration-defaults.conf", + *sorted(ROOT.glob("compose.coolify*.yaml")), +] +FORBIDDEN = { + "retired database": re.compile(r"(?i)\bpostgres(?:ql)?\b"), + "retired workload agent": re.compile(r"(?i)workload[-_ ](?:identity[-_ ])?agent"), + "retired Notary": re.compile(r"(?i)\bnotary\b"), + "retired data-purpose": re.compile(r"(?i)data[-_]purpose"), + "retired authoring CLI": re.compile(r"\bregistryctl\b"), + "retired dataset route": re.compile(r"/v1/datasets\b"), +} + + +def failures(paths: list[Path] = ACTIVE) -> list[str]: + found: list[str] = [] + for path in paths: + text = path.read_text(encoding="utf-8") + # eSignet is the only surviving stateful third-party component. Its + # database is deliberately isolated from Registry Stack runtime state. + suppress_database = path.name in {"compose.esignet.yaml", "compose.coolify.esignet.yaml"} + for label, pattern in FORBIDDEN.items(): + if suppress_database and label == "retired database": + continue + for match in pattern.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + found.append(f"{path.name}:{line}: {label}") + return found + + +def main() -> int: + found = failures() + if found: + print("\n".join(found), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-signer-public-keys.py b/scripts/check-signer-public-keys.py new file mode 100755 index 0000000..fc476a1 --- /dev/null +++ b/scripts/check-signer-public-keys.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Verify every generated provider public JWK matches its operator private JWK.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROVIDERS = ("mint", "cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") +PUBLIC_MEMBERS = ("alg", "crv", "kid", "kty", "x", "y") + + +def failures(root: Path = ROOT) -> list[str]: + result: list[str] = [] + for provider in PROVIDERS: + private_path = root / "config/evidence/local/cells" / provider / "secrets/signing.jwk" + public_root = ( + root / "runtime/evidence-cells/mint/public-keys" + if provider == "mint" + else root / "runtime/evidence-cells/cells" / provider / "bundle/public-keys" + ) + try: + private = json.loads(private_path.read_text(encoding="utf-8")) + public_path = public_root / f"{private['kid']}.jwk.json" + public = json.loads(public_path.read_text(encoding="utf-8")) + except (FileNotFoundError, KeyError, json.JSONDecodeError): + result.append(f"{provider}: signer material is missing or invalid") + continue + if set(public) != set(PUBLIC_MEMBERS) or any(public.get(name) != private.get(name) for name in PUBLIC_MEMBERS): + result.append(f"{provider}: generated public key does not match operator signer") + return result + + +def main() -> int: + found = failures() + if found: + print("\n".join(found), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/contract-generation-proof.py b/scripts/contract-generation-proof.py deleted file mode 100755 index a6d898c..0000000 --- a/scripts/contract-generation-proof.py +++ /dev/null @@ -1,607 +0,0 @@ -#!/usr/bin/env python3 -"""Prove a compiler-generated SRO Relay/Notary blue-green transition. - -The proof is intentionally bounded to one authority. It compiles two complete -generations with the pinned registryctl, rejects the mixed pair during Notary -activation, and then activates the complete successor generation. -""" - -from __future__ import annotations - -import json -import os -import re -import shlex -import shutil -import subprocess -import sys -import tempfile -import time -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any, Callable, Mapping, NoReturn, Sequence - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] -PROJECT = ROOT / "projects" / "sro-social" -PURPOSE = "https://id.registrystack.org/solmara/purpose/child-benefit-review" -CLAIM_ID = "household-below-poverty-threshold" -BLUE_SUBJECT = "2300027390" -GREEN_SUBJECT = "2300018263" -RESULT_FORMAT = "application/vnd.registry-notary.claim-result+json" -MIXED_GENERATION_PROBLEM_CODE = "notary.relay.profile_mismatch" -SENSITIVE_ENV_MARKERS = ("TOKEN", "PASSWORD", "SECRET", "JWK") -MAX_DIAGNOSTIC_LINES = 12 -MAX_DIAGNOSTIC_LINE_BYTES = 256 -MAX_DIAGNOSTIC_BYTES = 4096 -ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -REGISTRY_RELAY_IMAGE = re.compile( - r"ghcr\.io/registrystack/registry-relay@sha256:[0-9a-f]{64}" -) - - -class ProofFailure(RuntimeError): - """A bounded proof assertion failed.""" - - -def read_env(path: Path) -> dict[str, str]: - values: dict[str, str] = {} - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - name, raw_value = line.split("=", 1) - parts = shlex.split(raw_value, posix=True) - if len(parts) > 1: - raise ProofFailure(f"{path.name} contains an invalid value for {name.strip()}") - values[name.strip()] = parts[0] if parts else "" - return values - - -def relay_image(environment: Mapping[str, str]) -> str: - image = environment.get("REGISTRY_RELAY_IMAGE", "") - if REGISTRY_RELAY_IMAGE.fullmatch(image) is None: - raise ProofFailure( - "REGISTRY_RELAY_IMAGE must pin the canonical Relay release by digest" - ) - return image - - -def diagnostic_environment(environment: Mapping[str, str] | None) -> dict[str, str]: - return dict(os.environ if environment is None else environment) - - -def bounded_redacted_output( - output: str, environment: Mapping[str, str] | None -) -> str: - redacted = output - values = [BLUE_SUBJECT, GREEN_SUBJECT] - values.extend( - value - for name, value in diagnostic_environment(environment).items() - if value and any(marker in name for marker in SENSITIVE_ENV_MARKERS) - ) - for value in sorted(set(values), key=len, reverse=True): - redacted = redacted.replace(value, "[redacted]") - redacted = ANSI_ESCAPE.sub("", redacted) - lines = [ - "".join( - character - if character.isprintable() or character == "\t" - else "?" - for character in line - ) - for line in redacted.splitlines() - ] - if not lines: - return "(no command output captured)" - if len(lines) > MAX_DIAGNOSTIC_LINES: - lines = [*lines[:6], "... output lines omitted ...", *lines[-5:]] - bounded_lines = [] - for line in lines: - encoded = line.encode("utf-8") - if len(encoded) > MAX_DIAGNOSTIC_LINE_BYTES: - line = encoded[:MAX_DIAGNOSTIC_LINE_BYTES].decode("utf-8", errors="ignore") - line += "..." - bounded_lines.append(line) - bounded = "\n".join(bounded_lines) - encoded = bounded.encode("utf-8") - if len(encoded) > MAX_DIAGNOSTIC_BYTES: - bounded = encoded[:MAX_DIAGNOSTIC_BYTES].decode("utf-8", errors="ignore") - return bounded - - -def command_failure( - executable: str, - returncode: int, - output: str, - environment: Mapping[str, str] | None, -) -> ProofFailure: - diagnostic = bounded_redacted_output(output, environment) - return ProofFailure( - f"{Path(executable).name} command failed with exit {returncode}\n" - f"command output (redacted and bounded):\n{diagnostic}" - ) - - -def run( - arguments: Sequence[str], - *, - environment: Mapping[str, str] | None = None, - timeout: int = 240, - check: bool = True, -) -> subprocess.CompletedProcess[str]: - result = subprocess.run( - list(arguments), - cwd=ROOT, - env=dict(environment) if environment is not None else None, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=timeout, - check=False, - ) - if check and result.returncode != 0: - raise command_failure(arguments[0], result.returncode, result.stdout, environment) - return result - - -def preserve_cleanup_failure( - cleanup: Callable[[], subprocess.CompletedProcess[str]], - *, - environment: Mapping[str, str] | None, - primary_failure_active: bool, -) -> None: - failure: ProofFailure | None = None - try: - result = cleanup() - except subprocess.TimeoutExpired: - failure = ProofFailure("docker Compose cleanup timed out") - except OSError: - failure = ProofFailure("docker Compose cleanup could not start") - else: - if result.returncode != 0: - failure = command_failure( - "docker compose cleanup", - result.returncode, - result.stdout, - environment, - ) - if failure is None: - return - if primary_failure_active: - print( - f"contract-generation-proof: secondary cleanup failure: {failure}", - file=sys.stderr, - ) - return - raise failure - - -def raise_mixed_notary_timeout( - container_name: str, - timeout_error: subprocess.TimeoutExpired, - *, - environment: Mapping[str, str] | None, - emergency_cleanup: Callable[[], subprocess.CompletedProcess[str]] | None = None, -) -> NoReturn: - cleanup = emergency_cleanup or ( - lambda: run( - ["docker", "rm", "--force", container_name], - environment=environment, - timeout=30, - check=False, - ) - ) - preserve_cleanup_failure( - cleanup, - environment=environment, - primary_failure_active=True, - ) - raise ProofFailure("mixed-generation Notary unexpectedly kept serving") from timeout_error - - -def make_successor(project: Path) -> None: - integration = project / "integrations" / "child-benefit-household-by-uin" / "integration.yaml" - document = yaml.safe_load(integration.read_text(encoding="utf-8")) - if document.get("id") != "child-benefit-household-by-uin" or document.get("revision") != 1: - raise ProofFailure("the SRO integration no longer has the expected blue revision") - document["revision"] = 2 - integration.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") - - -def make_runtime_readable(root: Path) -> None: - root.chmod(0o755) - for path in root.rglob("*"): - path.chmod(0o755 if path.is_dir() else 0o644) - - -def build_generation(registryctl: Path, source: Path, destination: Path) -> str: - run( - [ - str(registryctl), - "build", - "--project-dir", - str(source), - "--environment", - "local", - ] - ) - private = source / ".registry-stack" / "build" / "local" / "private" - relay = destination / "relay" - notary = destination / "notary" - shutil.copytree(private / "relay" / "config", relay) - notary.mkdir(parents=True) - shutil.copy2(private / "notary" / "config" / "notary.yaml", notary / "notary.yaml") - make_runtime_readable(relay) - make_runtime_readable(notary) - document = yaml.safe_load((notary / "notary.yaml").read_text(encoding="utf-8")) - return document["evidence"]["claims"][0]["evidence_mode"]["consultations"]["household"]["profile"]["contract_hash"] - - -def write_override(path: Path, generation: Path, *, relay: bool, notary: bool) -> None: - services: dict[str, Any] = {} - relay_mounts = [ - f"{generation / 'relay'}:/etc/registry-relay:ro", - ] - notary_mounts = [ - f"{generation / 'notary' / 'notary.yaml'}:/etc/registry-notary/notary.yaml:ro", - ] - if relay: - services["sro-relay-state-bootstrap"] = {"volumes": relay_mounts} - services["sro-social-relay-consultation"] = {"volumes": relay_mounts} - if notary: - services["sro-notary-state-install"] = {"volumes": notary_mounts} - services["sro-notary"] = {"volumes": notary_mounts} - path.write_text(yaml.safe_dump({"services": services}, sort_keys=False), encoding="utf-8") - - -def compose_command(project_name: str, override: Path | None = None) -> list[str]: - command = [ - "docker", - "compose", - "--project-name", - project_name, - "--env-file", - str(ROOT / "versions.env"), - "--env-file", - str(ROOT / ".env"), - "--file", - str(ROOT / "compose.yaml"), - ] - if override is not None: - command.extend(["--file", str(override)]) - return command - - -def start_generation( - compose: Sequence[str], environment: Mapping[str, str] -) -> None: - result = run( - [ - *compose, - "up", - "--detach", - "--wait", - "--wait-timeout", - "180", - "sro-notary", - ], - environment=environment, - timeout=300, - check=False, - ) - if result.returncode == 0: - return - logs = run( - [ - *compose, - "logs", - "--no-color", - "postgres", - "registry-postgresql-bootstrap", - "sro-relay-state-bootstrap", - "sro-social-relay-consultation", - ], - environment=environment, - timeout=30, - check=False, - ) - raise command_failure( - "docker", - result.returncode, - f"{result.stdout}\nfailed service logs:\n{logs.stdout}", - environment, - ) - - -def wait_for_ready(url: str, timeout: int = 120) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - with urllib.request.urlopen(f"{url}/ready", timeout=2) as response: - if response.status in (200, 204): - return - except (OSError, urllib.error.URLError): - pass - time.sleep(1) - raise ProofFailure("SRO Notary did not become ready") - - -def shared_notary_url(compose: Sequence[str], environment: Mapping[str, str]) -> str: - result = run( - [*compose, "port", "sro-social-relay-consultation", "8081"], - environment=environment, - ) - address = result.stdout.strip().splitlines()[-1] - match = re.search(r":([0-9]+)$", address) - if match is None: - raise ProofFailure( - "could not resolve the SRO Notary port shared by its consultation Relay" - ) - return f"http://127.0.0.1:{match.group(1)}" - - -def evaluate(url: str, token: str, subject: str) -> dict[str, Any]: - body = json.dumps( - { - "target": { - "type": "Person", - "identifiers": [{"scheme": "solmara_uin", "value": subject}], - }, - "claims": [CLAIM_ID], - "disclosure": "predicate", - "format": RESULT_FORMAT, - }, - separators=(",", ":"), - ).encode("utf-8") - request = urllib.request.Request( - f"{url}/v1/evaluations", - data=body, - method="POST", - headers={ - "Accept": RESULT_FORMAT, - "Content-Type": "application/json", - "Data-Purpose": PURPOSE, - "x-api-key": token, - }, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: - payload = json.loads(response.read().decode("utf-8")) - status = response.status - except urllib.error.HTTPError as error: - error.read() - raise ProofFailure(f"SRO evaluation returned HTTP {error.code}") from error - if status != 200 or not successful_evaluation(payload, subject): - raise ProofFailure("SRO evaluation did not return the expected minimized predicate") - return payload - - -def successful_evaluation(payload: Any, subject: str) -> bool: - if not isinstance(payload, dict) or subject in json.dumps(payload, sort_keys=True): - return False - results = payload.get("results") - if not isinstance(results, list) or len(results) != 1 or not isinstance(results[0], dict): - return False - result = results[0] - return ( - result.get("claim_id") == CLAIM_ID - and result.get("disclosure") == "predicate" - and (result.get("value") is True or result.get("satisfied") is True) - ) - - -def relay_activity_counts( - compose: Sequence[str], environment: Mapping[str, str] -) -> tuple[int, int]: - state_epoch = environment.get("REGISTRY_RELAY_STATE_EPOCH", "") - if re.fullmatch(r"[a-z0-9_]+", state_epoch) is None: - raise ProofFailure("REGISTRY_RELAY_STATE_EPOCH is missing or invalid") - sql = ( - "SELECT (SELECT count(*) FROM relay_state_private.consultation_completion_intent)," - "(SELECT count(*) FROM relay_state_private.dispatch_permit WHERE dispatched_at IS NOT NULL);" - ) - user = environment.get("SOLMARA_POSTGRES_USER", "solmara_registry") - result = run( - [ - *compose, - "exec", - "--no-TTY", - "postgres", - "psql", - "--username", - user, - "--dbname", - f"solmara_relay_sro_consultation_{state_epoch}", - "--tuples-only", - "--no-align", - "--command", - sql, - ], - environment=environment, - ) - match = re.fullmatch(r"\s*([0-9]+)\|([0-9]+)\s*", result.stdout) - if match is None: - raise ProofFailure("could not read the SRO Relay execution counters") - return int(match.group(1)), int(match.group(2)) - - -def sensitive_patterns(environment: Mapping[str, str]) -> dict[str, bytes]: - patterns = { - "blue synthetic subject": BLUE_SUBJECT.encode("utf-8"), - "successor synthetic subject": GREEN_SUBJECT.encode("utf-8"), - } - for name, value in environment.items(): - if len(value) >= 16 and any(marker in name for marker in SENSITIVE_ENV_MARKERS): - patterns[f"credential from {name}"] = value.encode("utf-8") - return patterns - - -def scan_paths(paths: Sequence[Path], patterns: Mapping[str, bytes]) -> None: - for root in paths: - candidates = [root] if root.is_file() else sorted(path for path in root.rglob("*") if path.is_file()) - for path in candidates: - data = path.read_bytes() - for description, pattern in patterns.items(): - if pattern in data: - raise ProofFailure(f"{description} appeared in captured artifact {path.name}") - - -def main() -> int: - if not (ROOT / ".env").is_file(): - raise ProofFailure(".env is missing; run `just generate` first") - environment = read_env(ROOT / "versions.env") - environment.update(read_env(ROOT / ".env")) - environment.update(os.environ) - environment.update( - { - "REGISTRY_RELAY_IMAGE": relay_image(environment), - "SOLMARA_POSTGRES_PORT": "0", - "SOLMARA_SRO_RELAY_PORT": "0", - "SOLMARA_SRO_NOTARY_PORT": "0", - } - ) - token = environment.get("SRO_CHILD_BENEFIT_CLIENT_TOKEN", "") - if not token: - raise ProofFailure("generated SRO client credential is missing") - - registryctl_result = run([str(ROOT / "scripts" / "registryctl-pinned.sh"), "path"]) - registryctl = Path(registryctl_result.stdout.strip()) - project_name = f"solmara-contract-proof-{os.getpid()}" - - with tempfile.TemporaryDirectory(prefix="solmara-contract-proof-") as temporary: - workspace = Path(temporary) - blue_project = workspace / "projects" / "blue" - green_project = workspace / "projects" / "green" - shutil.copytree(PROJECT, blue_project) - shutil.copytree(PROJECT, green_project) - make_successor(green_project) - - print("contract-generation-proof: compiling blue and successor generations") - blue = workspace / "generations" / "blue" - green = workspace / "generations" / "green" - blue_hash = build_generation(registryctl, blue_project, blue) - green_hash = build_generation(registryctl, green_project, green) - if blue_hash == green_hash: - raise ProofFailure("the harmless successor did not move the consultation contract hash") - - blue_override = workspace / "blue.compose.yaml" - mixed_override = workspace / "mixed.compose.yaml" - green_override = workspace / "green.compose.yaml" - write_override(blue_override, blue, relay=True, notary=True) - write_override(mixed_override, green, relay=False, notary=True) - write_override(green_override, green, relay=True, notary=True) - blue_compose = compose_command(project_name, blue_override) - mixed_compose = compose_command(project_name, mixed_override) - green_compose = compose_command(project_name, green_override) - evidence = workspace / "evidence" - evidence.mkdir() - - try: - print("contract-generation-proof: starting the complete blue generation") - start_generation(blue_compose, environment) - blue_url = shared_notary_url(blue_compose, environment) - wait_for_ready(blue_url) - blue_response = evaluate(blue_url, token, BLUE_SUBJECT) - (evidence / "blue-response.json").write_text( - json.dumps(blue_response, sort_keys=True), encoding="utf-8" - ) - before_mixed = relay_activity_counts(blue_compose, environment) - if before_mixed[0] < 1: - raise ProofFailure("the blue generation did not execute its Relay consultation") - - print("contract-generation-proof: rejecting the mixed blue Relay / successor Notary") - run([*blue_compose, "stop", "sro-notary"], environment=environment) - run( - [ - *mixed_compose, - "run", - "--rm", - "--no-deps", - "sro-notary-state-install", - ], - environment=environment, - ) - mixed_name = f"{project_name}-mixed-notary" - try: - mixed = run( - [ - *mixed_compose, - "run", - "--rm", - "--no-deps", - "--name", - mixed_name, - "sro-notary", - ], - environment=environment, - timeout=45, - check=False, - ) - except subprocess.TimeoutExpired as error: - raise_mixed_notary_timeout( - mixed_name, - error, - environment=environment, - ) - (evidence / "mixed-notary.log").write_text(mixed.stdout, encoding="utf-8") - if mixed.returncode == 0: - raise ProofFailure("mixed-generation Notary unexpectedly activated") - expected_failure = f"ERROR {MIXED_GENERATION_PROBLEM_CODE}:" - if expected_failure not in mixed.stdout: - diagnostic = bounded_redacted_output(mixed.stdout, environment) - raise ProofFailure( - "mixed-generation Notary failed for an unexpected reason\n" - f"command output (redacted and bounded):\n{diagnostic}" - ) - after_mixed = relay_activity_counts(blue_compose, environment) - if after_mixed != before_mixed: - raise ProofFailure("mixed-generation activation reached Relay execute or source dispatch") - - blue_logs = run([*blue_compose, "logs", "--no-color"], environment=environment) - (evidence / "blue-services.log").write_text(blue_logs.stdout, encoding="utf-8") - run([*blue_compose, "down", "--remove-orphans"], environment=environment) - - print("contract-generation-proof: activating the complete successor generation") - start_generation(green_compose, environment) - green_url = shared_notary_url(green_compose, environment) - wait_for_ready(green_url) - green_response = evaluate(green_url, token, GREEN_SUBJECT) - (evidence / "green-response.json").write_text( - json.dumps(green_response, sort_keys=True), encoding="utf-8" - ) - after_green = relay_activity_counts(green_compose, environment) - if after_green[0] <= after_mixed[0]: - raise ProofFailure("the complete successor did not execute its Relay consultation") - (evidence / "green-services.log").write_text( - run([*green_compose, "logs", "--no-color"], environment=environment).stdout, - encoding="utf-8", - ) - - scan_paths([blue, green, evidence], sensitive_patterns(environment)) - finally: - preserve_cleanup_failure( - lambda: run( - [*green_compose, "down", "--volumes", "--remove-orphans"], - environment=environment, - timeout=120, - check=False, - ), - environment=environment, - primary_failure_active=sys.exc_info()[0] is not None, - ) - - print("contract-generation-proof: blue success, mixed rejection, and successor success passed") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except ProofFailure as error: - print(f"contract-generation-proof: {error}", file=sys.stderr) - raise SystemExit(1) from error diff --git a/scripts/gen-secrets.py b/scripts/gen-secrets.py index 67fde6a..c4ab270 100755 --- a/scripts/gen-secrets.py +++ b/scripts/gen-secrets.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 -"""Generate local .env credentials for Solmara Lab.""" +"""Create ignored local operator keys and the Compose environment.""" from __future__ import annotations -import argparse import base64 import hashlib import json @@ -11,248 +10,228 @@ import shlex import subprocess from pathlib import Path +from typing import Callable + +from cryptography.hazmat.primitives.asymmetric import ec, rsa from compose_project_name import compose_project_name ROOT = Path(__file__).resolve().parents[1] -POSTGRES_SSL_DIR = ROOT / "config" / "postgres" / "ssl" - -RAW_HASH_PAIRS = [ - ("CRA_CHILD_BENEFIT_CLIENT_TOKEN", "CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH"), - ("CRA_PENSION_CLIENT_TOKEN", "CRA_PENSION_CLIENT_TOKEN_HASH"), - ("CRA_CITIZEN_CLIENT_TOKEN", "CRA_CITIZEN_CLIENT_TOKEN_HASH"), - ("NIA_CHILD_BENEFIT_CLIENT_TOKEN", "NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH"), - ("NIA_CITIZEN_CLIENT_TOKEN", "NIA_CITIZEN_CLIENT_TOKEN_HASH"), - ("SRO_CHILD_BENEFIT_CLIENT_TOKEN", "SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH"), - ( - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - ), - ("SIPF_PENSION_CLIENT_TOKEN", "SIPF_PENSION_CLIENT_TOKEN_HASH"), - ("NAGDI_NOTARY_TOKEN", "NAGDI_CLIENT_TOKEN_HASH"), -] - -JWK_KIDS = { - "CRA_RELAY_WORKLOAD_JWK": "solmara-cra-relay-workload-key-1", - "NIA_RELAY_WORKLOAD_JWK": "solmara-nia-relay-workload-key-1", - "NIA_ESIGNET_RELAY_WORKLOAD_JWK": "solmara-nia-esignet-relay-workload-key-1", - "SRO_RELAY_WORKLOAD_JWK": "solmara-sro-relay-workload-key-1", - "PROGRAMME_RELAY_WORKLOAD_JWK": "solmara-programme-relay-workload-key-1", - "SIPF_RELAY_WORKLOAD_JWK": "solmara-sipf-relay-workload-key-1", - "NAGDI_RELAY_WORKLOAD_JWK": "solmara-nagdi-relay-workload-key-1", - "NIA_NOTARY_ISSUER_JWK": "did:web:id.registrystack.org:solmara:authority:nia#issuer-key-1", - "SIPF_NOTARY_ISSUER_JWK": "did:web:id.registrystack.org:solmara:authority:sipf#issuer-key-1", - "NAGDI_NOTARY_ISSUER_JWK": "did:web:id.registrystack.org:solmara:authority:nagdi#issuer-key-1", -} - -DIRECT_PROJECT_SECRET_NAMES = { - "SOLMARA_NIA_DATABASE_URL", - "SOLMARA_SIPF_DATABASE_URL", -} +LOCAL = ROOT / "config/evidence/local" +RANDOM_ENV_KEYS = ( + "CRA_RELAY_AUDIT_KEY", + "NIA_RELAY_AUDIT_KEY", + "MOSD_RELAY_AUDIT_KEY", + "SIPF_RELAY_AUDIT_KEY", + "SIPF_RELAY_CURSOR_KEY", + "NAGDI_RELAY_AUDIT_KEY", + "NAGDI_RELAY_CURSOR_KEY", + "CHILD_BENEFIT_FEDERATOR_TOKEN", + "PORTAL_SESSION_SECRET", + "SOLMARA_ESIGNET_POSTGRES_PASSWORD", + "REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD", + "REGISTRY_ESIGNET_KYC_TOKEN_SECRET", + "REGISTRY_ESIGNET_PSUT_SECRET", +) + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def raw_key() -> str: return secrets.token_urlsafe(32) -def fingerprint(value: str) -> str: - return "sha256:" + hashlib.sha256(value.encode("ascii")).hexdigest() +def p256_jwk() -> str: + private = ec.generate_private_key(ec.SECP256R1()).private_numbers() + public = private.public_numbers + jwk = { + "kty": "EC", "crv": "P-256", "alg": "ES256", + "x": b64url(public.x.to_bytes(32, "big")), + "y": b64url(public.y.to_bytes(32, "big")), + "d": b64url(private.private_value.to_bytes(32, "big")), + } + thumbprint = {key: jwk[key] for key in ("crv", "kty", "x", "y")} + jwk["kid"] = b64url(hashlib.sha256(json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode()).digest()) + return json.dumps(jwk, separators=(",", ":"), sort_keys=True) -def b64url(raw: bytes) -> str: - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") +def rsa_jwk() -> str: + private = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_numbers() + public = private.public_numbers + def encode(number: int) -> str: + return b64url(number.to_bytes((number.bit_length() + 7) // 8, "big")) -def local_ed25519_jwk(kid: str) -> str: - private_der = subprocess.run( - ["openssl", "genpkey", "-algorithm", "ED25519", "-outform", "DER"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ).stdout - public_der = subprocess.run( - ["openssl", "pkey", "-inform", "DER", "-pubout", "-outform", "DER"], - input=private_der, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ).stdout - private_seed = private_der[-32:] - public_key = public_der[-32:] jwk = { - "kty": "OKP", - "crv": "Ed25519", - "kid": kid, - "alg": "EdDSA", - "x": b64url(public_key), - "d": b64url(private_seed), + "kty": "RSA", "alg": "RS256", "n": encode(public.n), "e": encode(public.e), + "d": encode(private.d), "p": encode(private.p), "q": encode(private.q), + "dp": encode(private.dmp1), "dq": encode(private.dmq1), "qi": encode(private.iqmp), } + thumbprint = {key: jwk[key] for key in ("e", "kty", "n")} + jwk["kid"] = b64url(hashlib.sha256(json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode()).digest()) return json.dumps(jwk, separators=(",", ":"), sort_keys=True) -def local_rsa_private_key_b64() -> str: - private_pem = subprocess.run( - ["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ).stdout - return base64.b64encode(private_pem).decode("ascii") - - -def ensure_postgres_tls() -> None: - POSTGRES_SSL_DIR.mkdir(parents=True, exist_ok=True) - key_path = POSTGRES_SSL_DIR / "server.key" - cert_path = POSTGRES_SSL_DIR / "server.crt" - for path in (key_path, cert_path): - path.unlink(missing_ok=True) - subprocess.run( - [ - "openssl", - "req", - "-x509", - "-newkey", - "rsa:2048", - "-nodes", - "-days", - "365", - "-subj", - "/CN=postgres", - "-addext", - "subjectAltName=DNS:postgres,IP:127.0.0.1", - "-keyout", - str(key_path), - "-out", - str(cert_path), - ], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, +def write_private(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value.rstrip("\n") + "\n", encoding="utf-8") + path.chmod(0o600) + + +def create_once(path: Path, factory) -> None: + if not path.exists(): + write_private(path, factory()) + + +def load_environment(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + values: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise ValueError("generated environment contains a malformed entry") + key, encoded = line.split("=", 1) + if key in values: + raise ValueError("generated environment contains a duplicate entry") + try: + parsed = shlex.split(encoded, posix=True) + except ValueError as exc: + raise ValueError( + "generated environment contains a malformed value" + ) from exc + if len(parsed) != 1 or not parsed[0]: + raise ValueError(f"generated environment value is invalid for {key}") + values[key] = parsed[0] + return values + + +def create_environment_value( + existing: dict[str, str], key: str, factory: Callable[[], str] +) -> str: + value = existing.get(key) + if value is not None: + if not value: + raise ValueError(f"generated environment value is invalid for {key}") + return value + return factory() + + +def compose_environment_values( + existing: dict[str, str], operator_values: dict[str, str] +) -> dict[str, str]: + values = { + key: create_environment_value(existing, key, raw_key) + for key in RANDOM_ENV_KEYS + } + values["PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64"] = create_environment_value( + existing, "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64", rsa_private_key_b64 ) - key_path.chmod(0o600) - cert_path.chmod(0o644) - - -def env_line(key: str, value: str) -> str: - return f"{key}={shlex.quote(value)}" - + values.update( + { + "COMPOSE_PROJECT_NAME": compose_project_name(ROOT), + "PORTAL_AUTH_PROVIDER": "mock", + "PORTAL_ESIGNET_CLIENT_ID": "solmara-portal", + "PORTAL_ESIGNET_CLIENT_KEY_ID": "solmara-portal-key-1", + **operator_values, + } + ) + return values + + +def ensure_client_identifier(path: Path, client_id: str) -> None: + """Write an exact public identifier while preserving unrelated material.""" + + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + current = path.read_text(encoding="utf-8") + if current == client_id: + path.chmod(0o600) + return + # Migrate the previous generator's single trailing newline only. Any + # other value is operator-owned divergence and must fail closed. + if current != f"{client_id}\n": + raise ValueError(f"client identifier does not match {path.name}") + path.write_text(client_id, encoding="utf-8") + path.chmod(0o600) + + +def ensure_operator_material() -> dict[str, str]: + cells = { + "cra": ("cra-pension-evidence", "cra-citizen-evidence"), + "nia": (), "sro": (), + "mosd-programme": ("mosd-child-benefit-evidence",), + "sipf": ("sipf-pension-evidence", "sipf-survivor-evidence"), + "nagdi": ("nagdi-voucher-evidence", "nagdi-livestock-evidence"), + } + for cell, clients in cells.items(): + secret_root = LOCAL / "cells" / cell / "secrets" + (LOCAL / "cells" / cell / "transit").mkdir(parents=True, exist_ok=True) + create_once(secret_root / "signing.jwk", p256_jwk) + create_once(secret_root / "audit-hmac-key", raw_key) + create_once(secret_root / "subject-binding-hmac-key", raw_key) + for client in clients: + create_once(secret_root / f"{client}-client-key", p256_jwk) + ensure_client_identifier(secret_root / f"{client}-client-id", client) + + mint = LOCAL / "cells" / "mint" + for directory in (mint / "secrets", mint / "clients", mint / "transit"): + directory.mkdir(parents=True, exist_ok=True) + create_once(mint / "secrets/signing.jwk", p256_jwk) + create_once(mint / "secrets/audit-hmac-key", raw_key) + create_once(mint / "clients/nia-esignet-rsa-client-key", rsa_jwk) + create_once(mint / "clients/solmara-demo-client-key", p256_jwk) + return { + "NIA_ESIGNET_CLIENT_PRIVATE_JWK": (mint / "clients/nia-esignet-rsa-client-key").read_text().strip(), + "SOLMARA_EVIDENCE_CLIENT_KEY": str(mint / "clients/solmara-demo-client-key"), + } -def write_env_file(output: Path, values: dict[str, str], header: str) -> None: - lines = [header, *[env_line(key, values[key]) for key in sorted(values)]] - output.write_text("\n".join(lines) + "\n") - output.chmod(0o600) +def ensure_tls() -> None: + tls = LOCAL / "tls" + tls.mkdir(parents=True, exist_ok=True) + ca_key, ca_crt = tls / "ca.key", tls / "ca.crt" + key, crt, csr = tls / "gateway.key", tls / "gateway.crt", tls / "gateway.csr" + sans = [ + "localhost", "mint.solmara.registrystack.org", "evidence.solmara.invalid", + "cra-relay.solmara.registrystack.org", "mosd-programme-relay.solmara.registrystack.org", + "sipf-relay.solmara.registrystack.org", "nagdi-relay.solmara.registrystack.org", + ] + if all(path.exists() for path in (ca_key, ca_crt, key, crt)): + certificate = subprocess.run( + ["openssl", "x509", "-in", str(crt), "-noout", "-ext", "subjectAltName"], + check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ).stdout + if all(f"DNS:{name}" in certificate for name in sans): + return + for path in (ca_key, ca_crt, key, crt, csr, tls / "ca.srl"): + path.unlink(missing_ok=True) + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650", "-subj", "/CN=Solmara Lab CA", "-keyout", str(ca_key), "-out", str(ca_crt)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=localhost", "-addext", "subjectAltName=" + ",".join(f"DNS:{name}" for name in sans), "-keyout", str(key), "-out", str(csr)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-CA", str(ca_crt), "-CAkey", str(ca_key), "-CAcreateserial", "-days", "3650", "-copy_extensions", "copy", "-out", str(crt)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + csr.unlink() + (tls / "ca.srl").unlink(missing_ok=True) + ca_key.chmod(0o600) + key.chmod(0o600) + ca_crt.chmod(0o644) + crt.chmod(0o644) -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.parse_args(argv) - - ensure_postgres_tls() - postgres_user = "solmara_registry" - postgres_password = raw_key() - postgres_db = "solmara_lab" - nia_source_password = raw_key() - sipf_source_password = raw_key() - values: dict[str, str] = { - "COMPOSE_PROJECT_NAME": compose_project_name(ROOT), - "CRA_RELAY_AUDIT_HASH_SECRET": raw_key(), - "CRA_RELAY_AUDIT_PSEUDONYM_EPOCH_1": raw_key(), - "CRA_RELAY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD": raw_key(), - "CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD": raw_key(), - "CRA_NOTARY_AUDIT_HASH_SECRET": raw_key(), - "CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD": raw_key(), - "CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "NIA_RELAY_AUDIT_HASH_SECRET": raw_key(), - "NIA_RELAY_AUDIT_PSEUDONYM_EPOCH_1": raw_key(), - "NIA_RELAY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD": raw_key(), - "NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD": raw_key(), - "NIA_NOTARY_AUDIT_HASH_SECRET": raw_key(), - "NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD": raw_key(), - "NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "SRO_RELAY_AUDIT_HASH_SECRET": raw_key(), - "SRO_RELAY_AUDIT_PSEUDONYM_EPOCH_1": raw_key(), - "SRO_RELAY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD": raw_key(), - "SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD": raw_key(), - "SRO_NOTARY_AUDIT_HASH_SECRET": raw_key(), - "SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD": raw_key(), - "SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "PROGRAMME_RELAY_AUDIT_HASH_SECRET": raw_key(), - "PROGRAMME_RELAY_AUDIT_PSEUDONYM_EPOCH_1": raw_key(), - "PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD": raw_key(), - "PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD": raw_key(), - "PROGRAMME_NOTARY_AUDIT_HASH_SECRET": raw_key(), - "PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD": raw_key(), - "PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "SIPF_RELAY_AUDIT_HASH_SECRET": raw_key(), - "SIPF_RELAY_AUDIT_PSEUDONYM_EPOCH_1": raw_key(), - "SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD": raw_key(), - "SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD": raw_key(), - "SIPF_NOTARY_AUDIT_HASH_SECRET": raw_key(), - "SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD": raw_key(), - "SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "NAGDI_RELAY_AUDIT_HASH_SECRET": raw_key(), - "NAGDI_RELAY_AUDIT_PSEUDONYM_EPOCH_1": raw_key(), - "NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD": raw_key(), - "NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD": raw_key(), - "NAGDI_NOTARY_AUDIT_HASH_SECRET": raw_key(), - "NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD": raw_key(), - "NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD": raw_key(), - "SOLMARA_RELAY_KEY_ACTIVE_WRITE_DEADLINE_UNIX_MS": "4102444800000", - "SOLMARA_RELAY_AUDIT_EVENT_RETENTION_MS": "2592000000", - "REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD": raw_key(), - "REGISTRY_ESIGNET_KYC_TOKEN_SECRET": raw_key(), - "REGISTRY_ESIGNET_PSUT_SECRET": raw_key(), - "PORTAL_SESSION_SECRET": raw_key(), - "PORTAL_AUTH_PROVIDER": "mock", - "PORTAL_ESIGNET_CLIENT_ID": "solmara-portal", - "PORTAL_ESIGNET_CLIENT_KEY_ID": "solmara-portal-key-1", - "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64": local_rsa_private_key_b64(), - "PORTAL_ESIGNET_ISSUER": "http://127.0.0.1:4308", - "PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT": "http://127.0.0.1:4309/authorize", - "PORTAL_ESIGNET_TOKEN_ENDPOINT": "http://esignet:8088/v1/esignet/oauth/v2/token", - "PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE": "http://127.0.0.1:4308/v1/esignet/oauth/v2/token", - "PORTAL_ESIGNET_USERINFO_ENDPOINT": "http://esignet:8088/v1/esignet/oidc/userinfo", - "PORTAL_ESIGNET_REDIRECT_URI": "http://127.0.0.1:4300/auth/callback", - "PORTAL_ESIGNET_SCOPE": "openid profile", - "PORTAL_ESIGNET_SUBJECT_CLAIM": "individual_id", - "SOLMARA_ESIGNET_PUBLIC_BASE_URL": "http://127.0.0.1:4308", - "SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL": "http://127.0.0.1:4309", - "SOLMARA_POSTGRES_USER": postgres_user, - "SOLMARA_POSTGRES_PASSWORD": postgres_password, - "SOLMARA_POSTGRES_DB": postgres_db, - "NIA_SOURCE_POSTGRES_READER_PASSWORD": nia_source_password, - "SIPF_SOURCE_POSTGRES_READER_PASSWORD": sipf_source_password, - "SOLMARA_NIA_DATABASE_URL": f"postgres://solmara_source_nia_reader:{nia_source_password}@postgres:5432/{postgres_db}?sslmode=require", - "SOLMARA_SIPF_DATABASE_URL": f"postgres://solmara_source_sipf_reader:{sipf_source_password}@postgres:5432/{postgres_db}?sslmode=require", - "SOLMARA_ESIGNET_POSTGRES_PASSWORD": raw_key(), - "CHILD_BENEFIT_FEDERATOR_TOKEN": raw_key(), - "CHILD_BENEFIT_FEDERATOR_URL": "http://127.0.0.1:4321", - "CRA_NOTARY_URL": "http://127.0.0.1:4325", - "NIA_NOTARY_URL": "http://127.0.0.1:4326", - "SRO_NOTARY_URL": "http://127.0.0.1:4327", - "PROGRAMME_NOTARY_URL": "http://127.0.0.1:4328", - "SIPF_NOTARY_URL": "http://127.0.0.1:4322", - "NAGDI_NOTARY_URL": "http://127.0.0.1:4323", - } - for raw_name, hash_name in RAW_HASH_PAIRS: - raw = raw_key() - values[raw_name] = raw - values[hash_name] = fingerprint(raw) +def rsa_private_key_b64() -> str: + return base64.b64encode(subprocess.run(["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"], check=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout).decode() - for name, kid in JWK_KIDS.items(): - values[name] = local_ed25519_jwk(kid) +def main() -> int: + operator_values = ensure_operator_material() + ensure_tls() output = ROOT / ".env" - write_env_file( - output, values, "# Generated by scripts/gen-secrets.py. Do not commit." - ) + values = compose_environment_values(load_environment(output), operator_values) + output.write_text("# Generated by scripts/gen-secrets.py. Do not commit.\n" + "\n".join(f"{key}={shlex.quote(value)}" for key, value in sorted(values.items())) + "\n", encoding="utf-8") + output.chmod(0o600) print(f"Wrote {output}") return 0 diff --git a/scripts/generate-hosted-relay-bundles.py b/scripts/generate-hosted-relay-bundles.py deleted file mode 100755 index ef47073..0000000 --- a/scripts/generate-hosted-relay-bundles.py +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env python3 -"""Generate signed, instance-bound Relay bundles for hosted Solmara.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import subprocess -import tempfile -from pathlib import Path - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] -PROJECTS = ( - "cra-civil", - "nia-population", - "sro-social", - "mosd-programme", - "sipf-pensions", - "nagdi-agriculture", -) -BUNDLE_VARIANTS = ( - ("public", "relay.yaml", "", False), - ("consultation", "relay-consultation.yaml", "consultation", True), -) -CONTAINER_ROOT = Path("/etc/solmara/hosted-relay-bundles") -ANTIROLLBACK_PATH = ( - "/var/lib/registry-relay/cache/config-bundle-antirollback.json" -) - - -def run(*args: str) -> None: - subprocess.run(args, check=True) - - -def registryctl_path() -> str: - result = subprocess.run( - [str(ROOT / "scripts" / "registryctl-pinned.sh"), "path"], - check=True, - stdout=subprocess.PIPE, - text=True, - ) - return result.stdout.strip() - - -def configured_bundle_sequence() -> int: - for raw_line in (ROOT / "versions.env").read_text(encoding="utf-8").splitlines(): - if raw_line.startswith("SOLMARA_RELAY_BUNDLE_SEQUENCE="): - raw_sequence = raw_line.split("=", 1)[1] - break - else: - raise SystemExit("versions.env must set SOLMARA_RELAY_BUNDLE_SEQUENCE") - try: - sequence = int(raw_sequence) - except ValueError: - raise SystemExit( - "SOLMARA_RELAY_BUNDLE_SEQUENCE must be a positive integer" - ) from None - if sequence < 1: - raise SystemExit("SOLMARA_RELAY_BUNDLE_SEQUENCE must be a positive integer") - return sequence - - -def validate_private_jwk_reference(value: str) -> str: - if value.startswith("op://"): - if not value.removeprefix("op://").strip() or any( - character in value for character in ("\r", "\n", "\0") - ): - raise SystemExit("invalid 1Password private JWK reference") - return value - - key_path = Path(value) - if not key_path.is_file(): - raise SystemExit(f"missing signing key: {key_path}") - private_jwk = json.loads(key_path.read_text(encoding="utf-8")) - if "d" not in private_jwk: - raise SystemExit("expected a private JWK or op:// secret reference") - return value - - -def validate_public_jwk(path: Path) -> Path: - if not path.is_file(): - raise SystemExit(f"missing signing key: {path}") - public_jwk = json.loads(path.read_text(encoding="utf-8")) - if "d" in public_jwk: - raise SystemExit("expected a public-only JWK") - return path - - -def write_governed_config( - source: Path, - destination: Path, - container_dir: Path, -) -> dict[str, object]: - config = yaml.safe_load(source.read_text(encoding="utf-8")) - config["config_trust"] = { - "trust_anchor_path": str(container_dir / "trust-anchor.json"), - "bundle_path": str(container_dir / "bundle"), - "antirollback_state_path": ANTIROLLBACK_PATH, - } - destination.write_text( - yaml.safe_dump(config, sort_keys=False), - encoding="utf-8", - ) - return config - - -def generate_bundle( - registryctl: str, - project: str, - variant: str, - source_name: str, - output_subdirectory: str, - include_artifacts: bool, - private_jwk: str, - public_jwk: Path, - sequence: int, - output_root: Path, -) -> None: - runtime_dir = ROOT / "runtime" / "registry-projects" / "hosted" / project / "relay" - source_config = runtime_dir / source_name - if not source_config.is_file(): - raise SystemExit(f"missing hosted {variant} Relay config: {source_config}") - - source_document = yaml.safe_load(source_config.read_text(encoding="utf-8")) - if variant == "public" and "consultation" in source_document: - raise SystemExit(f"{project} public Relay config contains consultation authority") - if variant == "consultation" and "consultation" not in source_document: - raise SystemExit(f"{project} consultation Relay config omits consultation authority") - instance_id = source_document["instance"]["id"] - stream_suffix = "" if variant == "public" else "-consultation" - stream_id = f"solmara-hosted-{project}{stream_suffix}" - project_output = output_root / project - container_dir = CONTAINER_ROOT / project - if output_subdirectory: - project_output /= output_subdirectory - container_dir /= output_subdirectory - - with tempfile.TemporaryDirectory( - prefix=f"solmara-{project}-{variant}-bundle-" - ) as temporary: - staging = Path(temporary) - input_config = staging / "input" / "config" - input_config.mkdir(parents=True) - governed_config = input_config / "relay.yaml" - write_governed_config(source_config, governed_config, container_dir) - artifacts = runtime_dir / "artifacts" - if include_artifacts: - if not artifacts.is_dir(): - raise SystemExit(f"missing hosted consultation artifacts: {artifacts}") - shutil.copytree(artifacts, input_config / "artifacts") - - bundle_dir = staging / "bundle" - run( - registryctl, - "bundle", - "sign", - "--input", - str(staging / "input"), - "--key", - private_jwk, - "--product", - "registry-relay", - "--environment", - "hosted", - "--stream-id", - stream_id, - "--instance-id", - instance_id, - "--sequence", - str(sequence), - "--bundle-id", - f"{stream_id}-sequence-{sequence}", - "--out", - str(bundle_dir), - ) - - anchor = staging / "trust-anchor.json" - run( - registryctl, - "anchor", - "init", - "--anchor-path", - str(anchor), - "--product", - "registry-relay", - "--environment", - "hosted", - "--stream-id", - stream_id, - "--instance-id", - instance_id, - ) - run( - registryctl, - "anchor", - "add-key", - "--anchor-path", - str(anchor), - "--jwk-path", - str(public_jwk), - ) - run( - registryctl, - "bundle", - "verify", - "--bundle-dir", - str(bundle_dir), - "--anchor-path", - str(anchor), - ) - - project_output.mkdir(parents=True) - shutil.copy2(governed_config, project_output / "bootstrap.yaml") - shutil.copy2(anchor, project_output / "trust-anchor.json") - shutil.copytree(bundle_dir, project_output / "bundle") - seed = { - "key": { - "product": "registry-relay", - "environment": "hosted", - "stream_id": stream_id, - }, - "last_sequence": 0, - "last_config_hash": f"sha256:{'0' * 64}", - } - (project_output / "antirollback-seed.json").write_text( - json.dumps(seed, indent=2) + "\n", - encoding="utf-8", - ) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--private-jwk", - required=True, - help="Private JWK path or op:// secret reference", - ) - parser.add_argument("--public-jwk", type=Path, required=True) - parser.add_argument("--sequence", type=int) - parser.add_argument("--out", type=Path, required=True) - args = parser.parse_args() - - configured_sequence = configured_bundle_sequence() - sequence = args.sequence if args.sequence is not None else configured_sequence - if sequence < 1: - raise SystemExit("--sequence must be positive") - if sequence != configured_sequence: - raise SystemExit( - "--sequence must match SOLMARA_RELAY_BUNDLE_SEQUENCE in versions.env" - ) - private_jwk = validate_private_jwk_reference(args.private_jwk) - public_jwk = validate_public_jwk(args.public_jwk) - if args.out.exists(): - raise SystemExit(f"output path must not exist: {args.out}") - - registryctl = registryctl_path() - args.out.mkdir(parents=True) - for project in PROJECTS: - for variant in BUNDLE_VARIANTS: - generate_bundle( - registryctl, - project, - *variant, - private_jwk, - public_jwk, - sequence, - args.out, - ) - for path in args.out.rglob("*"): - path.chmod(0o755 if path.is_dir() else 0o644) - print(f"Wrote {args.out}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/hosted-authority-rollout.py b/scripts/hosted-authority-rollout.py new file mode 100755 index 0000000..b1fddad --- /dev/null +++ b/scripts/hosted-authority-rollout.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Print the non-destructive authority-cell rollout operation for one phase.""" + +from __future__ import annotations + +import argparse + +PHASES = { + "side-by-side": "start authority-cell services with public routes disabled", + "switch": "enable authority-cell public routes after health and evidence checks", + "disable": "stop superseded services; retain every superseded volume", +} + + +def operation(phase: str) -> dict[str, str]: + if phase not in PHASES: + raise ValueError("unknown rollout phase") + return { + "phase": phase, + "action": PHASES[phase], + "volumePolicy": "retain", + "destructiveCommand": "none", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("phase", choices=tuple(PHASES)) + args = parser.parse_args() + plan = operation(args.phase) + for key, value in plan.items(): + print(f"{key}={value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hosted-image-manifest.py b/scripts/hosted-image-manifest.py new file mode 100755 index 0000000..32bfbc5 --- /dev/null +++ b/scripts/hosted-image-manifest.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Build and validate the sanitized image handoff for hosted Solmara deployments.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import tempfile +from collections.abc import Mapping, Sequence +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +IMAGE_REPOSITORIES = ( + ("REGISTRY_RELAY_IMAGE", "relay"), + ("SOLMARA_EVIDENCE_IMAGE", "evidence"), + ("SOLMARA_MINT_IMAGE", "mint"), + ( + "SOLMARA_AUTHORITY_PROVISIONER_IMAGE", + "solmara-lab-authority-provisioner", + ), + ("SOLMARA_TRANSIT_SIGNER_IMAGE", "solmara-lab-transit-signer"), + ("SOLMARA_STATIC_METADATA_IMAGE", "solmara-lab-static-metadata"), + ("SOLMARA_SCENARIO_RUNNER_IMAGE", "solmara-lab-scenario-runner"), + ("SOLMARA_HOME_IMAGE", "solmara-lab-home"), + ("SOLMARA_PORTAL_IMAGE", "solmara-lab-portal"), + ("SOLMARA_ESIGNET_RELAY_IMAGE", "solmara-lab-esignet-relay"), + ("SOLMARA_ESIGNET_POSTGRES_IMAGE", "solmara-lab-esignet-postgres"), + ("SOLMARA_ESIGNET_UI_IMAGE", "solmara-lab-esignet-ui"), + ("SOLMARA_ESIGNET_SEED_IMAGE", "solmara-lab-esignet-seed"), +) +EXPECTED_KEYS = tuple(key for key, _repository in IMAGE_REPOSITORIES) +EXPECTED_REPOSITORIES = dict(IMAGE_REPOSITORIES) +MANIFEST_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=([^\s#]+)$") +HOSTED_IMAGE_LINE_RE = re.compile( + r"^\s*image:\s*\$\{(REGISTRY_RELAY_IMAGE|SOLMARA_[A-Z0-9_]+_IMAGE):\?[^}]+\}\s*(?:#.*)?$" +) +DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") + + +class ManifestError(ValueError): + """A hosted image handoff violates its closed contract.""" + + +def expected_reference(key: str, digest: str) -> str: + repository = EXPECTED_REPOSITORIES[key] + return f"ghcr.io/registrystack/{repository}@sha256:{digest}" + + +def validate_reference(key: str, value: str) -> None: + prefix = expected_reference(key, "") + if not value.startswith(prefix): + raise ManifestError( + f"{key} must reference {prefix}<64 lowercase hex>" + ) + digest = value.removeprefix(prefix) + if DIGEST_RE.fullmatch(digest) is None: + raise ManifestError( + f"{key} must be a full ghcr.io image@sha256:<64 lowercase hex> reference" + ) + + +def parse_manifest(text: str) -> dict[str, str]: + if not text.endswith("\n"): + raise ManifestError("manifest must end with one newline") + if "\r" in text: + raise ManifestError("manifest must use LF line endings") + + values: dict[str, str] = {} + keys: list[str] = [] + for line_number, line in enumerate(text[:-1].split("\n"), start=1): + match = MANIFEST_LINE_RE.fullmatch(line) + if match is None: + raise ManifestError( + f"line {line_number} must contain exactly NAME=image@sha256:<64 hex>" + ) + key, value = match.groups() + if key in values: + raise ManifestError(f"line {line_number} duplicates {key}") + if key not in EXPECTED_REPOSITORIES: + raise ManifestError(f"line {line_number} contains unexpected key {key}") + validate_reference(key, value) + values[key] = value + keys.append(key) + + missing = [key for key in EXPECTED_KEYS if key not in values] + if missing: + raise ManifestError(f"manifest is missing {', '.join(missing)}") + if tuple(keys) != EXPECTED_KEYS: + raise ManifestError("manifest keys are not in canonical order") + return values + + +def render_manifest(environment: Mapping[str, str]) -> str: + missing = [key for key in EXPECTED_KEYS if not environment.get(key)] + if missing: + raise ManifestError(f"environment is missing {', '.join(missing)}") + + text = "".join(f"{key}={environment[key]}\n" for key in EXPECTED_KEYS) + parse_manifest(text) + return text + + +def read_manifest(path: Path) -> str: + return path.read_bytes().decode("utf-8") + + +def hosted_compose_paths(root: Path) -> tuple[Path, ...]: + return (root / "compose.hosted.yaml", *sorted(root.glob("compose.coolify*.yaml"))) + + +def validate_hosted_compose_inventory(root: Path) -> None: + paths = hosted_compose_paths(root) + missing_files = [path.name for path in paths if not path.is_file()] + if missing_files: + raise ManifestError(f"hosted Compose file is missing: {', '.join(missing_files)}") + + observed: set[str] = set() + malformed: list[str] = [] + for path in paths: + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if "image:" not in line or not any( + marker in line + for marker in ("${REGISTRY_RELAY_IMAGE", "${SOLMARA_") + ): + continue + match = HOSTED_IMAGE_LINE_RE.fullmatch(line) + if match is None: + malformed.append(f"{path.name}:{line_number}") + continue + observed.add(match.group(1)) + + if malformed: + raise ManifestError( + "hosted image references must be required variables: " + + ", ".join(malformed) + ) + + expected = set(EXPECTED_KEYS) + if observed != expected: + missing = sorted(expected - observed) + unexpected = sorted(observed - expected) + detail: list[str] = [] + if missing: + detail.append(f"missing {', '.join(missing)}") + if unexpected: + detail.append(f"unexpected {', '.join(unexpected)}") + raise ManifestError("hosted Compose image inventory mismatch: " + "; ".join(detail)) + + +def write_manifest(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as temporary: + temporary.write(text) + temporary.flush() + temporary_path = Path(temporary.name) + temporary_path.chmod(0o644) + temporary_path.replace(path) + + +def parser() -> argparse.ArgumentParser: + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument( + "--compose-root", + type=Path, + default=ROOT, + help="repository root containing hosted Compose files", + ) + subparsers = argument_parser.add_subparsers(dest="command", required=True) + write = subparsers.add_parser("write", help="write a canonical manifest from the environment") + write.add_argument("--output", type=Path, required=True) + validate = subparsers.add_parser("validate", help="validate an existing canonical manifest") + validate.add_argument("--manifest", type=Path, required=True) + subparsers.add_parser("inventory", help="validate the hosted Compose image inventory") + return argument_parser + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = parser().parse_args(argv) + try: + validate_hosted_compose_inventory(arguments.compose_root) + if arguments.command == "write": + text = render_manifest(os.environ) + write_manifest(arguments.output, text) + parse_manifest(read_manifest(arguments.output)) + elif arguments.command == "validate": + parse_manifest(read_manifest(arguments.manifest)) + except (ManifestError, OSError, UnicodeError) as error: + print(f"hosted image manifest: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hosted-runtime-assets.py b/scripts/hosted-runtime-assets.py new file mode 100755 index 0000000..fdead54 --- /dev/null +++ b/scripts/hosted-runtime-assets.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""Assemble the closed, secret-free hosted authority provisioning payload.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path, PurePosixPath + +AUTHORITIES = ("cra", "nia", "mosd", "sipf", "nagdi") +EVIDENCE_CELLS = ("cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") +GENERATOR_FILES = frozenset({"__init__.py", "generate.py", "publisher.py"}) +RELAY_FILES = { + "cra": frozenset( + { + "codelists/record-lifecycle.yaml", + "expected-http.yaml", + "fixture.sql", + "governance/classification-review-rationale.md", + "governance/classification-review.yaml", + "governance/identifier-lifecycle.yaml", + "governance/legal-basis.yaml", + "governance/processing.dpv.yaml", + "registry.yaml", + "runtime.yaml", + } + ), + "nia": frozenset( + { + "codelists/identity-status.yaml", + "codelists/record-lifecycle.yaml", + "codelists/sex.yaml", + "expected-http.yaml", + "fixture.sql", + "governance/classification-review-rationale.md", + "governance/classification-review.yaml", + "governance/identifier-lifecycle.yaml", + "governance/legal-basis.yaml", + "governance/processing.dpv.yaml", + "registry.yaml", + "runtime.yaml", + } + ), + "mosd": frozenset( + { + "codelists/record-lifecycle.yaml", + "expected-http.yaml", + "fixture.sql", + "governance/classification-review-rationale.md", + "governance/classification-review.yaml", + "governance/identifier-lifecycle.yaml", + "governance/legal-basis.yaml", + "governance/processing.dpv.yaml", + "registry.yaml", + "runtime.yaml", + } + ), + "sipf": frozenset( + { + "codelists/payment-status.yaml", + "codelists/record-lifecycle.yaml", + "expected-http.yaml", + "fixture.sql", + "governance/classification-review-rationale.md", + "governance/classification-review.yaml", + "governance/identifier-lifecycle.yaml", + "governance/legal-basis.yaml", + "governance/processing.dpv.yaml", + "registry.yaml", + "runtime.yaml", + } + ), + "nagdi": frozenset( + { + "codelists/record-lifecycle.yaml", + "expected-http.yaml", + "fixture.sql", + "governance/classification-review-rationale.md", + "governance/classification-review.yaml", + "governance/identifier-lifecycle.yaml", + "governance/legal-basis.yaml", + "governance/processing.dpv.yaml", + "registry.yaml", + "runtime.yaml", + } + ), +} +EVIDENCE_FILES = { + "cra": frozenset( + { + "bundle/adapters/birth-extract.rhai", + "bundle/adapters/relay-extract.rhai", + "bundle/adapters/relay-prepare.rhai", + "bundle/derivations/child-benefit.rhai", + "bundle/derivations/deceased.rhai", + "bundle/derivations/linked.rhai", + "bundle/evidence.yaml", + "bundle/fixtures/child-benefit.yaml", + "bundle/fixtures/citizen.yaml", + "bundle/fixtures/pension.yaml", + "bundle/queries/birth-evidence.sql", + "bundle/schemas/birth-facts.schema.yaml", + "bundle/schemas/birth-response.schema.yaml", + "bundle/schemas/deceased-facts.schema.yaml", + "bundle/schemas/deceased-response.schema.yaml", + "bundle/schemas/linked-facts.schema.yaml", + "bundle/schemas/linked-response.schema.yaml", + "bundle/schemas/relay-adapter-parameters.schema.yaml", + "runtime.yaml", + } + ), + "nia": frozenset( + { + "bundle/adapters/sqlite-extract.rhai", + "bundle/derivations/population-active.rhai", + "bundle/evidence.yaml", + "bundle/fixtures/child-benefit.yaml", + "bundle/fixtures/citizen.yaml", + "bundle/queries/population-evidence.sql", + "bundle/schemas/population-facts.schema.yaml", + "bundle/schemas/population-response.schema.yaml", + "runtime.yaml", + } + ), + "sro": frozenset( + { + "bundle/adapters/sqlite-extract.rhai", + "bundle/derivations/poverty-priority.rhai", + "bundle/evidence.yaml", + "bundle/fixtures/child-benefit.yaml", + "bundle/queries/poverty-evidence.sql", + "bundle/schemas/poverty-facts.schema.yaml", + "bundle/schemas/poverty-response.schema.yaml", + "runtime.yaml", + } + ), + "mosd-programme": frozenset( + { + "bundle/adapters/relay-extract.rhai", + "bundle/adapters/relay-prepare.rhai", + "bundle/derivations/not-enrolled.rhai", + "bundle/evidence.yaml", + "bundle/fixtures/child-benefit.yaml", + "bundle/schemas/facts.schema.yaml", + "bundle/schemas/relay-adapter-parameters.schema.yaml", + "bundle/schemas/response.schema.yaml", + "runtime.yaml", + } + ), + "sipf": frozenset( + { + "bundle/adapters/relay-extract.rhai", + "bundle/adapters/relay-prepare.rhai", + "bundle/derivations/pension-active.rhai", + "bundle/derivations/survivor-eligible.rhai", + "bundle/evidence.yaml", + "bundle/fixtures/pension.yaml", + "bundle/fixtures/survivor.yaml", + "bundle/schemas/pension-facts.schema.yaml", + "bundle/schemas/pension-response.schema.yaml", + "bundle/schemas/relay-adapter-parameters.schema.yaml", + "bundle/schemas/survivor-facts.schema.yaml", + "bundle/schemas/survivor-response.schema.yaml", + "runtime.yaml", + } + ), + "nagdi": frozenset( + { + "bundle/adapters/relay-extract.rhai", + "bundle/adapters/relay-prepare.rhai", + "bundle/derivations/livestock.rhai", + "bundle/derivations/voucher.rhai", + "bundle/evidence.yaml", + "bundle/fixtures/livestock.yaml", + "bundle/fixtures/voucher.yaml", + "bundle/schemas/livestock-facts.schema.yaml", + "bundle/schemas/livestock-response.schema.yaml", + "bundle/schemas/relay-adapter-parameters.schema.yaml", + "bundle/schemas/voucher-facts.schema.yaml", + "bundle/schemas/voucher-response.schema.yaml", + "runtime.yaml", + } + ), +} +MANIFEST_NAME = "manifest.json" +MANIFEST_KEYS = frozenset({"format", "files"}) +SHA256_LENGTH = 64 +SAFE_PATH_COMPONENT = re.compile(r"^[A-Za-z0-9._-]+$") +FORBIDDEN_DIRECTORY_NAMES = frozenset( + { + ".cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "audit", + "audits", + "extracts", + "secrets", + } +) +FORBIDDEN_SUFFIXES = ( + ".db", + ".jwk", + ".jwk.json", + ".jsonl", + ".key", + ".log", + ".p12", + ".pem", + ".pfx", + ".pyc", + ".sqlite-journal", + ".sqlite-shm", + ".sqlite-wal", +) + + +class AssetBuildError(RuntimeError): + """Raised when the immutable provisioning payload cannot be trusted.""" + + +def _digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _walk_files(root: Path) -> set[str]: + if root.is_symlink() or not root.is_dir(): + raise AssetBuildError("asset source is not a regular directory") + files: set[str] = set() + for current, directories, names in os.walk(root, followlinks=False): + current_path = Path(current) + for name in directories: + path = current_path / name + if path.is_symlink(): + raise AssetBuildError("asset source contains a symbolic link") + for name in names: + path = current_path / name + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + raise AssetBuildError("asset source contains a symbolic link") + if not stat.S_ISREG(metadata.st_mode): + raise AssetBuildError("asset source contains a non-regular file") + files.add(path.relative_to(root).as_posix()) + return files + + +def _validate_exact_tree(root: Path, expected: frozenset[str]) -> None: + if _walk_files(root) != set(expected): + raise AssetBuildError("asset source inventory is not allowed") + + +def _copy_exact_tree(source: Path, destination: Path, files: frozenset[str]) -> None: + if destination.exists(): + raise AssetBuildError("asset destination is not empty") + for relative in sorted(files): + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / relative, target) + + +def _path_is_sensitive(relative: str, *, allow_seed: bool = False) -> bool: + path = PurePosixPath(relative) + lowered = tuple(part.lower() for part in path.parts) + if any(part in FORBIDDEN_DIRECTORY_NAMES for part in lowered[:-1]): + return True + name = lowered[-1] + if "secret" in name or "private" in name: + return True + if name.endswith(FORBIDDEN_SUFFIXES): + return True + return name.endswith(".sqlite") and not allow_seed + + +def _validate_generated_tree(root: Path, *, allow_seed: bool = False) -> None: + files = _walk_files(root) + if not files: + raise AssetBuildError("generated asset tree is empty") + for relative in files: + if _path_is_sensitive(relative, allow_seed=allow_seed): + raise AssetBuildError("generated asset tree contains a forbidden artifact") + + +def _manifest(root: Path) -> dict[str, object]: + files: dict[str, str] = {} + for relative in sorted(_walk_files(root)): + if relative != MANIFEST_NAME: + files[relative] = _digest(root / relative) + if not files: + raise AssetBuildError("asset payload is empty") + return {"format": 1, "files": files} + + +def _manifest_bytes(manifest: dict[str, object]) -> bytes: + return (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def _strict_json(data: bytes) -> object: + def object_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise AssetBuildError("asset manifest contains duplicate keys") + result[key] = value + return result + + try: + return json.loads(data.decode("utf-8"), object_pairs_hook=object_pairs) + except (UnicodeError, json.JSONDecodeError): + raise AssetBuildError("asset manifest is invalid") from None + + +def verify_manifest(root: Path) -> None: + """Verify the exact, canonical manifest and every payload file.""" + + if root.is_symlink() or not root.is_dir(): + raise AssetBuildError("asset payload is unavailable") + manifest_path = root / MANIFEST_NAME + try: + raw = manifest_path.read_bytes() + manifest = _strict_json(raw) + if not isinstance(manifest, dict) or set(manifest) != MANIFEST_KEYS: + raise AssetBuildError("asset manifest shape is invalid") + if manifest.get("format") != 1 or isinstance(manifest.get("format"), bool): + raise AssetBuildError("asset manifest format is invalid") + files = manifest.get("files") + if not isinstance(files, dict) or not files: + raise AssetBuildError("asset manifest inventory is invalid") + for relative, expected_digest in files.items(): + if ( + not isinstance(relative, str) + or not relative + or PurePosixPath(relative).is_absolute() + or ".." in PurePosixPath(relative).parts + or relative != PurePosixPath(relative).as_posix() + or any( + not SAFE_PATH_COMPONENT.fullmatch(component) + for component in PurePosixPath(relative).parts + ) + or relative == MANIFEST_NAME + or not isinstance(expected_digest, str) + or len(expected_digest) != SHA256_LENGTH + or any( + character not in "0123456789abcdef" for character in expected_digest + ) + ): + raise AssetBuildError("asset manifest entry is invalid") + observed = _manifest(root) + if manifest != observed or raw != _manifest_bytes(observed): + raise AssetBuildError("asset manifest verification failed") + except AssetBuildError: + raise + except OSError: + raise AssetBuildError("asset manifest verification failed") from None + + +def _validate_sources(root: Path) -> None: + _validate_exact_tree(root / "generator" / "solmara_lab", GENERATOR_FILES) + for authority in AUTHORITIES: + _validate_exact_tree(root / "relays" / authority, RELAY_FILES[authority]) + for cell in EVIDENCE_CELLS: + _validate_exact_tree(root / "evidence" / "cells" / cell, EVIDENCE_FILES[cell]) + mint = root / "evidence" / "mint.yaml" + if mint.is_symlink() or not mint.is_file(): + raise AssetBuildError("Mint template is unavailable") + + +def _relay_environment() -> dict[str, str]: + return {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} + + +def _prepare_packaging_project(project: Path, authority: str, seed: Path) -> None: + shutil.copyfile(seed, project / "source.sqlite") + runtime = project / "runtime.yaml" + hosted_path = f"/var/lib/relay/source/{authority}.sqlite" + content = runtime.read_text(encoding="utf-8") + if content.count(hosted_path) != 1: + raise AssetBuildError("Relay runtime source binding is unexpected") + runtime.write_text(content.replace(hosted_path, "source.sqlite"), encoding="utf-8") + + +def build(root: Path, output: Path, relayctl: Path) -> None: + """Build fresh Relay packages/sources and copy authored templates.""" + + root = root.absolute() + output = output.absolute() + relayctl = relayctl.absolute() + if output.exists() or output.is_symlink(): + raise AssetBuildError("asset destination already exists") + if not relayctl.is_file() or relayctl.is_symlink(): + raise AssetBuildError("relayctl is unavailable") + _validate_sources(root) + + sys.dont_write_bytecode = True + sys.path.insert(0, str(root / "generator")) + from solmara_lab import publisher # pylint: disable=import-outside-toplevel + + with tempfile.TemporaryDirectory(prefix="solmara-hosted-assets-") as temporary: + staging = Path(temporary) / "assets" + staging.mkdir() + + published = publisher.publish_relay_sources(Path(temporary) / "published") + for authority in AUTHORITIES: + project = Path(temporary) / "projects" / authority + _copy_exact_tree( + root / "relays" / authority, project, RELAY_FILES[authority] + ) + _prepare_packaging_project(project, authority, published[authority]) + package = staging / "relays" / authority / "package" + package.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + str(relayctl), + "--json", + "package", + str(project), + "--output", + str(package), + ], + cwd=root, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=_relay_environment(), + ) + _validate_generated_tree(package) + runtime = staging / "relays" / authority / "runtime.yaml" + shutil.copyfile(root / "relays" / authority / "runtime.yaml", runtime) + seed = staging / "relays" / authority / "source" / f"{authority}.sqlite" + seed.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(published[authority], seed) + + for cell in EVIDENCE_CELLS: + _copy_exact_tree( + root / "evidence" / "cells" / cell, + staging / "evidence" / "cells" / cell, + EVIDENCE_FILES[cell], + ) + mint = staging / "mint" / "mint.yaml" + mint.parent.mkdir(parents=True) + shutil.copyfile(root / "evidence" / "mint.yaml", mint) + _copy_exact_tree( + root / "generator" / "solmara_lab", + staging / "generator" / "solmara_lab", + GENERATOR_FILES, + ) + + manifest = _manifest(staging) + (staging / MANIFEST_NAME).write_bytes(_manifest_bytes(manifest)) + verify_manifest(staging) + shutil.copytree(staging, output) + verify_manifest(output) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build", choices=("build",)) + parser.add_argument("--root", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--relayctl", required=True, type=Path) + args = parser.parse_args() + try: + build(args.root, args.output, args.relayctl) + # This is the security redaction boundary. Neither dependency failures nor + # relayctl diagnostics may disclose paths, configuration, or source values. + except Exception: # noqa: BLE001 + print("hosted runtime asset build failed", file=sys.stderr) + return 1 + print("hosted runtime assets ready") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hosted-transit-signer.py b/scripts/hosted-transit-signer.py new file mode 100644 index 0000000..a5a95cf --- /dev/null +++ b/scripts/hosted-transit-signer.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +"""Confine one hosted signing secret to one Transit proxy process.""" + +from __future__ import annotations + +import argparse +import base64 +import errno +import hashlib +import json +import os +import socket +import stat +import sys +import tempfile +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric import ec + +MAX_SECRET_BYTES = 16 * 1024 +SECRET_PATH = Path("/tmp/solmara-signing.jwk") +PUBLIC_PATH = Path("/tmp/solmara-signing-public.jwk") +SOCKET_PATH = Path("/transit/transit-proxy.sock") +STAGING_ROOT = Path("/tmp") +ALLOWED_KEY_NAMES = frozenset( + { + "solmara-mint", + "solmara-evidence-cra", + "solmara-evidence-nia", + "solmara-evidence-sro", + "solmara-evidence-mosd-programme", + "solmara-evidence-sipf", + "solmara-evidence-nagdi", + } +) +GENERIC_ERROR = "hosted Transit signer could not start" + + +class SignerError(Exception): + """A value-free signer configuration refusal.""" + + +class QuietArgumentParser(argparse.ArgumentParser): + """Keep rejected configuration values out of diagnostics.""" + + def error(self, message: str) -> None: + del message + raise SignerError("invalid arguments") + + +def _directory_flags() -> int: + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + return flags + + +def _file_flags() -> int: + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + return flags + + +def _directory_is_confined(metadata: os.stat_result) -> bool: + if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid not in {0, os.geteuid()}: + return False + if metadata.st_mode & 0o022 == 0: + return True + # Root-owned sticky directories such as /tmp prevent unprivileged callers + # from replacing a child they do not own. Every following component is + # still opened relative to a pinned descriptor and must be owner-controlled. + return metadata.st_uid == 0 and metadata.st_mode & stat.S_ISVTX != 0 + + +def _open_confined_directory(path: Path) -> int: + if not path.is_absolute(): + raise SignerError("invalid secret") + components = path.parts[1:] + if any(component in {"", ".", ".."} for component in components): + raise SignerError("invalid secret") + directory = os.open("/", _directory_flags()) + try: + root_metadata = os.fstat(directory) + if not _directory_is_confined(root_metadata): + raise SignerError("invalid secret") + for component in components: + next_directory = os.open(component, _directory_flags(), dir_fd=directory) + os.close(directory) + directory = next_directory + metadata = os.fstat(directory) + if not _directory_is_confined(metadata): + raise SignerError("invalid secret") + return directory + except OSError as error: + os.close(directory) + raise SignerError("invalid secret") from error + except SignerError: + os.close(directory) + raise + + +def _open_confined_secret(path: Path) -> int: + """Open an absolute secret without following any path component.""" + + if not path.is_absolute() or path.name in {"", ".", ".."}: + raise SignerError("invalid secret") + directory = _open_confined_directory(path.parent) + try: + return os.open(path.name, _file_flags(), dir_fd=directory) + except OSError as error: + raise SignerError("invalid secret") from error + finally: + os.close(directory) + + +def _secret_metadata_is_confined(metadata: os.stat_result) -> bool: + return ( + stat.S_ISREG(metadata.st_mode) + and metadata.st_uid in {0, os.geteuid()} + and metadata.st_mode & 0o022 == 0 + and metadata.st_nlink == 1 + and 0 < metadata.st_size <= MAX_SECRET_BYTES + ) + + +def _read_secret_descriptor(descriptor: int) -> tuple[bytearray, os.stat_result]: + before = os.fstat(descriptor) + if not _secret_metadata_is_confined(before): + raise SignerError("invalid secret") + + value = bytearray() + while len(value) <= MAX_SECRET_BYTES: + chunk = os.read(descriptor, min(4096, MAX_SECRET_BYTES + 1 - len(value))) + if not chunk: + break + value.extend(chunk) + after = os.fstat(descriptor) + if ( + len(value) > MAX_SECRET_BYTES + or len(value) != before.st_size + or ( + before.st_dev, + before.st_ino, + before.st_uid, + before.st_mode, + before.st_size, + before.st_nlink, + ) + != ( + after.st_dev, + after.st_ino, + after.st_uid, + after.st_mode, + after.st_size, + after.st_nlink, + ) + ): + raise SignerError("invalid secret") + return value, before + + +def _read_secret(path: Path) -> bytearray: + descriptor = _open_confined_secret(path) + try: + value, _ = _read_secret_descriptor(descriptor) + return value + except OSError as error: + raise SignerError("invalid secret") from error + finally: + os.close(descriptor) + + +def _stage_secret( + source: Path, + staging_root: Path = STAGING_ROOT, + *, + expected_digest: bytes | None = None, +) -> Path: + value = _read_secret(source) + try: + if ( + expected_digest is not None + and hashlib.sha256(value).digest() != expected_digest + ): + raise SignerError("invalid secret") + directory = Path(tempfile.mkdtemp(prefix="solmara-transit-", dir=staging_root)) + directory.chmod(0o700) + metadata = directory.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) != 0o700 + ): + raise SignerError("invalid staging area") + + destination = directory / "signing.jwk" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(destination, flags, 0o600) + try: + written = 0 + while written < len(value): + written += os.write(descriptor, value[written:]) + os.fchmod(descriptor, 0o600) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_size != len(value) + ): + raise SignerError("invalid staging area") + finally: + os.close(descriptor) + return destination + except OSError as error: + raise SignerError("invalid staging area") from error + finally: + value[:] = b"\0" * len(value) + + +def _consume_secret(path: Path, expected_digest: bytes) -> None: + descriptor = _open_confined_secret(path) + value = bytearray() + directory = -1 + try: + value, opened = _read_secret_descriptor(descriptor) + if hashlib.sha256(value).digest() != expected_digest: + raise SignerError("invalid secret") + directory = _open_confined_directory(path.parent) + current = os.stat(path.name, dir_fd=directory, follow_symlinks=False) + + def identity(metadata: os.stat_result) -> tuple[int, int, int, int, int, int]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_uid, + metadata.st_mode, + metadata.st_size, + metadata.st_nlink, + ) + + if identity(current) != identity(opened): + raise SignerError("invalid secret") + os.unlink(path.name, dir_fd=directory) + except OSError as error: + raise SignerError("invalid secret") from error + finally: + if directory >= 0: + os.close(directory) + value[:] = b"\0" * len(value) + os.close(descriptor) + + +def _remove_empty_staging_directory( + path: Path, staging_root: Path = STAGING_ROOT +) -> None: + directory = path.parent + try: + metadata = directory.lstat() + if ( + directory.parent != staging_root + or not directory.name.startswith("solmara-transit-") + or not stat.S_ISDIR(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) != 0o700 + ): + raise SignerError("invalid staging area") + directory.rmdir() + except OSError as error: + raise SignerError("invalid staging area") from error + + +def _discard_staged_secret(path: Path, expected_digest: bytes) -> None: + _consume_secret(path, expected_digest) + _remove_empty_staging_directory(path, path.parent.parent) + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _b64decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _verify_public_match(private_path: Path, public_path: Path) -> tuple[bytes, bytes]: + private_bytes = _read_secret(private_path) + public_bytes = _read_secret(public_path) + try: + private = json.loads(private_bytes.decode("utf-8")) + public = json.loads(public_bytes.decode("utf-8")) + private_keys = {"kty", "crv", "alg", "x", "y", "d", "kid"} + public_keys = {"kty", "crv", "alg", "x", "y", "kid"} + if ( + not isinstance(private, dict) + or not isinstance(public, dict) + or set(private) != private_keys + or set(public) != public_keys + or private.get("kty") != "EC" + or private.get("crv") != "P-256" + or private.get("alg") != "ES256" + ): + raise SignerError("invalid key pair") + scalar = int.from_bytes(_b64decode(private["d"]), "big") + numbers = ( + ec.derive_private_key(scalar, ec.SECP256R1()).public_key().public_numbers() + ) + expected = { + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "x": _b64url(numbers.x.to_bytes(32, "big")), + "y": _b64url(numbers.y.to_bytes(32, "big")), + } + thumbprint = {key: expected[key] for key in ("crv", "kty", "x", "y")} + expected["kid"] = _b64url( + hashlib.sha256( + json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode() + ).digest() + ) + if public != expected or any(private[key] != expected[key] for key in expected): + raise SignerError("invalid key pair") + return ( + hashlib.sha256(private_bytes).digest(), + hashlib.sha256(public_bytes).digest(), + ) + except SignerError: + raise + except (UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError): + raise SignerError("invalid key pair") from None + finally: + private_bytes[:] = b"\0" * len(private_bytes) + public_bytes[:] = b"\0" * len(public_bytes) + + +def _socket_identity(path: Path) -> tuple[int, int, int, int]: + try: + metadata = path.lstat() + except OSError as error: + raise SignerError("invalid socket") from error + if ( + not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o077 + or metadata.st_nlink != 1 + ): + raise SignerError("invalid socket") + return metadata.st_dev, metadata.st_ino, metadata.st_uid, metadata.st_mode + + +def _remove_stale_socket(path: Path) -> None: + identity = _socket_identity(path) + try: + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(0.25) + client.connect(str(path)) + except (ConnectionRefusedError, FileNotFoundError): + pass + except OSError as error: + if error.errno not in {errno.ECONNREFUSED, errno.ENOENT}: + raise SignerError("invalid socket") from error + else: + raise SignerError("invalid socket") + + if not path.exists() and not path.is_symlink(): + return + if _socket_identity(path) != identity: + raise SignerError("invalid socket") + try: + path.unlink() + except OSError as error: + raise SignerError("invalid socket") from error + + +def _validate_socket(path: Path) -> None: + if path != SOCKET_PATH: + raise SignerError("invalid socket") + try: + metadata = path.parent.lstat() + except OSError as error: + raise SignerError("invalid socket") from error + if ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o022 + ): + raise SignerError("invalid socket") + if path.exists() or path.is_symlink(): + _remove_stale_socket(path) + + +def _validate_proxy(path: Path) -> None: + if not path.is_absolute() or path.is_symlink(): + raise SignerError("invalid proxy") + try: + metadata = path.stat() + except OSError as error: + raise SignerError("invalid proxy") from error + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid not in {0, os.geteuid()} + or metadata.st_mode & 0o022 + ): + raise SignerError("invalid proxy") + + +def exec_signer( + private_jwk: Path, + public_jwk: Path, + socket_path: Path, + key_name: str, + proxy: Path, +) -> None: + if ( + private_jwk != SECRET_PATH + or public_jwk != PUBLIC_PATH + or key_name not in ALLOWED_KEY_NAMES + ): + raise SignerError("invalid signer configuration") + private_digest, public_digest = _verify_public_match(private_jwk, public_jwk) + _validate_socket(socket_path) + _validate_proxy(proxy) + staged = _stage_secret(private_jwk, expected_digest=private_digest) + try: + _consume_secret(private_jwk, private_digest) + _consume_secret(public_jwk, public_digest) + except SignerError: + _discard_staged_secret(staged, private_digest) + raise + arguments = [ + sys.executable, + str(proxy), + "--private-jwk", + str(staged), + "--consume-private-jwk", + "--socket", + str(socket_path), + "--key-name", + key_name, + ] + environment = { + "LANG": "C.UTF-8", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1", + } + try: + os.execve(sys.executable, arguments, environment) + except OSError: + _discard_staged_secret(staged, private_digest) + raise + + +def main() -> int: + parser = QuietArgumentParser(add_help=False) + parser.add_argument("--private-jwk", required=True, type=Path) + parser.add_argument("--public-jwk", required=True, type=Path) + parser.add_argument("--socket", required=True, type=Path) + parser.add_argument("--key-name", required=True) + try: + arguments = parser.parse_args() + proxy = Path(__file__).resolve().with_name("local-transit-proxy.py") + os.umask(0o077) + exec_signer( + arguments.private_jwk, + arguments.public_jwk, + arguments.socket, + arguments.key_name, + proxy, + ) + except (OSError, SignerError, ValueError): + print(GENERIC_ERROR, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/init-notary-postgresql.sh b/scripts/init-notary-postgresql.sh deleted file mode 100755 index c41ceb0..0000000 --- a/scripts/init-notary-postgresql.sh +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env sh -set -eu - -# PostgreSQL runs this file while initializing a fresh cluster and Compose -# reruns it as an idempotent bootstrap before schema installation. Each -# allowlisted authority receives separate Relay consultation and Notary -# databases. Passwords arrive through the deployment secret store and are -# never printed. - -relay_state_epoch=${REGISTRY_RELAY_STATE_EPOCH:-v015} -case "$relay_state_epoch" in - "" | *[!a-z0-9_]*) - echo "REGISTRY_RELAY_STATE_EPOCH must contain only lowercase letters, digits, and underscores" >&2 - exit 1 - ;; -esac - -provision_relay() { - key=$1 - runtime_password=$2 - maintenance_password=$3 - reader_password=$4 - database="solmara_relay_${key}_consultation_${relay_state_epoch}" - owner="${database}_owner" - runtime="${database}_runtime" - maintenance="${database}_keyring_maintenance" - reader="${database}_keyring_reader" - - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ - --set=database="$database" \ - --set=owner="$owner" \ - --set=runtime="$runtime" \ - --set=maintenance="$maintenance" \ - --set=reader="$reader" \ - --set=runtime_password="$runtime_password" \ - --set=maintenance_password="$maintenance_password" \ - --set=reader_password="$reader_password" <<'SQL' -SELECT format( - 'CREATE ROLE %I NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS', - :'owner' -) -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'owner') -\gexec -SELECT format( - 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - role_name, - role_password -) -FROM (VALUES - (:'runtime', :'runtime_password'), - (:'maintenance', :'maintenance_password'), - (:'reader', :'reader_password') -) AS requested(role_name, role_password) -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = requested.role_name) -\gexec -SELECT format( - 'ALTER ROLE %I NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS', - :'owner' -) -\gexec -SELECT format( - 'ALTER ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - role_name, - role_password -) -FROM (VALUES - (:'runtime', :'runtime_password'), - (:'maintenance', :'maintenance_password'), - (:'reader', :'reader_password') -) AS requested(role_name, role_password) -\gexec -SELECT format('REVOKE %I FROM %I', granted.rolname, member.rolname) -FROM pg_catalog.pg_auth_members AS membership -JOIN pg_catalog.pg_roles AS granted ON granted.oid = membership.roleid -JOIN pg_catalog.pg_roles AS member ON member.oid = membership.member -WHERE member.rolname IN (:'owner', :'runtime', :'maintenance', :'reader') -\gexec -SELECT format('CREATE DATABASE %I OWNER %I', :'database', :'owner') -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = :'database') -\gexec -SELECT format('ALTER DATABASE %I OWNER TO %I', :'database', :'owner') -\gexec -SELECT format('REVOKE ALL ON DATABASE %I FROM PUBLIC', :'database') -\gexec -SELECT format( - 'REVOKE ALL ON DATABASE %I FROM %I, %I, %I, %I', - :'database', :'owner', :'runtime', :'maintenance', :'reader' -) -\gexec -SELECT format('GRANT CONNECT, CREATE ON DATABASE %I TO %I', :'database', :'owner') -\gexec -SELECT format( - 'GRANT CONNECT ON DATABASE %I TO %I, %I, %I', - :'database', :'runtime', :'maintenance', :'reader' -) -\gexec -SQL - - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$database" <<'SQL' -REVOKE ALL ON SCHEMA public FROM PUBLIC; -SQL -} - -provision_notary() { - key=$1 - migrator_password=$2 - runtime_password=$3 - database="solmara_notary_${key}" - owner="${database}_owner" - migrator="${database}_migrator" - runtime="${database}_runtime" - - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ - --set=database="$database" \ - --set=owner="$owner" \ - --set=migrator="$migrator" \ - --set=runtime="$runtime" \ - --set=migrator_password="$migrator_password" \ - --set=runtime_password="$runtime_password" <<'SQL' -SELECT format( - 'CREATE ROLE %I NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS', - :'owner' -) -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'owner') -\gexec -SELECT format( - 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - :'migrator', - :'migrator_password' -) -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'migrator') -\gexec -SELECT format( - 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - :'runtime', - :'runtime_password' -) -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'runtime') -\gexec -SELECT format( - 'ALTER ROLE %I NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS', - :'owner' -) -\gexec -SELECT format( - 'ALTER ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - :'migrator', - :'migrator_password' -) -\gexec -SELECT format( - 'ALTER ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - :'runtime', - :'runtime_password' -) -\gexec -SELECT format('GRANT %I TO %I', :'owner', :'migrator') -WHERE NOT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_auth_members AS membership - JOIN pg_catalog.pg_roles AS granted_role ON granted_role.oid = membership.roleid - JOIN pg_catalog.pg_roles AS member_role ON member_role.oid = membership.member - WHERE granted_role.rolname = :'owner' AND member_role.rolname = :'migrator' -) -\gexec -SELECT format('REVOKE %I FROM %I', :'owner', :'runtime') -WHERE EXISTS ( - SELECT 1 - FROM pg_catalog.pg_auth_members AS membership - JOIN pg_catalog.pg_roles AS granted_role ON granted_role.oid = membership.roleid - JOIN pg_catalog.pg_roles AS member_role ON member_role.oid = membership.member - WHERE granted_role.rolname = :'owner' AND member_role.rolname = :'runtime' -) -\gexec -SELECT format('CREATE DATABASE %I OWNER %I', :'database', :'owner') -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = :'database') -\gexec -SELECT format('ALTER DATABASE %I OWNER TO %I', :'database', :'owner') -\gexec -SELECT format('REVOKE ALL ON DATABASE %I FROM PUBLIC', :'database') -\gexec -SELECT format('REVOKE ALL ON DATABASE %I FROM %I, %I', :'database', :'migrator', :'runtime') -\gexec -SELECT format('GRANT CONNECT ON DATABASE %I TO %I, %I', :'database', :'migrator', :'runtime') -\gexec -SQL - - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$database" <<'SQL' -REVOKE ALL ON SCHEMA public FROM PUBLIC; -SQL -} - -provision_source_reader() { - key=$1 - password=$2 - role="solmara_source_${key}_reader" - - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ - --set=database="$POSTGRES_DB" \ - --set=role="$role" \ - --set=password="$password" <<'SQL' -SELECT format( - 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - :'role', - :'password' -) -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'role') -\gexec -SELECT format( - 'ALTER ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', - :'role', - :'password' -) -\gexec -SELECT format('REVOKE %I FROM %I', granted.rolname, member.rolname) -FROM pg_catalog.pg_auth_members AS membership -JOIN pg_catalog.pg_roles AS granted ON granted.oid = membership.roleid -JOIN pg_catalog.pg_roles AS member ON member.oid = membership.member -WHERE member.rolname = :'role' -\gexec -SELECT format('REVOKE ALL ON DATABASE %I FROM %I', :'database', :'role') -\gexec -SELECT format('GRANT CONNECT ON DATABASE %I TO %I', :'database', :'role') -\gexec -SQL - - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ - --set=role="$role" <<'SQL' -REVOKE CREATE ON SCHEMA public FROM PUBLIC; -SELECT format('REVOKE ALL ON ALL TABLES IN SCHEMA public FROM %I', :'role') -\gexec -SELECT format('GRANT USAGE ON SCHEMA public TO %I', :'role') -\gexec -SQL - - case "$key" in - nia) - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ - --set=role="$role" <<'SQL' -SELECT format('GRANT SELECT ON TABLE public.population_person TO %I', :'role') -\gexec -SQL - ;; - sipf) - psql --quiet --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ - --set=role="$role" <<'SQL' -SELECT format( - 'GRANT SELECT ON TABLE public.sipf_pension_payment, public.sipf_survivor_benefit TO %I', - :'role' -) -\gexec -SQL - ;; - *) - echo "Unsupported source reader" >&2 - exit 1 - ;; - esac -} - -for key in ${SOLMARA_SOURCE_READER_DATABASES:-}; do - case "$key" in - nia) - provision_source_reader "$key" \ - "${NIA_SOURCE_POSTGRES_READER_PASSWORD:?missing NIA source reader password}" - ;; - sipf) - provision_source_reader "$key" \ - "${SIPF_SOURCE_POSTGRES_READER_PASSWORD:?missing SIPF source reader password}" - ;; - *) - echo "Unsupported SOLMARA_SOURCE_READER_DATABASES entry" >&2 - exit 1 - ;; - esac -done - -for key in ${SOLMARA_RELAY_DATABASES:-}; do - case "$key" in - cra) - provision_relay "$key" \ - "${CRA_RELAY_POSTGRES_RUNTIME_PASSWORD:?missing CRA Relay runtime password}" \ - "${CRA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD:?missing CRA Relay keyring maintenance password}" \ - "${CRA_RELAY_POSTGRES_KEYRING_READER_PASSWORD:?missing CRA Relay keyring reader password}" - ;; - nia) - provision_relay "$key" \ - "${NIA_RELAY_POSTGRES_RUNTIME_PASSWORD:?missing NIA Relay runtime password}" \ - "${NIA_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD:?missing NIA Relay keyring maintenance password}" \ - "${NIA_RELAY_POSTGRES_KEYRING_READER_PASSWORD:?missing NIA Relay keyring reader password}" - ;; - sro) - provision_relay "$key" \ - "${SRO_RELAY_POSTGRES_RUNTIME_PASSWORD:?missing SRO Relay runtime password}" \ - "${SRO_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD:?missing SRO Relay keyring maintenance password}" \ - "${SRO_RELAY_POSTGRES_KEYRING_READER_PASSWORD:?missing SRO Relay keyring reader password}" - ;; - programme) - provision_relay "$key" \ - "${PROGRAMME_RELAY_POSTGRES_RUNTIME_PASSWORD:?missing programme Relay runtime password}" \ - "${PROGRAMME_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD:?missing programme Relay keyring maintenance password}" \ - "${PROGRAMME_RELAY_POSTGRES_KEYRING_READER_PASSWORD:?missing programme Relay keyring reader password}" - ;; - sipf) - provision_relay "$key" \ - "${SIPF_RELAY_POSTGRES_RUNTIME_PASSWORD:?missing SIPF Relay runtime password}" \ - "${SIPF_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD:?missing SIPF Relay keyring maintenance password}" \ - "${SIPF_RELAY_POSTGRES_KEYRING_READER_PASSWORD:?missing SIPF Relay keyring reader password}" - ;; - nagdi) - provision_relay "$key" \ - "${NAGDI_RELAY_POSTGRES_RUNTIME_PASSWORD:?missing NAgDI Relay runtime password}" \ - "${NAGDI_RELAY_POSTGRES_KEYRING_MAINTENANCE_PASSWORD:?missing NAgDI Relay keyring maintenance password}" \ - "${NAGDI_RELAY_POSTGRES_KEYRING_READER_PASSWORD:?missing NAgDI Relay keyring reader password}" - ;; - *) - echo "Unsupported SOLMARA_RELAY_DATABASES entry" >&2 - exit 1 - ;; - esac -done - -for key in ${SOLMARA_NOTARY_DATABASES:-}; do - case "$key" in - cra) - provision_notary "$key" \ - "${CRA_NOTARY_POSTGRES_MIGRATOR_PASSWORD:?missing CRA Notary migrator password}" \ - "${CRA_NOTARY_POSTGRES_RUNTIME_PASSWORD:?missing CRA Notary runtime password}" - ;; - nia) - provision_notary "$key" \ - "${NIA_NOTARY_POSTGRES_MIGRATOR_PASSWORD:?missing NIA Notary migrator password}" \ - "${NIA_NOTARY_POSTGRES_RUNTIME_PASSWORD:?missing NIA Notary runtime password}" - ;; - sro) - provision_notary "$key" \ - "${SRO_NOTARY_POSTGRES_MIGRATOR_PASSWORD:?missing SRO Notary migrator password}" \ - "${SRO_NOTARY_POSTGRES_RUNTIME_PASSWORD:?missing SRO Notary runtime password}" - ;; - programme) - provision_notary "$key" \ - "${PROGRAMME_NOTARY_POSTGRES_MIGRATOR_PASSWORD:?missing programme Notary migrator password}" \ - "${PROGRAMME_NOTARY_POSTGRES_RUNTIME_PASSWORD:?missing programme Notary runtime password}" - ;; - sipf) - provision_notary "$key" \ - "${SIPF_NOTARY_POSTGRES_MIGRATOR_PASSWORD:?missing SIPF Notary migrator password}" \ - "${SIPF_NOTARY_POSTGRES_RUNTIME_PASSWORD:?missing SIPF Notary runtime password}" - ;; - nagdi) - provision_notary "$key" \ - "${NAGDI_NOTARY_POSTGRES_MIGRATOR_PASSWORD:?missing NAgDI Notary migrator password}" \ - "${NAGDI_NOTARY_POSTGRES_RUNTIME_PASSWORD:?missing NAgDI Notary runtime password}" - ;; - *) - echo "Unsupported SOLMARA_NOTARY_DATABASES entry" >&2 - exit 1 - ;; - esac -done diff --git a/scripts/lifecycle_proof.py b/scripts/lifecycle_proof.py new file mode 100755 index 0000000..0b8d2db --- /dev/null +++ b/scripts/lifecycle_proof.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Prove the two Solmara publisher cadences without touching active data.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sqlite3 +import stat +import sys +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "generator")) + +from solmara_lab.publisher import ( # noqa: E402 + DEFAULT_EXTRACTS, + EVIDENCE_DIRECTORY, + PUBLISHERS, + RELAY_DIRECTORY, + RELAY_FILENAMES, + mutate_mosd_state, + publish_all, + publish_extract, +) + +MAX_EXTRACT_AGE = timedelta(seconds=86_400) +MOSD_TEST_UIN = "2300010248" +SRO_TEST_UIN = "2300010248" + + +class LifecycleProofError(RuntimeError): + """Raised when a lifecycle invariant is not demonstrated.""" + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _database_uri(path: Path) -> str: + return f"file:{path.resolve().as_posix()}?mode=ro" + + +class GovernedMosdObservation: + """A long-lived, read-only source binding like Relay's mounted source.""" + + def __init__(self, database: Path) -> None: + self.database = database.resolve() + self.connection = sqlite3.connect(_database_uri(self.database), uri=True) + + def close(self) -> None: + self.connection.close() + + def observe_duplicate_flag(self, uin: str) -> tuple[int, str, str]: + row = self.connection.execute( + """ + SELECT duplicate_flag, record_revision, recorded_at + FROM relay_beneficiary_enrolment + WHERE uin = ? + """, + (uin,), + ).fetchone() + if row is None: + raise LifecycleProofError("governed MoSD lookup did not resolve exactly once") + return int(row[0]), str(row[1]), str(row[2]) + + +def _parse_timestamp(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise LifecycleProofError("extract timestamp is not timezone-aware") + return parsed.astimezone(timezone.utc) + + +def _validate_sro_extract( + path: Path, + *, + expected_extract_id: str, + observed_at: datetime, +) -> None: + if path.name != f"{expected_extract_id}.sqlite": + raise LifecycleProofError("extract filename does not match its binding") + if stat.S_IMODE(path.stat().st_mode) & 0o222: + raise LifecycleProofError("extract has a writable mode") + + with sqlite3.connect(_database_uri(path), uri=True) as connection: + if connection.execute("PRAGMA quick_check").fetchone() != ("ok",): + raise LifecycleProofError("extract integrity check failed") + metadata = connection.execute( + "SELECT published_at, publisher, extract_id FROM evidence_extract" + ).fetchall() + if len(metadata) != 1: + raise LifecycleProofError("extract metadata cardinality is invalid") + published_at, publisher, extract_id = metadata[0] + if publisher != PUBLISHERS["sro"] or extract_id != expected_extract_id: + raise LifecycleProofError("extract metadata does not match its binding") + published = _parse_timestamp(str(published_at)) + age = observed_at.astimezone(timezone.utc) - published + if age < timedelta(0) or age > MAX_EXTRACT_AGE: + raise LifecycleProofError("extract is outside its accepted age") + + expected_columns = [ + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "poverty_band", + ] + columns = [ + row[1] + for row in connection.execute("PRAGMA table_info(poverty_evidence)") + ] + if columns != expected_columns: + raise LifecycleProofError("extract schema does not match the SRO contract") + + +class SroExtractBinding: + """An Evidence-cell binding fixed to one immutable extract filename.""" + + def __init__( + self, + path: Path, + *, + expected_extract_id: str, + observed_at: datetime, + ) -> None: + self.path = path.resolve() + self.expected_extract_id = expected_extract_id + _validate_sro_extract( + self.path, + expected_extract_id=expected_extract_id, + observed_at=observed_at, + ) + self.connection = sqlite3.connect(_database_uri(self.path), uri=True) + + def close(self) -> None: + self.connection.close() + + def observe_poverty_band(self, uin: str) -> str: + row = self.connection.execute( + "SELECT poverty_band FROM poverty_evidence WHERE uin = ?", (uin,) + ).fetchone() + if row is None: + raise LifecycleProofError("bound SRO extract did not resolve exactly once") + return str(row[0]) + + +def _revision( + *, + record_id: str, + lifecycle_state: str, + recorded_at: str, + uin: str, + poverty_band: str, +) -> str: + value = { + "record_id": record_id, + "lifecycle_state": lifecycle_state, + "recorded_at": recorded_at, + "uin": uin, + "poverty_band": poverty_band, + } + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return f"rev-{hashlib.sha256(encoded).hexdigest()[:16]}" + + +def _publish_changed_sro_extract( + root: Path, + *, + extract_id: str, + published_at: str, + poverty_band: str, + metadata_extract_id: str | None = None, +) -> Path: + """Stage changes before atomically publishing a never-before-used filename.""" + + staging_root = root / ".lifecycle-staging" / extract_id + staged = publish_extract(staging_root, "sro", published_at, extract_id) + staged.chmod(0o600) + with sqlite3.connect(staged) as connection: + current = connection.execute( + """ + SELECT record_id, lifecycle_state, recorded_at + FROM poverty_evidence + WHERE uin = ? + """, + (SRO_TEST_UIN,), + ).fetchone() + if current is None: + raise LifecycleProofError("staged SRO extract is missing its control row") + connection.execute( + """ + UPDATE poverty_evidence + SET poverty_band = ?, record_revision = ? + WHERE uin = ? + """, + ( + poverty_band, + _revision( + record_id=str(current[0]), + lifecycle_state=str(current[1]), + recorded_at=str(current[2]), + uin=SRO_TEST_UIN, + poverty_band=poverty_band, + ), + SRO_TEST_UIN, + ), + ) + if metadata_extract_id is not None: + connection.execute( + "UPDATE evidence_extract SET extract_id = ?", (metadata_extract_id,) + ) + connection.commit() + connection.execute("VACUUM") + staged.chmod(0o444) + + target = root / EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + target.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(staged, target) + except FileExistsError: + raise LifecycleProofError("immutable extract publication refused overwrite") from None + return target + + +def run_proof() -> dict[str, object]: + reference_time = datetime(2026, 7, 5, 8, 30, tzinfo=timezone.utc) + with tempfile.TemporaryDirectory(prefix="solmara-lifecycle-") as temporary: + root = Path(temporary) + publish_all(root) + + mosd = root / RELAY_DIRECTORY / RELAY_FILENAMES["mosd"] + mosd_inode = (mosd.stat().st_dev, mosd.stat().st_ino) + mosd_binding = GovernedMosdObservation(mosd) + try: + connection_identity = id(mosd_binding.connection) + before = mosd_binding.observe_duplicate_flag(MOSD_TEST_UIN) + mutate_mosd_state( + mosd, + MOSD_TEST_UIN, + True, + "2026-07-05T08:15:00Z", + ) + after = mosd_binding.observe_duplicate_flag(MOSD_TEST_UIN) + if (before[0], after[0]) != (0, 1): + raise LifecycleProofError("live MoSD state did not change on next observation") + if before[1] == after[1] or before[2] == after[2]: + raise LifecycleProofError("live MoSD revision metadata did not advance") + if (mosd.stat().st_dev, mosd.stat().st_ino) != mosd_inode: + raise LifecycleProofError("live MoSD mutation replaced the mounted inode") + if id(mosd_binding.connection) != connection_identity: + raise LifecycleProofError("live MoSD source binding was restarted") + finally: + mosd_binding.close() + + original_id = DEFAULT_EXTRACTS["sro"] + original = root / EVIDENCE_DIRECTORY / f"{original_id}.sqlite" + original_digest = _digest(original) + original_inode = (original.stat().st_dev, original.stat().st_ino) + old_binding = SroExtractBinding( + original, + expected_extract_id=original_id, + observed_at=reference_time, + ) + replacement_id = "sro-poverty-20260705T080000Z" + replacement = _publish_changed_sro_extract( + root, + extract_id=replacement_id, + published_at="2026-07-05T08:00:00Z", + poverty_band="not_eligible", + ) + try: + old_result_before = old_binding.observe_poverty_band(SRO_TEST_UIN) + old_result_after_publication = old_binding.observe_poverty_band(SRO_TEST_UIN) + if old_result_before != old_result_after_publication: + raise LifecycleProofError("active SRO binding changed before a restart") + if _digest(original) != original_digest: + raise LifecycleProofError("active immutable SRO extract was overwritten") + if (original.stat().st_dev, original.stat().st_ino) != original_inode: + raise LifecycleProofError("active immutable SRO extract was replaced") + try: + publish_extract(root, "sro", "2026-07-05T08:00:00Z", original_id) + except FileExistsError: + pass + else: + raise LifecycleProofError("publisher allowed an active extract overwrite") + finally: + old_binding.close() + + rebound = SroExtractBinding( + replacement, + expected_extract_id=replacement_id, + observed_at=reference_time, + ) + try: + rebound_result = rebound.observe_poverty_band(SRO_TEST_UIN) + finally: + rebound.close() + if rebound_result == old_result_after_publication: + raise LifecycleProofError("SRO-only rebind did not expose the new assertion input") + + stale_id = "sro-poverty-20260703T080000Z" + stale = _publish_changed_sro_extract( + root, + extract_id=stale_id, + published_at="2026-07-03T08:00:00Z", + poverty_band="not_eligible", + ) + try: + SroExtractBinding( + stale, + expected_extract_id=stale_id, + observed_at=reference_time, + ) + except LifecycleProofError: + pass + else: + raise LifecycleProofError("stale SRO extract did not fail closed") + + invalid_id = "sro-poverty-20260705T081000Z" + invalid = _publish_changed_sro_extract( + root, + extract_id=invalid_id, + published_at="2026-07-05T08:10:00Z", + poverty_band="not_eligible", + metadata_extract_id="metadata-does-not-match-binding", + ) + try: + SroExtractBinding( + invalid, + expected_extract_id=invalid_id, + observed_at=reference_time, + ) + except LifecycleProofError: + pass + else: + raise LifecycleProofError("invalid SRO extract did not fail closed") + + return { + "status": "pass", + "fixtureState": "isolated-temporary-directory", + "checks": { + "mosdInPlaceMutationVisibleWithoutRestart": True, + "sroNewFilenameRequired": True, + "sroOldBindingStableUntilRebind": True, + "activeExtractOverwriteDenied": True, + "staleReplacementDenied": True, + "invalidReplacementDenied": True, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--json", + action="store_true", + help="print the sanitized result as compact JSON", + ) + args = parser.parse_args() + result = run_proof() + if args.json: + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + else: + print("Solmara lifecycle proof: PASS") + for check in result["checks"]: + print(f"- {check}: PASS") + print("- fixtureState: isolated temporary directory") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/live-lifecycle-proof.py b/scripts/live-lifecycle-proof.py new file mode 100755 index 0000000..c567c89 --- /dev/null +++ b/scripts/live-lifecycle-proof.py @@ -0,0 +1,839 @@ +#!/usr/bin/env python3 +"""Prove Solmara's live Relay and immutable-extract cadences over HTTP. + +The proof deliberately emits only named checks. Selectors, tokens, signed JWS +bytes, source values, extract names, and private operational data stay inside +the process and are never copied into its result or errors. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shlex +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import time +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path, PurePosixPath +from typing import Any, Protocol + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(ROOT / "generator") not in sys.path: + sys.path.insert(0, str(ROOT / "generator")) + +from scenarios import common as evidence_common # noqa: E402 +from scenarios.service_config import requirement_config, service_url # noqa: E402 +from solmara_lab import publisher # noqa: E402 + +MOSD_SERVICE = "mosd-programme-evidence" +MOSD_RELAY_SERVICE = "mosd-relay" +SRO_SERVICE = "sro-evidence" +NON_SRO_SERVICES = ( + "cra-relay", + "nia-relay", + MOSD_RELAY_SERVICE, + "sipf-relay", + "nagdi-relay", + "mint", + "cra-evidence", + "nia-evidence", + MOSD_SERVICE, + "sipf-evidence", + "nagdi-evidence", +) +TOPOLOGY_SERVICES = (*NON_SRO_SERVICES, SRO_SERVICE) +MOSD_REQUIREMENT = "programme-child-benefit" +SRO_REQUIREMENT = "sro-child-benefit" +MOSD_CONTROL_SUBJECT = "2300010248" +SRO_CONTROL_SUBJECT = "2300010248" +MOSD_CONCEPT = "not-already-enrolled" +SRO_CONCEPT = "household-below-poverty-threshold" +MOSD_PUBLISHER_SERVICE = "mosd-source-publisher" +MOSD_SOURCE_VOLUME = "mosd-relay-source" +RELAY_SOURCE_DESTINATION = "/var/lib/relay/source" +SRO_RUNTIME = Path("runtime/evidence-cells/cells/sro/runtime.yaml") +SRO_PROFILE = "sro-poverty-extract" +SRO_CONTAINER_DIRECTORY = PurePosixPath("/var/lib/registry-evidence/sro/extracts") +READINESS_ATTEMPTS = 45 +READINESS_INTERVAL_SECONDS = 1.0 +REFUSAL_OBSERVATION_SECONDS = 8.0 + +_PUBLIC_ERROR = "lifecycle proof did not complete" +_FORBIDDEN_OUTPUT_PATTERNS = ( + re.compile(r"\b(?:uin|selector|token|authorization|jws|signature|payload)\b", re.I), + re.compile(r"\b(?:poverty_band|duplicate_flag|record_revision|source row)\b", re.I), + re.compile(r"\b(?:\.sqlite|runtime\.yaml|/var/lib/)\b", re.I), + re.compile(r"\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b"), +) + + +class LifecycleProofError(RuntimeError): + """A value-free lifecycle proof failure.""" + + +class Operations(Protocol): + def load_environment(self) -> None: ... + + def validate_preconditions(self) -> None: ... + + def observe_evidence(self, requirement: str) -> bool: ... + + def generation(self, service: str) -> str: ... + + def capture_mosd_fixture(self) -> "MosdFixture": ... + + def mutate_mosd(self, duplicate: bool) -> None: ... + + def restore_mosd_fixture(self, fixture: "MosdFixture") -> None: ... + + def capture_sro_binding(self) -> bytes: ... + + def publish_changed_sro(self) -> "ExtractPublication": ... + + def bind_sro(self, publication: "ExtractPublication") -> None: ... + + def restart_sro(self) -> None: ... + + def wait_sro_ready(self) -> None: ... + + def prove_replacement_refusals(self) -> None: ... + + def restore_sro_binding(self, original: bytes) -> None: ... + + def discard_sro_publication(self, publication: "ExtractPublication") -> None: ... + + +@dataclass(frozen=True) +class ExtractPublication: + path: Path + container_path: str + extract_id: str + + +@dataclass(frozen=True) +class MosdFixture: + publisher_backup_started: bool + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _timestamp(value: datetime) -> str: + return ( + value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + ) + + +def _run_command( + command: Sequence[str], *, cwd: Path = ROOT, check: bool = True +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=cwd, + check=check, + capture_output=True, + text=True, + ) + + +def _load_dotenv(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, raw_value = line.split("=", 1) + key = key.strip() + if not key or key in os.environ: + continue + parts = shlex.split(raw_value, posix=True) + os.environ[key] = parts[0] if parts else "" + + +def _compose_command(*arguments: str) -> list[str]: + return [ + "docker", + "compose", + "--env-file", + "versions.env", + "--env-file", + ".env", + "-f", + "compose.yaml", + *arguments, + ] + + +def _safe_failure(error: BaseException) -> LifecycleProofError: + return LifecycleProofError(_PUBLIC_ERROR) + + +def _claim_value(result: evidence_common.StepHttpResult, concept: str) -> bool: + body = result.body if isinstance(result.body, dict) else {} + values = { + member.get("claim_id"): member.get("value") + for member in body.get("results", []) + if isinstance(member, dict) + } + value = values.get(concept) + if not isinstance(value, bool): + raise LifecycleProofError(_PUBLIC_ERROR) + return value + + +def _verify_signed_observation(*, service_id: str, subject: str, purpose: str) -> bool: + token = evidence_common.evidence_access_token() + if not token: + raise LifecycleProofError(_PUBLIC_ERROR) + config = requirement_config(service_id) + request = evidence_common.evidence_body( + subject, + str(config["requirement"]), + purpose, + ) + response = evidence_common.http_json( + "POST", + service_url(service_id), + evidence_common.evidence_headers(token), + request, + timeout=10.0, + ) + verified = evidence_common.normalized_evidence_result( + response, + request=request, + service_id=service_id, + ) + if verified.status != 200: + raise LifecycleProofError(_PUBLIC_ERROR) + concept = MOSD_CONCEPT if service_id == MOSD_REQUIREMENT else SRO_CONCEPT + return _claim_value(verified, concept) + + +def _load_sro_binding(path: Path) -> tuple[dict[str, Any], str, str]: + try: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + extracts = document["sourceExtracts"] + if set(extracts) != {SRO_PROFILE}: + raise ValueError + binding = extracts[SRO_PROFILE] + if set(binding) != {"path"} or not isinstance(binding["path"], str): + raise ValueError + container_path = PurePosixPath(binding["path"]) + if ( + container_path.parent != SRO_CONTAINER_DIRECTORY + or not container_path.name.startswith("sro-poverty-") + or container_path.suffix != ".sqlite" + ): + raise ValueError + return document, binding["path"], container_path.stem + except (KeyError, OSError, TypeError, UnicodeError, ValueError, yaml.YAMLError): + raise LifecycleProofError(_PUBLIC_ERROR) from None + + +def _replace_runtime_binding(path: Path, container_path: str) -> None: + _, old_container_path, _ = _load_sro_binding(path) + new_path = PurePosixPath(container_path) + if ( + new_path.parent != SRO_CONTAINER_DIRECTORY + or not new_path.name.startswith("sro-poverty-") + or new_path.suffix != ".sqlite" + ): + raise LifecycleProofError(_PUBLIC_ERROR) + original = path.read_text(encoding="utf-8") + pattern = re.compile(rf"(? None: + original_mode = stat.S_IMODE(path.stat().st_mode) + directory_mode = stat.S_IMODE(path.parent.stat().st_mode) + path.parent.chmod(directory_mode | stat.S_IWUSR) + temporary = path.with_name(f".{path.name}.lifecycle-{os.getpid()}.tmp") + try: + if temporary.exists(): + temporary.unlink() + temporary.write_bytes(content) + temporary.chmod(original_mode) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + path.parent.chmod(directory_mode) + + +def _publish_changed_sro(root: Path) -> ExtractPublication: + published_at = _timestamp(_now()) + extract_id = publisher.timestamped_extract_id("sro", published_at) + target = publisher.extract_path(root, extract_id) + if target.exists() or target.is_symlink(): + raise LifecycleProofError(_PUBLIC_ERROR) + staging_parent = root / "runtime" + staging_parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".lifecycle-sro-", dir=staging_parent + ) as temporary: + staged = publisher.publish_extract( + Path(temporary), "sro", published_at, extract_id + ) + original_mode = stat.S_IMODE(staged.stat().st_mode) + try: + staged.chmod(original_mode | stat.S_IWUSR) + with sqlite3.connect(staged) as connection: + row = connection.execute( + """ + SELECT record_id, lifecycle_state, recorded_at + FROM poverty_evidence + WHERE uin = ? + """, + (SRO_CONTROL_SUBJECT,), + ).fetchone() + if row is None: + raise LifecycleProofError(_PUBLIC_ERROR) + changed = "standard" + revision_input = { + "record_id": str(row[0]), + "lifecycle_state": str(row[1]), + "recorded_at": str(row[2]), + "uin": SRO_CONTROL_SUBJECT, + "poverty_band": changed, + } + revision = ( + "rev-" + + hashlib.sha256( + json.dumps( + revision_input, sort_keys=True, separators=(",", ":") + ).encode() + ).hexdigest()[:16] + ) + connection.execute( + """ + UPDATE poverty_evidence + SET poverty_band = ?, record_revision = ? + WHERE uin = ? + """, + (changed, revision, SRO_CONTROL_SUBJECT), + ) + connection.commit() + connection.execute("VACUUM") + finally: + staged.chmod(0o444) + target.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(staged, target) + except FileExistsError: + raise LifecycleProofError(_PUBLIC_ERROR) from None + try: + publisher.validate_extract( + target, + "sro", + observed_at=published_at, + expected_extract_id=extract_id, + expected_published_at=published_at, + ) + except BaseException: + target.unlink(missing_ok=True) + raise + return ExtractPublication( + target, + str(SRO_CONTAINER_DIRECTORY / target.name), + extract_id, + ) + + +def _publish_invalid_sro(root: Path, *, stale: bool) -> Path: + published_at = _timestamp(_now() - (timedelta(days=2) if stale else timedelta())) + suffix = "stale" if stale else "invalid" + base_id = publisher.timestamped_extract_id("sro", published_at) + extract_id = f"{base_id}-{suffix}-{os.getpid()}" + path = publisher.publish_extract(root, "sro", published_at, extract_id) + if not stale: + original_mode = stat.S_IMODE(path.stat().st_mode) + path.chmod(original_mode | stat.S_IWUSR) + try: + with sqlite3.connect(path) as connection: + connection.execute( + "UPDATE evidence_extract SET published_at = ?", ("invalid",) + ) + connection.commit() + except BaseException: + path.chmod(0o444) + path.unlink(missing_ok=True) + raise + finally: + if path.exists(): + path.chmod(0o444) + return path + + +class LocalOperations: + """Real local runtime operations, with all sensitive values kept internal.""" + + def __init__(self, root: Path = ROOT) -> None: + self.root = root.resolve() + self.sro_runtime = self.root / SRO_RUNTIME + + def load_environment(self) -> None: + _load_dotenv(self.root / ".env") + os.environ.pop("SOLMARA_EVIDENCE_ACCESS_TOKEN", None) + os.environ["SOLMARA_MINT_URL"] = "https://localhost:4341" + os.environ["SOLMARA_MINT_ASSERTION_AUDIENCE"] = ( + "https://mint.solmara.registrystack.org/token" + ) + os.environ["SOLMARA_EVIDENCE_CLIENT_ID"] = "solmara-demo" + os.environ["SOLMARA_EVIDENCE_CLIENT_KEY"] = str( + self.root + / "config/evidence/local/cells/mint/clients/solmara-demo-client-key" + ) + os.environ["SOLMARA_EVIDENCE_CA_BUNDLE"] = str( + self.root / "config/evidence/local/tls/ca.crt" + ) + os.environ["SOLMARA_SRO_EVIDENCE_URL"] = "https://localhost:4341/evidence/sro" + os.environ["SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL"] = ( + "https://localhost:4341/evidence/mosd-programme" + ) + os.environ["SOLMARA_EVIDENCE_AUDIENCE"] = evidence_common.EVIDENCE_AUDIENCE + evidence_common._TOKEN_CACHE = ("", 0.0) + evidence_common._JWKS_CACHE.clear() + + def validate_preconditions(self) -> None: + required_files = ( + self.sro_runtime, + self.root / "scripts/local-relay-source-publisher.py", + Path(os.environ["SOLMARA_EVIDENCE_CLIENT_KEY"]), + Path(os.environ["SOLMARA_EVIDENCE_CA_BUNDLE"]), + ) + if not all(path.is_file() for path in required_files): + raise LifecycleProofError(_PUBLIC_ERROR) + _, _, bound_id = _load_sro_binding(self.sro_runtime) + bound = publisher.extract_path(self.root, bound_id) + publisher.validate_extract( + bound, + "sro", + observed_at=_timestamp(_now()), + expected_extract_id=bound_id, + ) + for service in TOPOLOGY_SERVICES: + self.generation(service) + self._require_read_only_named_volume( + MOSD_RELAY_SERVICE, + MOSD_SOURCE_VOLUME, + RELAY_SOURCE_DESTINATION, + ) + self._require_bind_mount(SRO_SERVICE, self.sro_runtime.parent) + self._require_bind_mount(SRO_SERVICE, self.root / publisher.EVIDENCE_DIRECTORY) + + def _require_bind_mount(self, service: str, expected: Path) -> None: + completed = _run_command(_compose_command("ps", "-q", service)) + container_id = completed.stdout.strip() + if not container_id: + raise LifecycleProofError(_PUBLIC_ERROR) + inspection = _run_command( + ["docker", "inspect", "--format", "{{json .Mounts}}", container_id] + ) + try: + mounts = json.loads(inspection.stdout) + if not isinstance(mounts, list): + raise ValueError + matched = any( + isinstance(mount, dict) + and mount.get("Type") == "bind" + and isinstance(mount.get("Source"), str) + and Path(mount["Source"]).exists() + and os.path.samefile(mount["Source"], expected) + for mount in mounts + ) + except (OSError, TypeError, ValueError, json.JSONDecodeError): + raise LifecycleProofError(_PUBLIC_ERROR) from None + if not matched: + raise LifecycleProofError(_PUBLIC_ERROR) + + def _require_read_only_named_volume( + self, service: str, volume: str, destination: str + ) -> None: + completed = _run_command(_compose_command("ps", "-q", service)) + container_id = completed.stdout.strip() + if not container_id: + raise LifecycleProofError(_PUBLIC_ERROR) + inspection = _run_command( + ["docker", "inspect", "--format", "{{json .Mounts}}", container_id] + ) + project = os.environ.get("COMPOSE_PROJECT_NAME", "") + expected_name = f"{project}_{volume}" if project else "" + try: + mounts = json.loads(inspection.stdout) + if not isinstance(mounts, list): + raise ValueError + matched = any( + isinstance(mount, dict) + and mount.get("Type") == "volume" + and mount.get("Destination") == destination + and mount.get("RW") is False + and isinstance(mount.get("Name"), str) + and ( + mount["Name"] == expected_name + if expected_name + else mount["Name"].endswith(f"_{volume}") + ) + for mount in mounts + ) + except (TypeError, ValueError, json.JSONDecodeError): + raise LifecycleProofError(_PUBLIC_ERROR) from None + if not matched: + raise LifecycleProofError(_PUBLIC_ERROR) + + def observe_evidence(self, requirement: str) -> bool: + purpose = ( + evidence_common.PURPOSES["child_benefit"] + if requirement in {MOSD_REQUIREMENT, SRO_REQUIREMENT} + else "" + ) + subject = ( + MOSD_CONTROL_SUBJECT + if requirement == MOSD_REQUIREMENT + else SRO_CONTROL_SUBJECT + ) + return _verify_signed_observation( + service_id=requirement, + subject=subject, + purpose=purpose, + ) + + def generation(self, service: str) -> str: + completed = _run_command(_compose_command("ps", "--format", "json", service)) + try: + parsed = json.loads(completed.stdout) + documents = parsed if isinstance(parsed, list) else [parsed] + except json.JSONDecodeError: + documents = [ + json.loads(line) + for line in completed.stdout.splitlines() + if line.strip() + ] + if len(documents) != 1: + raise LifecycleProofError(_PUBLIC_ERROR) + document = documents[0] + container_id = document.get("ID") + state = document.get("State") + if not isinstance(container_id, str) or not container_id or state != "running": + raise LifecycleProofError(_PUBLIC_ERROR) + inspection = _run_command( + [ + "docker", + "inspect", + "--format", + "{{.Id}} {{.State.StartedAt}} {{.RestartCount}}", + container_id, + ] + ).stdout.strip() + if not inspection: + raise LifecycleProofError(_PUBLIC_ERROR) + return inspection + + def capture_mosd_fixture(self) -> MosdFixture: + try: + self._run_mosd_publisher("begin-proof") + except BaseException: + try: + self._run_mosd_publisher("restore-proof") + except BaseException: + pass + raise LifecycleProofError(_PUBLIC_ERROR) from None + return MosdFixture(publisher_backup_started=True) + + def mutate_mosd(self, duplicate: bool) -> None: + if duplicate is not True: + raise LifecycleProofError(_PUBLIC_ERROR) + self._run_mosd_publisher("set-proof-state") + + def restore_mosd_fixture(self, fixture: MosdFixture) -> None: + if fixture.publisher_backup_started is not True: + raise LifecycleProofError(_PUBLIC_ERROR) + self._run_mosd_publisher("restore-proof") + + def _run_mosd_publisher(self, verb: str) -> None: + if verb not in {"begin-proof", "set-proof-state", "restore-proof"}: + raise LifecycleProofError(_PUBLIC_ERROR) + _run_command( + _compose_command( + "run", + "--rm", + "-T", + "--no-deps", + MOSD_PUBLISHER_SERVICE, + verb, + ) + ) + + def capture_sro_binding(self) -> bytes: + _load_sro_binding(self.sro_runtime) + return self.sro_runtime.read_bytes() + + def publish_changed_sro(self) -> ExtractPublication: + return _publish_changed_sro(self.root) + + def bind_sro(self, publication: ExtractPublication) -> None: + _replace_runtime_binding(self.sro_runtime, publication.container_path) + + def restart_sro(self) -> None: + _run_command(_compose_command("restart", "--no-deps", SRO_SERVICE)) + + def wait_sro_ready(self) -> None: + last_error: BaseException | None = None + for _ in range(READINESS_ATTEMPTS): + try: + result = evidence_common.http_json( + "GET", + service_url(SRO_REQUIREMENT, "/ready"), + {}, + timeout=2.0, + ) + if result.status in {200, 204}: + return + except BaseException as error: + last_error = error + time.sleep(READINESS_INTERVAL_SECONDS) + raise _safe_failure(last_error or LifecycleProofError(_PUBLIC_ERROR)) + + def _wait_sro_refused(self) -> None: + deadline = time.monotonic() + REFUSAL_OBSERVATION_SECONDS + while time.monotonic() < deadline: + # Readiness describes process health, so it may remain healthy when + # a bound extract is refused. Once ready, require the signed public + # operation itself to fail; otherwise the bounded absence of + # readiness is the fail-closed path. + try: + result = evidence_common.http_json( + "GET", + service_url(SRO_REQUIREMENT, "/ready"), + {}, + timeout=1.0, + ) + if result.status in {200, 204}: + break + except BaseException: + pass + time.sleep(0.5) + try: + self.observe_evidence(SRO_REQUIREMENT) + except LifecycleProofError: + return + raise LifecycleProofError(_PUBLIC_ERROR) + + def prove_replacement_refusals(self) -> None: + active_document, _, active_id = _load_sro_binding(self.sro_runtime) + active_runtime = self.sro_runtime.read_bytes() + active = publisher.extract_path(self.root, active_id) + before = active.read_bytes() + try: + publisher.publish_extract( + self.root, + "sro", + _timestamp(_now()), + active_id, + ) + except FileExistsError: + pass + else: + raise LifecycleProofError(_PUBLIC_ERROR) + if active.read_bytes() != before: + raise LifecycleProofError(_PUBLIC_ERROR) + if not isinstance(active_document, dict): + raise LifecycleProofError(_PUBLIC_ERROR) + + invalid_paths: list[Path] = [] + try: + for stale in (True, False): + candidate = _publish_invalid_sro(self.root, stale=stale) + invalid_paths.append(candidate) + _replace_runtime_binding( + self.sro_runtime, + str(SRO_CONTAINER_DIRECTORY / candidate.name), + ) + self.restart_sro() + self._wait_sro_refused() + _replace_file(self.sro_runtime, active_runtime) + self.restart_sro() + self.wait_sro_ready() + finally: + _replace_file(self.sro_runtime, active_runtime) + try: + self.restart_sro() + self.wait_sro_ready() + except BaseException: + pass + for candidate in invalid_paths: + candidate.unlink(missing_ok=True) + + def restore_sro_binding(self, original: bytes) -> None: + _replace_file(self.sro_runtime, original) + + def discard_sro_publication(self, publication: ExtractPublication) -> None: + _, _, active_id = _load_sro_binding(self.sro_runtime) + if active_id == publication.extract_id: + raise LifecycleProofError(_PUBLIC_ERROR) + publication.path.unlink(missing_ok=True) + + +def run_proof(operations: Operations | None = None) -> dict[str, Any]: + """Run the live proof and return a deliberately value-free result.""" + + operations = operations or LocalOperations() + checks: dict[str, bool] = { + "signed-http-observations": False, + "relay-live-change-without-restart": False, + "extract-stays-bound-until-restart": False, + "sro-only-restart-activates-publication": False, + "replacement-failures-close": False, + "deterministic-state-restored": False, + } + original_binding: bytes | None = None + initial_generations: dict[str, str] = {} + published: ExtractPublication | None = None + mosd_fixture: MosdFixture | None = None + sro_may_need_restore = False + primary_error: BaseException | None = None + cleanup_error: BaseException | None = None + + try: + operations.load_environment() + operations.validate_preconditions() + initial_generations = { + service: operations.generation(service) for service in TOPOLOGY_SERVICES + } + mosd_fixture = operations.capture_mosd_fixture() + original_binding = operations.capture_sro_binding() + + mosd_before = operations.observe_evidence(MOSD_REQUIREMENT) + sro_before = operations.observe_evidence(SRO_REQUIREMENT) + if mosd_before is not True or sro_before is not True: + raise LifecycleProofError(_PUBLIC_ERROR) + checks["signed-http-observations"] = True + + operations.mutate_mosd(True) + mosd_after = operations.observe_evidence(MOSD_REQUIREMENT) + if mosd_after is not False: + raise LifecycleProofError(_PUBLIC_ERROR) + for service in (MOSD_RELAY_SERVICE, MOSD_SERVICE): + if operations.generation(service) != initial_generations[service]: + raise LifecycleProofError(_PUBLIC_ERROR) + checks["relay-live-change-without-restart"] = True + + published = operations.publish_changed_sro() + if operations.observe_evidence(SRO_REQUIREMENT) is not sro_before: + raise LifecycleProofError(_PUBLIC_ERROR) + + sro_may_need_restore = True + operations.bind_sro(published) + if operations.observe_evidence(SRO_REQUIREMENT) is not sro_before: + raise LifecycleProofError(_PUBLIC_ERROR) + checks["extract-stays-bound-until-restart"] = True + + operations.restart_sro() + operations.wait_sro_ready() + if operations.observe_evidence(SRO_REQUIREMENT) is not False: + raise LifecycleProofError(_PUBLIC_ERROR) + for service in NON_SRO_SERVICES: + if operations.generation(service) != initial_generations[service]: + raise LifecycleProofError(_PUBLIC_ERROR) + if operations.generation(SRO_SERVICE) == initial_generations[SRO_SERVICE]: + raise LifecycleProofError(_PUBLIC_ERROR) + checks["sro-only-restart-activates-publication"] = True + + operations.prove_replacement_refusals() + for service in NON_SRO_SERVICES: + if operations.generation(service) != initial_generations[service]: + raise LifecycleProofError(_PUBLIC_ERROR) + checks["replacement-failures-close"] = True + except BaseException as error: + primary_error = error + finally: + cleanup_errors: list[BaseException] = [] + if mosd_fixture is not None: + try: + operations.restore_mosd_fixture(mosd_fixture) + except BaseException as error: + cleanup_errors.append(error) + sro_restored = original_binding is None + if original_binding is not None: + try: + operations.restore_sro_binding(original_binding) + if sro_may_need_restore: + operations.restart_sro() + operations.wait_sro_ready() + sro_restored = True + except BaseException as error: + cleanup_errors.append(error) + if published is not None and sro_restored: + try: + operations.discard_sro_publication(published) + except BaseException as error: + cleanup_errors.append(error) + if primary_error is None and not cleanup_errors: + try: + if operations.observe_evidence(MOSD_REQUIREMENT) is not True: + raise LifecycleProofError(_PUBLIC_ERROR) + if operations.observe_evidence(SRO_REQUIREMENT) is not True: + raise LifecycleProofError(_PUBLIC_ERROR) + for service in NON_SRO_SERVICES: + if operations.generation(service) != initial_generations[service]: + raise LifecycleProofError(_PUBLIC_ERROR) + checks["deterministic-state-restored"] = True + except BaseException as error: + cleanup_errors.append(error) + if cleanup_errors: + cleanup_error = cleanup_errors[0] + + if primary_error is not None or cleanup_error is not None: + raise _safe_failure( + cleanup_error or primary_error or LifecycleProofError(_PUBLIC_ERROR) + ) + if not all(checks.values()): + raise LifecycleProofError(_PUBLIC_ERROR) + return {"status": "pass", "proof": "live-http", "checks": checks} + + +def _assert_sanitized(value: str) -> None: + for pattern in _FORBIDDEN_OUTPUT_PATTERNS: + if pattern.search(value): + raise LifecycleProofError("proof output violated its redaction contract") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Prove Relay-live and immutable-extract Evidence lifecycles" + ) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + try: + result = run_proof() + rendered = json.dumps(result, sort_keys=True) + _assert_sanitized(rendered) + if args.json: + print(rendered) + else: + print("live-lifecycle-proof: pass") + return 0 + except BaseException: + print("live-lifecycle-proof: failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/local-relay-runtime-stager.py b/scripts/local-relay-runtime-stager.py new file mode 100755 index 0000000..6743549 --- /dev/null +++ b/scripts/local-relay-runtime-stager.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +"""Stage one authority's Relay runtime and sealed package into a local volume.""" + +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import re +import stat +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +AUTHORITIES = ("cra", "nia", "mosd", "sipf", "nagdi") +TARGET_UID = 65532 +TARGET_GID = 65532 +MAX_RUNTIME_BYTES = 64 * 1024 +MAX_MANIFEST_BYTES = 1024 * 1024 +MAX_PACKAGE_FILE_BYTES = 1024 * 1024 +MAX_PACKAGE_BYTES = 8 * 1024 * 1024 +MAX_PACKAGE_FILES = 128 +SUCCESS_MESSAGE = "Relay runtime staged" +FAILURE_MESSAGE = "Relay runtime staging failed" +LOCK_NAME = ".stager.lock" +STAGING_PACKAGE = ".staging-package" +STAGING_RUNTIME = ".staging-runtime.yaml" + + +class StagingError(RuntimeError): + """A deliberately value-free staging failure.""" + + +class QuietArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + del message + raise StagingError(FAILURE_MESSAGE) + + +@dataclass(frozen=True) +class FileSnapshot: + device: int + inode: int + mode: int + links: int + size: int + modified_ns: int + + +def _fail() -> StagingError: + return StagingError(FAILURE_MESSAGE) + + +def _snapshot(metadata: os.stat_result) -> FileSnapshot: + return FileSnapshot( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_nlink, + metadata.st_size, + metadata.st_mtime_ns, + ) + + +def _safe_file(metadata: os.stat_result, *, max_bytes: int) -> bool: + mode = stat.S_IMODE(metadata.st_mode) + return ( + stat.S_ISREG(metadata.st_mode) + and metadata.st_nlink == 1 + and 0 <= metadata.st_size <= max_bytes + and mode in {0o400, 0o440, 0o444, 0o600, 0o640, 0o644} + ) + + +def _safe_directory(metadata: os.stat_result) -> bool: + return stat.S_ISDIR(metadata.st_mode) and stat.S_IMODE(metadata.st_mode) in { + 0o500, + 0o550, + 0o555, + 0o700, + 0o750, + 0o755, + } + + +def _open_directory(path: Path) -> int: + return os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + + +def _scan_tree(directory_fd: int) -> tuple[dict[str, FileSnapshot], set[str]]: + files: dict[str, FileSnapshot] = {} + directories: set[str] = set() + total_bytes = 0 + + def visit(parent_fd: int, prefix: PurePosixPath) -> None: + nonlocal total_bytes + with os.scandir(parent_fd) as iterator: + entries = sorted(iterator, key=lambda entry: entry.name) + for entry in entries: + if entry.name in {".", ".."} or "/" in entry.name: + raise _fail() + metadata = entry.stat(follow_symlinks=False) + relative = str(prefix / entry.name) + if stat.S_ISDIR(metadata.st_mode): + if not _safe_directory(metadata): + raise _fail() + directories.add(relative) + child_fd = os.open( + entry.name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + try: + if _snapshot(os.fstat(child_fd)) != _snapshot(metadata): + raise _fail() + visit(child_fd, prefix / entry.name) + finally: + os.close(child_fd) + continue + if not _safe_file(metadata, max_bytes=MAX_PACKAGE_FILE_BYTES): + raise _fail() + files[relative] = _snapshot(metadata) + total_bytes += metadata.st_size + if len(files) > MAX_PACKAGE_FILES or total_bytes > MAX_PACKAGE_BYTES: + raise _fail() + + visit(directory_fd, PurePosixPath()) + return files, directories + + +def _open_relative(directory_fd: int, relative: str) -> int: + parts = PurePosixPath(relative).parts + if not parts: + raise _fail() + current_fd = os.dup(directory_fd) + try: + for part in parts[:-1]: + next_fd = os.open( + part, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=current_fd, + ) + os.close(current_fd) + current_fd = next_fd + return os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=current_fd) + finally: + os.close(current_fd) + + +def _read_exact( + directory_fd: int, + relative: str, + expected: FileSnapshot, + *, + max_bytes: int, +) -> bytes: + descriptor = _open_relative(directory_fd, relative) + try: + before = os.fstat(descriptor) + if _snapshot(before) != expected or not _safe_file(before, max_bytes=max_bytes): + raise _fail() + chunks: list[bytes] = [] + copied = 0 + while True: + chunk = os.read(descriptor, min(65536, max_bytes + 1 - copied)) + if not chunk: + break + copied += len(chunk) + if copied > max_bytes: + raise _fail() + chunks.append(chunk) + after = os.fstat(descriptor) + if _snapshot(after) != expected or copied != expected.size: + raise _fail() + return b"".join(chunks) + finally: + os.close(descriptor) + + +def _manifest_inventory(manifest_bytes: bytes) -> dict[str, tuple[int, str]]: + try: + document = json.loads(manifest_bytes) + files = document["files"] + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + raise _fail() from None + if not isinstance(document, dict) or not isinstance(files, list) or not files: + raise _fail() + + inventory: dict[str, tuple[int, str]] = {} + digest_pattern = re.compile(r"sha256:[0-9a-f]{64}") + for record in files: + if not isinstance(record, dict): + raise _fail() + path = record.get("path") + size = record.get("size") + digest = record.get("sha256") + if ( + not isinstance(path, str) + or not path + or len(path) > 512 + or PurePosixPath(path).is_absolute() + or any(part in {"", ".", ".."} for part in PurePosixPath(path).parts) + or not isinstance(size, int) + or isinstance(size, bool) + or size < 0 + or size > MAX_PACKAGE_FILE_BYTES + or not isinstance(digest, str) + or digest_pattern.fullmatch(digest) is None + or path in inventory + or path == "relay-package.json" + ): + raise _fail() + inventory[path] = (size, digest.removeprefix("sha256:")) + if len(inventory) + 1 > MAX_PACKAGE_FILES: + raise _fail() + return inventory + + +def _read_package(directory_fd: int) -> dict[str, bytes]: + files, directories = _scan_tree(directory_fd) + manifest_snapshot = files.get("relay-package.json") + if manifest_snapshot is None or manifest_snapshot.size > MAX_MANIFEST_BYTES: + raise _fail() + manifest_bytes = _read_exact( + directory_fd, + "relay-package.json", + manifest_snapshot, + max_bytes=MAX_MANIFEST_BYTES, + ) + inventory = _manifest_inventory(manifest_bytes) + if set(files) != set(inventory) | {"relay-package.json"}: + raise _fail() + expected_directories = { + str(parent) + for path in inventory + for parent in PurePosixPath(path).parents + if str(parent) != "." + } + if directories != expected_directories: + raise _fail() + + payloads = {"relay-package.json": manifest_bytes} + total_bytes = len(manifest_bytes) + for path, (expected_size, expected_digest) in sorted(inventory.items()): + payload = _read_exact( + directory_fd, + path, + files[path], + max_bytes=MAX_PACKAGE_FILE_BYTES, + ) + if len(payload) != expected_size: + raise _fail() + if hashlib.sha256(payload).hexdigest() != expected_digest: + raise _fail() + payloads[path] = payload + total_bytes += len(payload) + if total_bytes > MAX_PACKAGE_BYTES: + raise _fail() + + if _scan_tree(directory_fd) != (files, directories): + raise _fail() + return payloads + + +def _validate_runtime(runtime: bytes, authority: str) -> None: + try: + text = runtime.decode("utf-8") + except UnicodeDecodeError: + raise _fail() from None + expected = f"/etc/relay/{authority}/package" + exact_package_path = re.compile( + rf"^packagePath:[ \t]+(?:{re.escape(expected)}|" + rf'"{re.escape(expected)}"|\'{re.escape(expected)}\')' + r"(?:[ \t]+#.*)?[ \t]*$" + ) + entries = [] + for line in text.splitlines(): + if not line.startswith("packagePath:"): + continue + entries.append(line) + if ( + "\x00" in text + or len(entries) != 1 + or exact_package_path.fullmatch(entries[0]) is None + ): + raise _fail() + + +def _read_source(source: Path, authority: str) -> tuple[bytes, dict[str, bytes]]: + source_fd = _open_directory(source) + try: + with os.scandir(source_fd) as iterator: + entries = { + entry.name: entry.stat(follow_symlinks=False) for entry in iterator + } + if set(entries) != {"runtime.yaml", "package"}: + raise _fail() + runtime_metadata = entries["runtime.yaml"] + package_metadata = entries["package"] + if not _safe_file(runtime_metadata, max_bytes=MAX_RUNTIME_BYTES): + raise _fail() + if not _safe_directory(package_metadata): + raise _fail() + runtime = _read_exact( + source_fd, + "runtime.yaml", + _snapshot(runtime_metadata), + max_bytes=MAX_RUNTIME_BYTES, + ) + _validate_runtime(runtime, authority) + package_fd = os.open( + "package", + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=source_fd, + ) + try: + if _snapshot(os.fstat(package_fd)) != _snapshot(package_metadata): + raise _fail() + package = _read_package(package_fd) + finally: + os.close(package_fd) + with os.scandir(source_fd) as iterator: + after = { + entry.name: _snapshot(entry.stat(follow_symlinks=False)) + for entry in iterator + } + if after != {name: _snapshot(metadata) for name, metadata in entries.items()}: + raise _fail() + return runtime, package + finally: + os.close(source_fd) + + +def _remove_tree(parent_fd: int, name: str) -> None: + descriptor = os.open( + name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + try: + os.fchmod(descriptor, 0o700) + with os.scandir(descriptor) as iterator: + entries = sorted(iterator, key=lambda entry: entry.name) + for entry in entries: + metadata = entry.stat(follow_symlinks=False) + if stat.S_ISDIR(metadata.st_mode): + if not _safe_directory(metadata): + raise _fail() + _remove_tree(descriptor, entry.name) + elif _safe_file(metadata, max_bytes=MAX_PACKAGE_FILE_BYTES): + os.unlink(entry.name, dir_fd=descriptor) + else: + raise _fail() + finally: + os.close(descriptor) + os.rmdir(name, dir_fd=parent_fd) + + +def _validate_owned_tree(directory_fd: int, uid: int, gid: int) -> None: + files, directories = _scan_tree(directory_fd) + for relative in directories: + descriptor = _open_relative(directory_fd, relative + "/.") + try: + metadata = os.fstat(descriptor) + finally: + os.close(descriptor) + if metadata.st_uid != uid or metadata.st_gid != gid: + raise _fail() + for relative in files: + descriptor = _open_relative(directory_fd, relative) + try: + metadata = os.fstat(descriptor) + finally: + os.close(descriptor) + if metadata.st_uid != uid or metadata.st_gid != gid: + raise _fail() + + +def _read_existing_package( + parent_fd: int, name: str, uid: int, gid: int +) -> dict[str, bytes]: + descriptor = os.open( + name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + try: + _validate_owned_tree(descriptor, uid, gid) + return _read_package(descriptor) + finally: + os.close(descriptor) + + +def _read_existing_runtime(parent_fd: int, authority: str, uid: int, gid: int) -> bytes: + metadata = os.stat("runtime.yaml", dir_fd=parent_fd, follow_symlinks=False) + if ( + metadata.st_uid != uid + or metadata.st_gid != gid + or not _safe_file(metadata, max_bytes=MAX_RUNTIME_BYTES) + ): + raise _fail() + runtime = _read_exact( + parent_fd, + "runtime.yaml", + _snapshot(metadata), + max_bytes=MAX_RUNTIME_BYTES, + ) + _validate_runtime(runtime, authority) + return runtime + + +def _make_directory(parent_fd: int, name: str, uid: int, gid: int) -> int: + os.mkdir(name, 0o700, dir_fd=parent_fd) + descriptor = os.open( + name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + os.fchown(descriptor, uid, gid) + return descriptor + + +def _write_file(parent_fd: int, name: str, payload: bytes, uid: int, gid: int) -> None: + descriptor = os.open( + name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=parent_fd, + ) + try: + remaining = memoryview(payload) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise _fail() + remaining = remaining[written:] + os.fchown(descriptor, uid, gid) + os.fchmod(descriptor, 0o400) + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _write_package( + parent_fd: int, payloads: dict[str, bytes], uid: int, gid: int +) -> None: + root_fd = _make_directory(parent_fd, STAGING_PACKAGE, uid, gid) + opened: dict[PurePosixPath, int] = {PurePosixPath(): root_fd} + try: + directories = sorted( + { + parent + for path in payloads + for parent in PurePosixPath(path).parents + if str(parent) != "." + }, + key=lambda path: (len(path.parts), str(path)), + ) + for relative in directories: + parent = relative.parent + opened[relative] = _make_directory(opened[parent], relative.name, uid, gid) + for relative_string, payload in sorted(payloads.items()): + relative = PurePosixPath(relative_string) + _write_file(opened[relative.parent], relative.name, payload, uid, gid) + for relative in sorted(opened, key=lambda path: len(path.parts), reverse=True): + os.fchmod(opened[relative], 0o500) + os.fsync(opened[relative]) + finally: + for descriptor in set(opened.values()): + os.close(descriptor) + + +def _recover_destination( + destination_fd: int, authority: str, uid: int, gid: int +) -> None: + with os.scandir(destination_fd) as iterator: + entries = {entry.name: entry.stat(follow_symlinks=False) for entry in iterator} + allowed = { + LOCK_NAME, + "runtime.yaml", + "package", + STAGING_PACKAGE, + STAGING_RUNTIME, + } + if not set(entries) <= allowed: + raise _fail() + for name, metadata in entries.items(): + if name in {"package", STAGING_PACKAGE}: + if not _safe_directory(metadata): + raise _fail() + elif not _safe_file( + metadata, + max_bytes=MAX_RUNTIME_BYTES if "runtime" in name else 0, + ): + raise _fail() + + if STAGING_PACKAGE in entries: + _read_existing_package(destination_fd, STAGING_PACKAGE, uid, gid) + _remove_tree(destination_fd, STAGING_PACKAGE) + if STAGING_RUNTIME in entries: + os.unlink(STAGING_RUNTIME, dir_fd=destination_fd) + with os.scandir(destination_fd) as iterator: + active = {entry.name for entry in iterator} + has_runtime = "runtime.yaml" in active + has_package = "package" in active + if has_runtime != has_package: + if has_runtime: + os.unlink("runtime.yaml", dir_fd=destination_fd) + if has_package: + _read_existing_package(destination_fd, "package", uid, gid) + _remove_tree(destination_fd, "package") + return + if has_runtime: + _read_existing_runtime(destination_fd, authority, uid, gid) + _read_existing_package(destination_fd, "package", uid, gid) + + +def _open_lock(destination_fd: int, uid: int, gid: int) -> int: + descriptor = os.open( + LOCK_NAME, + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + dir_fd=destination_fd, + ) + metadata = os.fstat(descriptor) + if not _safe_file(metadata, max_bytes=0): + os.close(descriptor) + raise _fail() + os.fchown(descriptor, uid, gid) + os.fchmod(descriptor, 0o600) + fcntl.flock(descriptor, fcntl.LOCK_EX) + return descriptor + + +def stage( + authority: str, + source: Path, + destination: Path, + *, + target_uid: int = TARGET_UID, + target_gid: int = TARGET_GID, +) -> None: + if authority not in AUTHORITIES or target_uid <= 0 or target_gid <= 0: + raise _fail() + runtime, package = _read_source(source, authority) + + destination_fd = _open_directory(destination) + lock_fd = -1 + try: + os.fchown(destination_fd, target_uid, target_gid) + os.fchmod(destination_fd, 0o700) + lock_fd = _open_lock(destination_fd, target_uid, target_gid) + _recover_destination(destination_fd, authority, target_uid, target_gid) + + with os.scandir(destination_fd) as iterator: + active = {entry.name for entry in iterator} + if "runtime.yaml" in active: + existing_runtime = _read_existing_runtime( + destination_fd, authority, target_uid, target_gid + ) + existing_package = _read_existing_package( + destination_fd, "package", target_uid, target_gid + ) + if existing_runtime != runtime or existing_package != package: + raise _fail() + return + + _write_package(destination_fd, package, target_uid, target_gid) + _write_file( + destination_fd, + STAGING_RUNTIME, + runtime, + target_uid, + target_gid, + ) + os.rename( + STAGING_PACKAGE, + "package", + src_dir_fd=destination_fd, + dst_dir_fd=destination_fd, + ) + os.replace( + STAGING_RUNTIME, + "runtime.yaml", + src_dir_fd=destination_fd, + dst_dir_fd=destination_fd, + ) + os.fsync(destination_fd) + _read_existing_runtime(destination_fd, authority, target_uid, target_gid) + _read_existing_package(destination_fd, "package", target_uid, target_gid) + except StagingError: + raise + except OSError: + raise _fail() from None + finally: + if lock_fd >= 0: + os.close(lock_fd) + os.close(destination_fd) + + +def main(argv: list[str] | None = None) -> int: + parser = QuietArgumentParser(add_help=False) + parser.add_argument("--authority", required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--destination", type=Path, required=True) + parser.add_argument("command", choices=("stage",)) + try: + arguments = parser.parse_args(argv) + stage( + arguments.authority, + arguments.source, + arguments.destination, + ) + except (StagingError, OSError): + print(FAILURE_MESSAGE, file=sys.stderr) + return 1 + print(SUCCESS_MESSAGE) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/local-relay-source-publisher.py b/scripts/local-relay-source-publisher.py new file mode 100644 index 0000000..ddc3974 --- /dev/null +++ b/scripts/local-relay-source-publisher.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Publish authority-isolated Relay SQLite sources into local named volumes. + +The command deliberately reports only a generic operation result. Source rows, +selectors, backup contents, and database diagnostics never cross its output +boundary. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sqlite3 +import stat +import sys +import tempfile +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +AUTHORITIES = ("cra", "nia", "mosd", "sipf", "nagdi") +EXPECTED_OBJECTS = { + "cra": { + ("table", "civil_person_source"), + ("view", "relay_civil_person"), + }, + "nia": { + ("table", "population_person_source"), + ("view", "relay_population_person"), + }, + "mosd": { + ("table", "beneficiary_enrolment_source"), + ("view", "relay_beneficiary_enrolment"), + }, + "sipf": { + ("table", "pension_case_source"), + ("table", "survivor_case_source"), + ("view", "relay_pension_payment"), + ("view", "relay_survivor_case"), + }, + "nagdi": { + ("table", "farmer_voucher_source"), + ("table", "livestock_movement_source"), + ("view", "relay_farmer_voucher"), + ("view", "relay_livestock_movement"), + }, +} +MOSD_CONTROL_SELECTOR = "2300010248" +MOSD_TABLE = "beneficiary_enrolment_source" +MOSD_COLUMNS = ( + "record_id", + "record_revision", + "lifecycle_state", + "recorded_at", + "uin", + "duplicate_flag", +) +BACKUP_NAME = ".lifecycle-proof-backup.json" +BACKUP_VERSION = 1 +SUCCESS_MESSAGE = "publisher operation completed" +FAILURE_MESSAGE = "publisher operation failed" + + +class PublisherError(RuntimeError): + """A deliberately value-free publisher failure.""" + + +class QuietArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + del message + raise PublisherError(FAILURE_MESSAGE) + + +def _fail() -> PublisherError: + return PublisherError(FAILURE_MESSAGE) + + +def _expected_filename(authority: str) -> str: + return f"{authority}.sqlite" + + +def _validate_paths(authority: str, database: Path, seed: Path) -> None: + if authority not in EXPECTED_OBJECTS: + raise _fail() + expected = _expected_filename(authority) + if database.name != expected or seed.name != expected: + raise _fail() + if database.resolve(strict=False) == seed.resolve(strict=False): + raise _fail() + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + try: + return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) + except (OSError, sqlite3.Error): + raise _fail() from None + + +def _structural_schema(path: Path, authority: str) -> tuple[Any, ...]: + try: + if not path.is_file() or path.is_symlink(): + raise _fail() + with _connect_read_only(path) as connection: + integrity = connection.execute("PRAGMA quick_check").fetchall() + if integrity != [("ok",)]: + raise _fail() + objects = connection.execute( + """ + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + """ + ).fetchall() + object_names = {(row[0], row[1]) for row in objects} + if object_names != EXPECTED_OBJECTS[authority]: + raise _fail() + user_version = connection.execute("PRAGMA user_version").fetchone() + application_id = connection.execute("PRAGMA application_id").fetchone() + encoding = connection.execute("PRAGMA encoding").fetchone() + return (tuple(objects), user_version, application_id, encoding) + except PublisherError: + raise + except (OSError, sqlite3.Error): + raise _fail() from None + + +def _validate_schema_match(authority: str, database: Path, seed: Path) -> None: + seed_schema = _structural_schema(seed, authority) + database_schema = _structural_schema(database, authority) + if database_schema != seed_schema: + raise _fail() + + +def _validate_database(authority: str, database: Path, seed: Path) -> None: + _validate_paths(authority, database, seed) + _validate_schema_match(authority, database, seed) + + +def _fsync_directory(directory: Path) -> None: + descriptor = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def ensure_seeded(authority: str, database: Path, seed: Path) -> None: + database = database.absolute() + seed = seed.absolute() + _validate_paths(authority, database, seed) + _structural_schema(seed, authority) + try: + database.parent.mkdir(parents=True, exist_ok=True) + if os.path.lexists(database): + _validate_database(authority, database, seed) + return + + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{database.name}.", suffix=".tmp", dir=database.parent + ) + temporary = Path(temporary_name) + with os.fdopen(descriptor, "wb") as output, seed.open("rb") as source: + shutil.copyfileobj(source, output) + output.flush() + os.fsync(output.fileno()) + # The publisher remains the sole writer. Relay mounts the authority + # volume read-only and may run under a different unprivileged UID. + temporary.chmod( + stat.S_IRUSR + | stat.S_IWUSR + | stat.S_IRGRP + | stat.S_IROTH + ) + _validate_schema_match(authority, temporary, seed) + try: + os.link(temporary, database) + except FileExistsError: + _validate_database(authority, database, seed) + _fsync_directory(database.parent) + _validate_database(authority, database, seed) + except PublisherError: + raise + except (OSError, sqlite3.Error): + raise _fail() from None + finally: + if "temporary" in locals(): + temporary.unlink(missing_ok=True) + + +def _backup_path(database: Path) -> Path: + return database.parent / BACKUP_NAME + + +def _json_bytes(value: dict[str, Any]) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _backup_envelope(database: Path, row: tuple[Any, ...]) -> dict[str, Any]: + identity = database.stat() + payload: dict[str, Any] = { + "version": BACKUP_VERSION, + "authority": "mosd", + "database_device": identity.st_dev, + "database_inode": identity.st_ino, + "row": list(row), + } + return { + "payload": payload, + "sha256": hashlib.sha256(_json_bytes(payload)).hexdigest(), + } + + +def _read_backup(database: Path) -> dict[str, Any]: + path = _backup_path(database) + try: + if not path.is_file() or path.is_symlink(): + raise _fail() + if stat.S_IMODE(path.stat().st_mode) != 0o600: + raise _fail() + envelope = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(envelope, dict) or set(envelope) != {"payload", "sha256"}: + raise _fail() + payload = envelope["payload"] + if not isinstance(payload, dict) or set(payload) != { + "version", + "authority", + "database_device", + "database_inode", + "row", + }: + raise _fail() + if hashlib.sha256(_json_bytes(payload)).hexdigest() != envelope["sha256"]: + raise _fail() + identity = database.stat() + if ( + payload["version"] != BACKUP_VERSION + or payload["authority"] != "mosd" + or payload["database_device"] != identity.st_dev + or payload["database_inode"] != identity.st_ino + or not isinstance(payload["row"], list) + or len(payload["row"]) != len(MOSD_COLUMNS) + or payload["row"][4] != MOSD_CONTROL_SELECTOR + ): + raise _fail() + return payload + except PublisherError: + raise + except (OSError, ValueError, TypeError, json.JSONDecodeError): + raise _fail() from None + + +def _write_backup(database: Path, envelope: dict[str, Any]) -> None: + path = _backup_path(database) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{BACKUP_NAME}.", suffix=".tmp", dir=database.parent + ) + temporary = Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as output: + output.write(_json_bytes(envelope)) + output.flush() + os.fsync(output.fileno()) + try: + os.link(temporary, path) + except FileExistsError: + _read_backup(database) + _fsync_directory(database.parent) + except PublisherError: + raise + except OSError: + raise _fail() from None + finally: + temporary.unlink(missing_ok=True) + + +def _validate_mosd(authority: str, database: Path, seed: Path) -> None: + if authority != "mosd": + raise _fail() + _validate_database(authority, database, seed) + + +def _open_mutable(database: Path) -> sqlite3.Connection: + try: + connection = sqlite3.connect(database) + connection.execute("PRAGMA journal_mode = DELETE") + connection.execute("PRAGMA synchronous = FULL") + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA secure_delete = ON") + return connection + except sqlite3.Error: + raise _fail() from None + + +def _control_row(connection: sqlite3.Connection) -> tuple[Any, ...]: + try: + row = connection.execute( + f"SELECT {', '.join(MOSD_COLUMNS)} FROM {MOSD_TABLE} WHERE uin = ?", + (MOSD_CONTROL_SELECTOR,), + ).fetchone() + if row is None: + raise _fail() + return tuple(row) + except PublisherError: + raise + except sqlite3.Error: + raise _fail() from None + + +def begin_proof(authority: str, database: Path, seed: Path) -> None: + database = database.absolute() + seed = seed.absolute() + _validate_mosd(authority, database, seed) + backup = _backup_path(database) + if os.path.lexists(backup): + _read_backup(database) + return + try: + with _open_mutable(database) as connection: + row = _control_row(connection) + _write_backup(database, _backup_envelope(database, row)) + except PublisherError: + raise + except (OSError, sqlite3.Error): + raise _fail() from None + + +def _revision(row: tuple[Any, ...], recorded_at: str) -> str: + value = { + "record_id": str(row[0]), + "lifecycle_state": str(row[2]), + "recorded_at": recorded_at, + "uin": MOSD_CONTROL_SELECTOR, + "duplicate_flag": 1, + } + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return f"rev-{hashlib.sha256(encoded).hexdigest()[:16]}" + + +def _assert_same_inode(database: Path, before: os.stat_result) -> None: + after = database.stat() + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise _fail() + + +def set_proof_state(authority: str, database: Path, seed: Path) -> None: + database = database.absolute() + seed = seed.absolute() + _validate_mosd(authority, database, seed) + payload = _read_backup(database) + before = database.stat() + try: + with _open_mutable(database) as connection: + connection.execute("BEGIN IMMEDIATE") + row = _control_row(connection) + if row[0] != payload["row"][0] or row[4] != payload["row"][4]: + raise _fail() + if row[5] != 1: + recorded_at = ( + datetime.now(UTC) + .isoformat(timespec="microseconds") + .replace("+00:00", "Z") + ) + cursor = connection.execute( + f""" + UPDATE {MOSD_TABLE} + SET duplicate_flag = ?, record_revision = ?, recorded_at = ? + WHERE uin = ? + """, + (1, _revision(row, recorded_at), recorded_at, MOSD_CONTROL_SELECTOR), + ) + if cursor.rowcount != 1: + raise _fail() + connection.commit() + _assert_same_inode(database, before) + except PublisherError: + raise + except (OSError, sqlite3.Error): + raise _fail() from None + + +def restore_proof(authority: str, database: Path, seed: Path) -> None: + database = database.absolute() + seed = seed.absolute() + _validate_mosd(authority, database, seed) + backup = _backup_path(database) + if not os.path.lexists(backup): + return + payload = _read_backup(database) + before = database.stat() + row = payload["row"] + try: + with _open_mutable(database) as connection: + connection.execute("BEGIN IMMEDIATE") + current = _control_row(connection) + if current[0] != row[0] or current[4] != row[4]: + raise _fail() + assignments = ", ".join(f"{column} = ?" for column in MOSD_COLUMNS) + cursor = connection.execute( + f"UPDATE {MOSD_TABLE} SET {assignments} WHERE uin = ?", + (*row, MOSD_CONTROL_SELECTOR), + ) + if cursor.rowcount != 1: + raise _fail() + connection.commit() + _assert_same_inode(database, before) + backup.unlink() + _fsync_directory(database.parent) + except PublisherError: + raise + except (OSError, sqlite3.Error): + raise _fail() from None + + +def _parser() -> argparse.ArgumentParser: + parser = QuietArgumentParser(description="Manage a local Relay source") + parser.add_argument("--authority", choices=AUTHORITIES, required=True) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--seed", type=Path, required=True) + commands = parser.add_subparsers(dest="command", required=True) + for command in ("ensure-seeded", "begin-proof", "set-proof-state", "restore-proof"): + commands.add_parser(command) + return parser + + +def run(argv: list[str] | None = None) -> None: + args = _parser().parse_args(argv) + operations = { + "ensure-seeded": ensure_seeded, + "begin-proof": begin_proof, + "set-proof-state": set_proof_state, + "restore-proof": restore_proof, + } + operations[args.command](args.authority, args.database, args.seed) + + +def main() -> None: + try: + run() + except (PublisherError, OSError, sqlite3.Error): + print(FAILURE_MESSAGE, file=sys.stderr) + raise SystemExit(1) from None + print(SUCCESS_MESSAGE) + + +if __name__ == "__main__": + main() diff --git a/scripts/local-transit-proxy.py b/scripts/local-transit-proxy.py new file mode 100755 index 0000000..a0f6d16 --- /dev/null +++ b/scripts/local-transit-proxy.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Serve one local ES256 key through a narrow Transit-compatible Unix socket. + +The proxy is deliberately one process, one key, and one socket. Registry +processes receive only the socket. The operator-owned private JWK stays outside +their containers and is never rendered in responses or diagnostics. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import json +import os +import re +import signal +import socket +import socketserver +import stat +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, utils + +MAX_KEY_BYTES = 16 * 1024 +MAX_REQUEST_LINE_BYTES = 2 * 1024 +MAX_HEADER_BYTES = 8 * 1024 +MAX_HEADER_LINE_BYTES = 2 * 1024 +MAX_REQUEST_BODY_BYTES = 2 * 1024 +MAX_RESPONSE_BODY_BYTES = 64 * 1024 +SOCKET_TIMEOUT_SECONDS = 3 +ERROR_DOCUMENT = b'{"errors":["request refused"]}' +KEY_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +HEADER_NAME = re.compile(rb"[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z") +JWK_MEMBERS = {"alg", "crv", "d", "kid", "kty", "x", "y"} + + +class ProxyError(Exception): + """Value-free configuration or request refusal.""" + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProxyError("invalid document") + result[key] = value + return result + + +def _decode_base64url(value: Any) -> bytes: + if not isinstance(value, str) or not value or "=" in value: + raise ProxyError("invalid key") + try: + decoded = base64.b64decode( + value + "=" * (-len(value) % 4), altchars=b"-_", validate=True + ) + except (binascii.Error, ValueError) as error: + raise ProxyError("invalid key") from error + if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != value: + raise ProxyError("invalid key") + return decoded + + +def _read_private_jwk( + path: Path, + *, + allow_root_bind_owner: bool = False, + consume_private_jwk: bool = False, +) -> tuple[ec.EllipticCurvePrivateKey, str]: + if not path.is_absolute(): + raise ProxyError("invalid key") + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + except OSError as error: + raise ProxyError("invalid key") from error + chunks = bytearray() + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or ( + metadata.st_uid != os.geteuid() + and not (allow_root_bind_owner and os.geteuid() == 0) + ) + or metadata.st_mode & 0o077 + or metadata.st_size <= 0 + or metadata.st_size > MAX_KEY_BYTES + ): + raise ProxyError("invalid key") + while len(chunks) <= MAX_KEY_BYTES: + chunk = os.read(descriptor, min(4096, MAX_KEY_BYTES + 1 - len(chunks))) + if not chunk: + break + chunks.extend(chunk) + if len(chunks) > MAX_KEY_BYTES: + raise ProxyError("invalid key") + try: + document = json.loads(chunks, object_pairs_hook=_strict_object) + except (json.JSONDecodeError, UnicodeDecodeError, ProxyError) as error: + raise ProxyError("invalid key") from error + if not isinstance(document, dict) or set(document) != JWK_MEMBERS: + raise ProxyError("invalid key") + if ( + document["kty"] != "EC" + or document["crv"] != "P-256" + or document["alg"] != "ES256" + or not isinstance(document["kid"], str) + or not document["kid"].strip() + or len(document["kid"]) > 256 + or any( + ord(character) < 0x20 or ord(character) == 0x7F + for character in document["kid"] + ) + ): + raise ProxyError("invalid key") + + x_bytes = _decode_base64url(document["x"]) + y_bytes = _decode_base64url(document["y"]) + scalar_bytes = _decode_base64url(document["d"]) + if len(x_bytes) != 32 or len(y_bytes) != 32 or len(scalar_bytes) != 32: + raise ProxyError("invalid key") + try: + private_key = ec.derive_private_key( + int.from_bytes(scalar_bytes, "big"), ec.SECP256R1() + ) + except ValueError as error: + raise ProxyError("invalid key") from error + public = private_key.public_key().public_numbers() + if ( + public.x.to_bytes(32, "big") != x_bytes + or public.y.to_bytes(32, "big") != y_bytes + ): + raise ProxyError("invalid key") + public_pem = ( + private_key.public_key() + .public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("ascii") + ) + if consume_private_jwk: + _consume_staged_private_jwk(path, metadata) + return private_key, public_pem + finally: + chunks[:] = b"\0" * len(chunks) + os.close(descriptor) + + +def _consume_staged_private_jwk(path: Path, opened: os.stat_result) -> None: + parent = path.parent + staging_root = Path("/tmp").resolve() + if ( + parent.parent not in {Path("/tmp"), staging_root} + or not parent.name.startswith("solmara-transit-") + or path.name != "signing.jwk" + ): + raise ProxyError("invalid key") + + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + directory = -1 + staging_root_descriptor = -1 + try: + directory = os.open(parent, flags) + parent_metadata = os.fstat(directory) + if ( + not stat.S_ISDIR(parent_metadata.st_mode) + or parent_metadata.st_uid != os.geteuid() + or stat.S_IMODE(parent_metadata.st_mode) != 0o700 + or os.listdir(directory) != [path.name] + ): + raise ProxyError("invalid key") + current = os.stat(path.name, dir_fd=directory, follow_symlinks=False) + + def identity(metadata: os.stat_result) -> tuple[int, int, int, int, int, int]: + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_uid, + metadata.st_mode, + metadata.st_size, + metadata.st_nlink, + ) + + if identity(current) != identity(opened): + raise ProxyError("invalid key") + os.unlink(path.name, dir_fd=directory) + if os.listdir(directory): + raise ProxyError("invalid key") + + staging_root_descriptor = os.open(staging_root, flags) + current_parent = os.stat( + parent.name, dir_fd=staging_root_descriptor, follow_symlinks=False + ) + if identity(current_parent)[:4] != identity(parent_metadata)[:4]: + raise ProxyError("invalid key") + os.rmdir(parent.name, dir_fd=staging_root_descriptor) + except OSError as error: + raise ProxyError("invalid key") from error + finally: + if staging_root_descriptor >= 0: + os.close(staging_root_descriptor) + if directory >= 0: + os.close(directory) + + +def _validate_socket_path(path: Path) -> None: + if not path.is_absolute() or path.exists() or path.is_symlink(): + raise ProxyError("invalid socket") + try: + parent = path.parent.stat() + except OSError as error: + raise ProxyError("invalid socket") from error + if ( + not stat.S_ISDIR(parent.st_mode) + or parent.st_uid != os.geteuid() + or parent.st_mode & 0o022 + ): + raise ProxyError("invalid socket") + + +class TransitApplication: + def __init__( + self, + private_jwk: Path, + key_name: str, + *, + allow_root_bind_owner: bool = False, + consume_private_jwk: bool = False, + ) -> None: + if not KEY_NAME.fullmatch(key_name): + raise ProxyError("invalid key") + self._private_key, public_pem = _read_private_jwk( + private_jwk, + allow_root_bind_owner=allow_root_bind_owner, + consume_private_jwk=consume_private_jwk, + ) + self._metadata = json.dumps( + { + "data": { + "allow_plaintext_backup": False, + "deletion_allowed": False, + "derived": False, + "exportable": False, + "imported": True, + "keys": {"1": {"public_key": public_pem}}, + "latest_version": 1, + "min_decryption_version": 1, + "min_encryption_version": 1, + "name": key_name, + "supports_decryption": False, + "supports_derivation": False, + "supports_encryption": False, + "supports_signing": True, + "type": "ecdsa-p256", + } + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + self.metadata_path = f"/v1/transit/keys/{key_name}" + self.sign_path = f"/v1/transit/sign/{key_name}/sha2-256" + + def dispatch( + self, method: str, path: str, headers: dict[str, str], body: bytes + ) -> tuple[int, bytes]: + if headers.get("x-vault-request") != "true": + return 403, ERROR_DOCUMENT + if method == "GET" and path == self.metadata_path: + if body: + return 400, ERROR_DOCUMENT + return 200, self._metadata + if method == "POST" and path == self.sign_path: + if headers.get("content-type") != "application/json": + return 400, ERROR_DOCUMENT + try: + document = json.loads(body, object_pairs_hook=_strict_object) + except (json.JSONDecodeError, UnicodeDecodeError, ProxyError): + return 400, ERROR_DOCUMENT + if ( + not isinstance(document, dict) + or set(document) + != { + "input", + "key_version", + "marshaling_algorithm", + "prehashed", + } + or type(document["key_version"]) is not int + or document["key_version"] != 1 + or document["marshaling_algorithm"] != "jws" + or document["prehashed"] is not True + or not isinstance(document["input"], str) + ): + return 400, ERROR_DOCUMENT + try: + digest = base64.b64decode(document["input"], validate=True) + except (binascii.Error, ValueError): + return 400, ERROR_DOCUMENT + if ( + len(digest) != 32 + or base64.b64encode(digest).decode("ascii") != document["input"] + ): + return 400, ERROR_DOCUMENT + der_signature = self._private_key.sign( + digest, ec.ECDSA(utils.Prehashed(hashes.SHA256())) + ) + r_value, s_value = utils.decode_dss_signature(der_signature) + raw_signature = r_value.to_bytes(32, "big") + s_value.to_bytes(32, "big") + encoded = ( + base64.urlsafe_b64encode(raw_signature).rstrip(b"=").decode("ascii") + ) + response = json.dumps( + {"data": {"signature": f"vault:v1:{encoded}"}}, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return 200, response + return 404, ERROR_DOCUMENT + + +class TransitRequestHandler(socketserver.StreamRequestHandler): + server: "TransitServer" + + def handle(self) -> None: + self.connection.settimeout(SOCKET_TIMEOUT_SECONDS) + try: + method, path, headers, body = self._read_request() + status, response = self.server.application.dispatch( + method, path, headers, body + ) + except ProxyError: + status, response = 400, ERROR_DOCUMENT + except (OSError, TimeoutError): + return + except Exception: + status, response = 500, ERROR_DOCUMENT + try: + self._write_response(status, response) + except OSError: + pass + + def _read_request(self) -> tuple[str, str, dict[str, str], bytes]: + request_line = self.rfile.readline(MAX_REQUEST_LINE_BYTES + 1) + if ( + not request_line.endswith(b"\r\n") + or len(request_line) > MAX_REQUEST_LINE_BYTES + ): + raise ProxyError("invalid request") + try: + method, path, version = request_line[:-2].decode("ascii").split(" ") + except (UnicodeDecodeError, ValueError) as error: + raise ProxyError("invalid request") from error + if ( + method not in {"GET", "POST"} + or version != "HTTP/1.1" + or not path.startswith("/") + ): + raise ProxyError("invalid request") + + headers: dict[str, str] = {} + total_header_bytes = 0 + while True: + line = self.rfile.readline(MAX_HEADER_LINE_BYTES + 1) + total_header_bytes += len(line) + if ( + not line + or len(line) > MAX_HEADER_LINE_BYTES + or total_header_bytes > MAX_HEADER_BYTES + or not line.endswith(b"\r\n") + ): + raise ProxyError("invalid request") + if line == b"\r\n": + break + if line[:1] in {b" ", b"\t"} or b":" not in line: + raise ProxyError("invalid request") + name, value = line[:-2].split(b":", 1) + if not HEADER_NAME.fullmatch(name): + raise ProxyError("invalid request") + normalized = name.decode("ascii").lower() + if normalized in headers: + raise ProxyError("invalid request") + try: + decoded = value.strip(b" \t").decode("ascii") + except UnicodeDecodeError as error: + raise ProxyError("invalid request") from error + if any( + ord(character) < 0x20 or ord(character) == 0x7F for character in decoded + ): + raise ProxyError("invalid request") + headers[normalized] = decoded + + if "transfer-encoding" in headers: + raise ProxyError("invalid request") + length_text = headers.get("content-length") + if length_text is None: + content_length = 0 + elif not length_text.isascii() or not length_text.isdecimal(): + raise ProxyError("invalid request") + else: + content_length = int(length_text) + if content_length > MAX_REQUEST_BODY_BYTES: + raise ProxyError("invalid request") + body = self.rfile.read(content_length) + if len(body) != content_length: + raise ProxyError("invalid request") + return method, path, headers, body + + def _write_response(self, status: int, body: bytes) -> None: + if len(body) > MAX_RESPONSE_BODY_BYTES: + status, body = 500, ERROR_DOCUMENT + reason = { + 200: "OK", + 400: "Bad Request", + 403: "Forbidden", + 404: "Not Found", + 500: "Internal Server Error", + }[status] + head = ( + f"HTTP/1.1 {status} {reason}\r\n" + "Content-Type: application/json\r\n" + "Cache-Control: no-store\r\n" + f"Content-Length: {len(body)}\r\n" + "Connection: close\r\n\r\n" + ).encode("ascii") + self.wfile.write(head + body) + + +class TransitServer(socketserver.UnixStreamServer): + allow_reuse_address = False + + def __init__(self, socket_path: Path, application: TransitApplication) -> None: + _validate_socket_path(socket_path) + self.application = application + self._socket_path = socket_path + self._socket_identity: tuple[int, int] | None = None + previous_umask = os.umask(0o077) + try: + super().__init__(str(socket_path), TransitRequestHandler) + socket_path.chmod(0o600) + metadata = socket_path.stat() + if ( + not stat.S_ISSOCK(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + ): + raise ProxyError("invalid socket") + self._socket_identity = (metadata.st_dev, metadata.st_ino) + except Exception: + try: + socket_path.unlink() + except OSError: + pass + raise + finally: + os.umask(previous_umask) + + def handle_error(self, request: socket.socket, client_address: object) -> None: + # Deliberately suppress value-bearing exception diagnostics. + del request, client_address + + def server_close(self) -> None: + super().server_close() + try: + metadata = self._socket_path.lstat() + except FileNotFoundError: + return + identity = (metadata.st_dev, metadata.st_ino) + if stat.S_ISSOCK(metadata.st_mode) and identity == self._socket_identity: + self._socket_path.unlink() + + +def build_server( + private_jwk: Path, + socket_path: Path, + key_name: str, + *, + allow_root_bind_owner: bool = False, + consume_private_jwk: bool = False, +) -> TransitServer: + return TransitServer( + socket_path, + TransitApplication( + private_jwk, + key_name, + allow_root_bind_owner=allow_root_bind_owner, + consume_private_jwk=consume_private_jwk, + ), + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Expose one operator-owned ES256 JWK on one local Transit Unix socket." + ) + parser.add_argument("--private-jwk", required=True, type=Path) + parser.add_argument("--socket", required=True, type=Path) + parser.add_argument("--key-name", required=True) + parser.add_argument("--allow-root-bind-owner", action="store_true") + parser.add_argument("--consume-private-jwk", action="store_true") + arguments = parser.parse_args() + try: + server = build_server( + arguments.private_jwk, + arguments.socket, + arguments.key_name, + allow_root_bind_owner=arguments.allow_root_bind_owner, + consume_private_jwk=arguments.consume_private_jwk, + ) + except (OSError, ProxyError, ValueError): + print("local Transit proxy could not start", file=os.sys.stderr) + return 1 + + def stop(_signum: int, _frame: object) -> None: + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + try: + server.serve_forever(poll_interval=0.1) + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/local-transit-signers.py b/scripts/local-transit-signers.py new file mode 100644 index 0000000..c45e765 --- /dev/null +++ b/scripts/local-transit-signers.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Start and stop the seven PID-tracked local Transit signer proxies.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import signal +import socket +import stat +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROVIDERS = ("mint", "cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") +STATE = ROOT / "runtime/local-transit-signers" +PROXY = ROOT / "scripts/local-transit-proxy.py" +ALIAS_ROOT = Path("/tmp") / f"solmara-transit-{hashlib.sha256(str(ROOT).encode()).hexdigest()[:12]}" + + +def key_name(provider: str) -> str: + return "solmara-mint" if provider == "mint" else f"solmara-evidence-{provider}" + + +def paths(provider: str) -> tuple[Path, Path, Path]: + base = ROOT / "config/evidence/local/cells" / provider + return base / "secrets/signing.jwk", base / "transit/transit-proxy.sock", STATE / f"{provider}.pid" + + +def bind_path(provider: str, socket_path: Path) -> Path: + ALIAS_ROOT.mkdir(mode=0o700, exist_ok=True) + ALIAS_ROOT.chmod(0o700) + alias = ALIAS_ROOT / provider + expected = socket_path.parent.resolve() + if alias.is_symlink(): + if alias.resolve() != expected: + raise OSError("unexpected Transit alias") + elif alias.exists(): + raise OSError("unexpected Transit alias") + else: + alias.symlink_to(expected, target_is_directory=True) + return alias / socket_path.name + + +def owned_process(pid_file: Path) -> int | None: + try: + value = pid_file.read_text(encoding="ascii").strip() + pid = int(value) + os.kill(pid, 0) + except (FileNotFoundError, ProcessLookupError, PermissionError, ValueError): + return None + command = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ).stdout + if str(PROXY) not in command: + return None + return pid + + +def wait_for_socket(path: Path, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if stat.S_ISSOCK(path.stat().st_mode): + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(0.25) + client.connect(str(path)) + return True + except (FileNotFoundError, OSError): + pass + time.sleep(0.05) + return False + + +def start() -> int: + STATE.mkdir(parents=True, exist_ok=True, mode=0o700) + STATE.chmod(0o700) + started: list[str] = [] + for provider in PROVIDERS: + private_jwk, socket_path, pid_file = paths(provider) + existing = owned_process(pid_file) + socket_bind_path = bind_path(provider, socket_path) + if existing is not None and wait_for_socket(socket_bind_path, timeout=0.1): + continue + pid_file.unlink(missing_ok=True) + if socket_path.exists() or socket_path.is_symlink(): + print(f"{provider}: refusing an unowned Transit socket", file=sys.stderr) + stop(started) + return 1 + socket_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + socket_path.parent.chmod(0o700) + if not private_jwk.is_file(): + print(f"{provider}: signing key is missing; run just generate", file=sys.stderr) + stop(started) + return 1 + process = subprocess.Popen( + [sys.executable, str(PROXY), "--private-jwk", str(private_jwk), "--socket", str(socket_bind_path), "--key-name", key_name(provider)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + pid_file.write_text(f"{process.pid}\n", encoding="ascii") + pid_file.chmod(0o600) + started.append(provider) + if not wait_for_socket(socket_bind_path): + print(f"{provider}: Transit proxy did not become ready", file=sys.stderr) + stop(started) + return 1 + return 0 + + +def stop(providers: list[str] | tuple[str, ...] = PROVIDERS) -> int: + for provider in reversed(providers): + _, socket_path, pid_file = paths(provider) + pid = owned_process(pid_file) + if pid is None: + pid_file.unlink(missing_ok=True) + continue + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + pid_file.unlink(missing_ok=True) + if socket_path.exists() and not stat.S_ISSOCK(socket_path.lstat().st_mode): + print(f"{provider}: signer stopped but socket path was replaced", file=sys.stderr) + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("up", "down")) + args = parser.parse_args() + return start() if args.action == "up" else stop() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/notary_state_restart.py b/scripts/notary_state_restart.py deleted file mode 100644 index 186c477..0000000 --- a/scripts/notary_state_restart.py +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/env python3 -"""Prove that local Notary PostgreSQL correctness state survives replacement. - -This is deliberately a Solmara-local PostgreSQL 16 gate. It exercises the -operator-facing ``just down`` / ``just up`` path, verifies that PostgreSQL uses -the checkout's named data volume, and compares every Notary correctness-state -table before any post-restart doctor or scenario request can create new state. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import time -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import Mapping, Sequence - - -ROOT = Path(__file__).resolve().parents[1] -EXPECTED_POSTGRES_MAJOR = 16 -EXPECTED_PGDATA = PurePosixPath("/var/lib/postgresql/data") -DEFAULT_WAIT_SECONDS = 300.0 - -AUTHORITIES = ( - ("cra", "cra-notary"), - ("nia", "nia-notary"), - ("sro", "sro-notary"), - ("programme", "programme-notary"), - ("sipf", "sipf-notary"), - ("nagdi", "nagdi-notary"), -) - -CORRECTNESS_TABLES = ( - "replay_identifier", - "consumable_nonce", - "evaluation", - "batch_idempotency", - "credential_status", - "machine_quota", - "subject_access_quota", - "preauthorization_login_state", - "preauthorization_tx_code", -) - - -class GateError(RuntimeError): - """The restart-persistence proof could not be completed safely.""" - - -class CommandError(GateError): - """One bounded external command failed.""" - - -class CommandRunner: - """Injectable command and clock boundary used by the production gate.""" - - def run( - self, - command: Sequence[str], - *, - cwd: Path, - env: Mapping[str, str], - ) -> str: - completed = subprocess.run( - list(command), - cwd=cwd, - env=dict(env), - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if completed.returncode != 0: - detail = (completed.stderr or completed.stdout).strip() - if len(detail) > 4_000: - detail = detail[-4_000:] - rendered = " ".join(command) - suffix = f": {detail}" if detail else "" - raise CommandError( - f"command failed with exit {completed.returncode}: {rendered}{suffix}" - ) - return completed.stdout - - def monotonic(self) -> float: - return time.monotonic() - - def sleep(self, seconds: float) -> None: - time.sleep(seconds) - - -@dataclass(frozen=True) -class StateSnapshot: - container_id: str - system_identifier: str - server_version_num: int - data_directory: str - volume_name: str - counts: tuple[tuple[str, str, int], ...] - - def totals(self) -> dict[str, int]: - totals = {authority: 0 for authority, _service in AUTHORITIES} - for authority, _table, count in self.counts: - totals[authority] += count - return totals - - -def _is_postgres_storage_path(path: PurePosixPath) -> bool: - """Return true when a mount can cover, replace, or subdivide PGDATA.""" - - return ( - path == EXPECTED_PGDATA - or path in EXPECTED_PGDATA.parents - or EXPECTED_PGDATA in path.parents - ) - - -def validate_compose_pgdata_layout(config: object) -> str: - """Return the resolved named volume after validating the PG16 layout.""" - - if not isinstance(config, dict): - raise GateError("Compose configuration is not an object") - services = config.get("services") - volumes = config.get("volumes") - if not isinstance(services, dict) or not isinstance(volumes, dict): - raise GateError("Compose configuration is missing services or volumes") - postgres = services.get("postgres") - declared = volumes.get("postgres-data") - if not isinstance(postgres, dict) or not isinstance(declared, dict): - raise GateError("Compose must declare postgres and postgres-data") - resolved_name = declared.get("name") - if not isinstance(resolved_name, str) or not resolved_name: - raise GateError("Compose did not resolve the postgres-data volume name") - - configured = postgres.get("volumes") - if not isinstance(configured, list): - raise GateError("PostgreSQL has no configured volumes") - postgres_storage = [] - for mount in configured: - if not isinstance(mount, dict): - raise GateError("PostgreSQL volume configuration is not normalized") - target = mount.get("target") - if isinstance(target, str) and _is_postgres_storage_path(PurePosixPath(target)): - postgres_storage.append(mount) - expected = [ - mount - for mount in postgres_storage - if mount.get("type") == "volume" - and mount.get("source") == "postgres-data" - and mount.get("target") == str(EXPECTED_PGDATA) - and not mount.get("read_only", False) - ] - if len(postgres_storage) != 1 or len(expected) != 1: - raise GateError( - "PostgreSQL 16 must mount only named postgres-data directly and " - "read-write at /var/lib/postgresql/data" - ) - return resolved_name - - -def validate_runtime_pgdata_mounts( - mounts: object, - *, - expected_volume: str, - volume_labels: object, - compose_project_name: str, -) -> None: - """Reject anonymous, parent, nested, bind, and read-only PGDATA mounts.""" - - if not isinstance(mounts, list): - raise GateError("Docker did not return PostgreSQL mounts") - storage_mounts = [] - for mount in mounts: - if not isinstance(mount, dict): - raise GateError("Docker returned a malformed PostgreSQL mount") - destination = mount.get("Destination") - if isinstance(destination, str) and _is_postgres_storage_path( - PurePosixPath(destination) - ): - storage_mounts.append(mount) - if len(storage_mounts) != 1: - raise GateError( - "PostgreSQL has an anonymous, parent, or nested volume overlapping PGDATA" - ) - mount = storage_mounts[0] - if ( - mount.get("Type") != "volume" - or mount.get("Name") != expected_volume - or mount.get("Destination") != str(EXPECTED_PGDATA) - or mount.get("RW") is not True - ): - raise GateError( - "PostgreSQL PGDATA is not the expected writable named postgres-data volume" - ) - if not isinstance(volume_labels, dict): - raise GateError("PostgreSQL named volume has no Compose ownership labels") - if ( - volume_labels.get("com.docker.compose.project") != compose_project_name - or volume_labels.get("com.docker.compose.volume") != "postgres-data" - ): - raise GateError( - "PostgreSQL PGDATA volume is anonymous or belongs to another Compose project" - ) - - -def parse_control_snapshot(output: str) -> tuple[str, int, str]: - lines = [line.strip() for line in output.splitlines() if line.strip()] - if len(lines) != 3: - raise GateError("PostgreSQL control query did not return exactly three values") - system_identifier, version_text, data_directory = lines - if not system_identifier.isdecimal() or int(system_identifier) <= 0: - raise GateError("PostgreSQL returned an invalid system identifier") - if not version_text.isdecimal(): - raise GateError("PostgreSQL returned an invalid server_version_num") - server_version_num = int(version_text) - if server_version_num // 10_000 != EXPECTED_POSTGRES_MAJOR: - raise GateError( - "the local restart gate is deliberately pinned to PostgreSQL 16; " - "follow the documented major-upgrade procedure" - ) - if data_directory != str(EXPECTED_PGDATA): - raise GateError( - f"PostgreSQL data_directory is {data_directory!r}, expected " - f"{str(EXPECTED_PGDATA)!r}" - ) - return system_identifier, server_version_num, data_directory - - -def parse_authority_counts(authority: str, output: str) -> dict[str, int]: - expected = set(CORRECTNESS_TABLES) - parsed: dict[str, int] = {} - for line in output.splitlines(): - if not line.strip(): - continue - parts = line.strip().split("|") - if len(parts) != 2: - raise GateError(f"{authority} returned a malformed correctness-state row") - table, count_text = parts - if table not in expected or table in parsed or not count_text.isdecimal(): - raise GateError(f"{authority} returned invalid or duplicate table counts") - parsed[table] = int(count_text) - if set(parsed) != expected: - missing = sorted(expected - set(parsed)) - raise GateError( - f"{authority} correctness-state snapshot is incomplete: {', '.join(missing)}" - ) - if sum(parsed.values()) == 0: - raise GateError( - f"{authority} has no correctness rows; run live smoke before the restart gate" - ) - return parsed - - -def compare_snapshots(before: StateSnapshot, after: StateSnapshot) -> None: - if before.container_id == after.container_id: - raise GateError("just down/up reused the PostgreSQL container") - if before.system_identifier != after.system_identifier: - raise GateError( - "PostgreSQL system_identifier changed across just down/up; " - "the named cluster was not preserved" - ) - if before.server_version_num != after.server_version_num: - raise GateError("PostgreSQL server_version_num changed across restart") - if before.data_directory != after.data_directory: - raise GateError("PostgreSQL data_directory changed across restart") - if before.volume_name != after.volume_name: - raise GateError("PostgreSQL named volume changed across restart") - if before.counts != after.counts: - before_map = {(a, t): c for a, t, c in before.counts} - after_map = {(a, t): c for a, t, c in after.counts} - changes = [ - f"{authority}.{table}: {before_map.get((authority, table))} -> " - f"{after_map.get((authority, table))}" - for authority, table in sorted(set(before_map) | set(after_map)) - if before_map.get((authority, table)) != after_map.get((authority, table)) - ] - raise GateError( - "Notary correctness-state counts changed across restart: " + "; ".join(changes) - ) - - -class RestartPersistenceGate: - def __init__( - self, - *, - root: Path = ROOT, - runner: CommandRunner | None = None, - environ: Mapping[str, str] | None = None, - wait_seconds: float = DEFAULT_WAIT_SECONDS, - ) -> None: - self.root = root.resolve() - self.runner = runner or CommandRunner() - self.env = dict(os.environ if environ is None else environ) - self.wait_seconds = wait_seconds - self.compose_project_name = "" - self.compose: list[str] = [] - self.expected_volume = "" - - def _run(self, command: Sequence[str]) -> str: - return self.runner.run(command, cwd=self.root, env=self.env) - - def resolve_compose(self) -> None: - for required in (".env", "versions.env", "compose.yaml"): - if not (self.root / required).is_file(): - raise GateError(f"{required} is missing") - project = self.env.get("COMPOSE_PROJECT_NAME", "").strip() - if not project: - project = self._run( - [sys.executable, str(self.root / "scripts" / "compose_project_name.py")] - ).strip() - if not project: - raise GateError("could not resolve COMPOSE_PROJECT_NAME") - self.compose_project_name = project - self.env["COMPOSE_PROJECT_NAME"] = project - self.compose = [ - "docker", - "compose", - "--env-file", - str(self.root / "versions.env"), - "--env-file", - str(self.root / ".env"), - "-f", - str(self.root / "compose.yaml"), - ] - rendered = self._run([*self.compose, "config", "--format", "json"]) - try: - config = json.loads(rendered) - except json.JSONDecodeError as error: - raise GateError("Docker Compose returned invalid configuration JSON") from error - self.expected_volume = validate_compose_pgdata_layout(config) - - def _compose(self, *arguments: str) -> str: - if not self.compose: - raise GateError("Compose has not been resolved") - return self._run([*self.compose, *arguments]) - - def service_container_id(self, service: str, *, include_stopped: bool = False) -> str: - arguments = ["ps", "-q"] - if include_stopped: - arguments.append("--all") - arguments.append(service) - output = self._compose(*arguments).strip() - identifiers = [line for line in output.splitlines() if line] - if len(identifiers) > 1: - raise GateError(f"Compose returned multiple containers for {service}") - return identifiers[0] if identifiers else "" - - def _container_health(self, container_id: str) -> str: - return self._run( - [ - "docker", - "inspect", - "--format", - "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", - container_id, - ] - ).strip() - - def _installer_status(self, container_id: str) -> tuple[str, int]: - output = self._run( - [ - "docker", - "inspect", - "--format", - "{{.State.Status}}|{{.State.ExitCode}}", - container_id, - ] - ).strip() - parts = output.split("|") - if len(parts) != 2 or not parts[1].lstrip("-").isdecimal(): - raise GateError("Docker returned a malformed installer status") - return parts[0], int(parts[1]) - - def wait_for_stack(self) -> None: - deadline = self.runner.monotonic() + self.wait_seconds - health_services = ("postgres", *(service for _key, service in AUTHORITIES)) - installers = tuple(f"{service}-state-install" for _key, service in AUTHORITIES) - last_pending: list[str] = [] - while self.runner.monotonic() < deadline: - pending: list[str] = [] - failed: list[str] = [] - for service in health_services: - container = self.service_container_id(service) - if not container: - pending.append(f"{service}=missing") - continue - status = self._container_health(container) - if status != "healthy": - pending.append(f"{service}={status or 'unknown'}") - for service in installers: - container = self.service_container_id(service, include_stopped=True) - if not container: - pending.append(f"{service}=missing") - continue - status, exit_code = self._installer_status(container) - if status == "exited" and exit_code == 0: - continue - if status == "exited": - failed.append(f"{service}=exit-{exit_code}") - else: - pending.append(f"{service}={status}") - if failed: - raise GateError("Notary state installer failed: " + ", ".join(failed)) - if not pending: - return - last_pending = pending - self.runner.sleep(2.0) - detail = ", ".join(last_pending) if last_pending else "unknown status" - raise GateError(f"stack did not become healthy within the deadline: {detail}") - - def doctor_all(self) -> None: - for _authority, service in AUTHORITIES: - self._compose( - "run", - "--rm", - "--no-deps", - service, - "--config", - "/etc/registry-notary/notary.yaml", - "state", - "doctor", - ) - print(f"notary-state: {service} doctor passed") - - def validate_runtime_mount(self, container_id: str) -> None: - mounts_output = self._run( - ["docker", "inspect", "--format", "{{json .Mounts}}", container_id] - ) - labels_output = self._run( - [ - "docker", - "volume", - "inspect", - "--format", - "{{json .Labels}}", - self.expected_volume, - ] - ) - try: - mounts = json.loads(mounts_output) - labels = json.loads(labels_output) - except json.JSONDecodeError as error: - raise GateError("Docker returned invalid mount metadata") from error - validate_runtime_pgdata_mounts( - mounts, - expected_volume=self.expected_volume, - volume_labels=labels, - compose_project_name=self.compose_project_name, - ) - - def _postgres_query(self, database: str, sql: str) -> str: - return self._compose( - "exec", - "-T", - "postgres", - "sh", - "-eu", - "-c", - 'exec psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$1" -Atc "$2"', - "notary-state", - database, - sql, - ) - - @staticmethod - def _counts_sql() -> str: - branches = [ - f"SELECT '{table}'::text AS table_name, count(*)::bigint AS row_count " - f"FROM registry_notary_private.{table}" - for table in CORRECTNESS_TABLES - ] - return ( - "WITH correctness_counts AS (" - + " UNION ALL ".join(branches) - + ") SELECT table_name || '|' || row_count::text " - "FROM correctness_counts ORDER BY table_name" - ) - - def capture_snapshot(self) -> StateSnapshot: - container_id = self.service_container_id("postgres") - if not container_id: - raise GateError("PostgreSQL container is not running") - self.validate_runtime_mount(container_id) - control = self._postgres_query( - "solmara_lab", - "SELECT system_identifier FROM pg_control_system(); " - "SHOW server_version_num; SHOW data_directory", - ) - system_identifier, server_version_num, data_directory = parse_control_snapshot( - control - ) - rows: list[tuple[str, str, int]] = [] - query = self._counts_sql() - for authority, _service in AUTHORITIES: - parsed = parse_authority_counts( - authority, - self._postgres_query(f"solmara_notary_{authority}", query), - ) - rows.extend( - (authority, table, parsed[table]) for table in CORRECTNESS_TABLES - ) - return StateSnapshot( - container_id=container_id, - system_identifier=system_identifier, - server_version_num=server_version_num, - data_directory=data_directory, - volume_name=self.expected_volume, - counts=tuple(sorted(rows)), - ) - - def run_just(self, recipe: str) -> None: - output = self._run(["just", recipe]) - if output: - print(output, end="" if output.endswith("\n") else "\n") - - def run_gate(self) -> StateSnapshot: - self.resolve_compose() - self.wait_for_stack() - self.doctor_all() - before = self.capture_snapshot() - - self.run_just("down") - if self.service_container_id("postgres", include_stopped=True): - raise GateError("just down did not remove the PostgreSQL container") - self.run_just("up") - - self.wait_for_stack() - # No doctor or scenario request may move above this snapshot. Installer - # schema metadata and readiness probes are deliberately excluded from - # the nine correctness-state tables. - after = self.capture_snapshot() - compare_snapshots(before, after) - self.doctor_all() - - totals = after.totals() - rendered_totals = ", ".join( - f"{authority}={totals[authority]}" for authority, _service in AUTHORITIES - ) - print( - "notary-state: just down/up preserved PostgreSQL system_identifier " - f"{after.system_identifier} on {after.volume_name}" - ) - print(f"notary-state: preserved correctness rows: {rendered_totals}") - return after - - -def main() -> int: - try: - RestartPersistenceGate().run_gate() - except GateError as error: - print(f"notary-state: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/postgres-entrypoint.sh b/scripts/postgres-entrypoint.sh deleted file mode 100755 index 7b55ea1..0000000 --- a/scripts/postgres-entrypoint.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env sh -set -eu - -ssl_src=/run/solmara-postgres-ssl -ssl_dst=/var/lib/postgresql/server-ssl - -mkdir -p "$ssl_dst" -cp "$ssl_src/server.crt" "$ssl_dst/server.crt" -cp "$ssl_src/server.key" "$ssl_dst/server.key" -chown postgres:postgres "$ssl_dst/server.crt" "$ssl_dst/server.key" -chmod 0644 "$ssl_dst/server.crt" -chmod 0600 "$ssl_dst/server.key" - -exec /usr/local/bin/docker-entrypoint.sh "$@" diff --git a/scripts/prepare-authority-runtime.sh b/scripts/prepare-authority-runtime.sh new file mode 100755 index 0000000..feddf15 --- /dev/null +++ b/scripts/prepare-authority-runtime.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +set -a +# shellcheck disable=SC1091 +. "$root/versions.env" +set +a +relayctl_image=${REGISTRY_RELAYCTL_IMAGE:?missing REGISTRY_RELAYCTL_IMAGE} + +"$root/scripts/publish-relay-sources.sh" + +cd "$root" +uv run scripts/publish-runtime-extracts.py + +for authority in cra nia mosd sipf nagdi; do + destination="$root/relays/$authority/package" + temporary_root=$(mktemp -d "$root/relays/$authority/.package.XXXXXX") + temporary="$temporary_root/package" + cleanup() { + rm -rf "$temporary_root" + } + trap cleanup EXIT HUP INT TERM + docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + --volume "$root:/workspace" \ + --volume "$root/output/sqlite/relay/$authority.sqlite:/var/lib/relay/source/$authority.sqlite:ro" \ + --workdir /workspace \ + "$relayctl_image" \ + --json package "relays/$authority" --output "${temporary#"$root/"}" >/dev/null + rm -rf "$destination" + mv "$temporary" "$destination" + rmdir "$temporary_root" + trap - EXIT HUP INT TERM +done diff --git a/scripts/project-runtime-secrets.py b/scripts/project-runtime-secrets.py new file mode 100644 index 0000000..2202faf --- /dev/null +++ b/scripts/project-runtime-secrets.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Project runtime secrets while excluding every provider signing key.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PRIVATE = ROOT / "config/evidence/local/cells" +OUTPUT = ROOT / "runtime/evidence-cells/secrets" +CELL_CLIENTS = { + "cra": ("cra-pension-evidence", "cra-citizen-evidence"), + "nia": (), "sro": (), + "mosd-programme": ("mosd-child-benefit-evidence",), + "sipf": ("sipf-pension-evidence", "sipf-survivor-evidence"), + "nagdi": ("nagdi-voucher-evidence", "nagdi-livestock-evidence"), +} + + +def project(private: Path = PRIVATE, output: Path = OUTPUT) -> None: + output.mkdir(parents=True, exist_ok=True) + for cell, clients in CELL_CLIENTS.items(): + destination = output / cell + destination.mkdir(mode=0o700) + client_files = tuple(f"{client}-{suffix}" for client in clients for suffix in ("client-id", "client-key")) + for name in ("audit-hmac-key", "subject-binding-hmac-key", *client_files): + shutil.copyfile(private / cell / "secrets" / name, destination / name) + (destination / name).chmod(0o600) + mint = output / "mint" + mint.mkdir(mode=0o700) + shutil.copyfile(private / "mint/secrets/audit-hmac-key", mint / "audit-hmac-key") + (mint / "audit-hmac-key").chmod(0o600) + + +if __name__ == "__main__": + project() diff --git a/scripts/provision-hosted-runtime.py b/scripts/provision-hosted-runtime.py new file mode 100644 index 0000000..e21eb79 --- /dev/null +++ b/scripts/provision-hosted-runtime.py @@ -0,0 +1,1201 @@ +#!/usr/bin/env python3 +"""Materialize one authority-owned hosted runtime from immutable public assets.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib +import importlib.util +import json +import os +import re +import shutil +import sqlite3 +import stat +import sys +import tempfile +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import urlsplit + +import yaml +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +GENERIC_ERROR = "hosted target provisioning failed" +SUCCESS = "hosted target ready" +MAX_SECRET_BYTES = 16 * 1024 +RELAYS = ("cra", "nia", "mosd", "sipf", "nagdi") +CELLS = ("cra", "nia", "sro", "mosd-programme", "sipf", "nagdi") +MINT_ORIGIN = "https://mint-authority-cells.solmara.registrystack.org" +RELAY_ORIGINS = { + "cra": "https://cra-relay-authority-cells.solmara.registrystack.org", + "mosd-programme": ( + "https://mosd-programme-relay-authority-cells.solmara.registrystack.org" + ), + "sipf": "https://sipf-relay-authority-cells.solmara.registrystack.org", + "nagdi": "https://nagdi-relay-authority-cells.solmara.registrystack.org", +} +DIRECT = { + "cra": ("cra-birth-extract", "cra-birth"), + "nia": ("nia-population-extract", "nia-population"), + "sro": ("sro-poverty-extract", "sro-poverty"), +} +EXPECTED_BIND_HOST = { + "mint": "172.29.1.20", + "cra": "172.29.2.21", + "nia": "172.29.2.22", + "sro": "172.29.3.23", + "mosd-programme": "172.29.3.24", + "sipf": "172.29.4.25", + "nagdi": "172.29.5.26", +} +CELL_CLIENTS = { + "cra": ("cra-pension-evidence", "cra-citizen-evidence"), + "nia": (), + "sro": (), + "mosd-programme": ("mosd-child-benefit-evidence",), + "sipf": ("sipf-pension-evidence", "sipf-survivor-evidence"), + "nagdi": ("nagdi-voucher-evidence", "nagdi-livestock-evidence"), +} +MINT_CLIENTS = { + "cra-pension-evidence": ( + "solmara:relay:cra:death-by-uin", + "https://id.registrystack.org/solmara/purpose/pension-payment-review", + ), + "cra-citizen-evidence": ( + "solmara:relay:cra:citizen-link-by-uin", + "https://id.registrystack.org/solmara/purpose/citizen-self-service", + ), + "mosd-child-benefit-evidence": ( + "solmara:relay:mosd:by-uin", + "https://id.registrystack.org/solmara/purpose/child-benefit-review", + ), + "sipf-pension-evidence": ( + "solmara:relay:sipf:by-pensioner-uin", + "https://id.registrystack.org/solmara/purpose/pension-payment-review", + ), + "sipf-survivor-evidence": ( + "solmara:relay:sipf:by-spouse-uin", + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination", + ), + "nagdi-voucher-evidence": ( + "solmara:relay:nagdi:voucher-by-farmer-id", + "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review", + ), + "nagdi-livestock-evidence": ( + "solmara:relay:nagdi:movement-by-farmer-id", + "https://id.registrystack.org/solmara/purpose/livestock-movement-control", + ), + "nia-esignet": ( + "solmara:relay:nia:esignet-userinfo", + "https://id.registrystack.org/solmara/purpose/esignet-identity-verification", + ), +} +ROLLBACK_RUNTIME = re.compile( + r"^runtime\.rollback-(?:cra-birth|nia-population|sro-poverty)-" + r"[0-9]{8}T[0-9]{6}(?:[0-9]{6})?Z\.yaml$" +) + + +class ProvisionError(RuntimeError): + """A value-free provisioning refusal.""" + + +class QuietParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + del message + raise ProvisionError("invalid arguments") + + +def _digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def verify_assets(root: Path) -> None: + try: + module_path = Path(__file__).with_name("hosted-runtime-assets.py") + spec = importlib.util.spec_from_file_location( + "hosted_runtime_assets", module_path + ) + if spec is None or spec.loader is None: + raise ProvisionError("invalid assets") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.verify_manifest(root) + except Exception: # noqa: BLE001 - public boundary is deliberately value-free. + raise ProvisionError("invalid assets") from None + + +def _read_secret(root: Path, name: str) -> bytes: + path = root / name + try: + if path.parent.resolve() != root.resolve() or path.is_symlink(): + raise ProvisionError("invalid secret") + metadata = path.stat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_mode & 0o022 + or metadata.st_size < 1 + or metadata.st_size > MAX_SECRET_BYTES + ): + raise ProvisionError("invalid secret") + value = path.read_bytes() + if len(value) != metadata.st_size: + raise ProvisionError("invalid secret") + return value.rstrip(b"\n") + except ProvisionError: + raise + except OSError: + raise ProvisionError("invalid secret") from None + + +def _hmac_secret(root: Path, name: str) -> bytes: + value = _read_secret(root, name) + if ( + len(value) < 32 + or len(value) > 128 + or any(byte < 0x21 or byte > 0x7E for byte in value) + ): + raise ProvisionError("invalid secret") + return value + + +def _validate_secret_inventory(root: Path, expected: set[str]) -> None: + try: + root_metadata = root.lstat() + if ( + not stat.S_ISDIR(root_metadata.st_mode) + or stat.S_IMODE(root_metadata.st_mode) != 0o700 + or root_metadata.st_uid != os.geteuid() + or root_metadata.st_gid != os.getegid() + ): + raise ProvisionError("invalid secret inventory") + observed: set[str] = set() + with os.scandir(root) as entries: + for entry in entries: + metadata = entry.stat(follow_symlinks=False) + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o400 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + or entry.name not in expected + ): + raise ProvisionError("invalid secret inventory") + observed.add(entry.name) + if observed != expected: + raise ProvisionError("invalid secret inventory") + for name in sorted(expected): + _read_secret(root, name) + except ProvisionError: + raise + except OSError: + raise ProvisionError("invalid secret inventory") from None + + +def _confine_secret_inventory(root: Path) -> None: + descriptor: int | None = None + try: + descriptor = os.open( + root, + os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), + ) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o755 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + ): + raise ProvisionError("invalid secret inventory") + os.fchmod(descriptor, 0o700) + except ProvisionError: + raise + except OSError: + raise ProvisionError("invalid secret inventory") from None + finally: + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + raise ProvisionError("invalid secret inventory") from None + + +def _provision_secret_inventory(target: str) -> set[str] | None: + if target.endswith("-relay") and target.removesuffix("-relay") in RELAYS: + return None + if target == "mint": + return { + "signing-public.jwk", + "audit-hmac-key", + "solmara-demo-client-public.jwk", + *(f"{client}-public.jwk" for client in MINT_CLIENTS), + } + if target.endswith("-evidence"): + cell = target.removesuffix("-evidence") + if cell in CELLS: + return { + "signing-public.jwk", + "audit-hmac-key", + "subject-binding-hmac-key", + *(f"{client}-client-key" for client in CELL_CLIENTS[cell]), + } + raise ProvisionError("invalid target") + + +def _consume_secret_inventory(root: Path, expected: set[str]) -> None: + """Remove only the closed injected input inventory, never output material.""" + descriptor: int | None = None + valid = True + observed: set[str] = set() + try: + descriptor = os.open( + root, + os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), + ) + root_metadata = os.fstat(descriptor) + if ( + not stat.S_ISDIR(root_metadata.st_mode) + or stat.S_IMODE(root_metadata.st_mode) != 0o700 + or root_metadata.st_uid != os.geteuid() + or root_metadata.st_gid != os.getegid() + ): + valid = False + observed = set(os.listdir(descriptor)) + if observed != expected: + valid = False + for name in sorted(observed & expected): + try: + metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o400 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + ): + valid = False + os.unlink(name, dir_fd=descriptor) + except OSError: + valid = False + if observed - expected: + valid = False + except OSError: + valid = False + finally: + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + valid = False + if not observed - expected: + try: + root.rmdir() + except OSError: + valid = False + if not valid: + raise ProvisionError("secret cleanup failed") + + +def _b64decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _public_jwk(data: bytes, *, allow_rsa: bool = True) -> dict[str, str]: + try: + value = json.loads(data.decode("utf-8")) + if not isinstance(value, dict) or "d" in value: + raise ProvisionError("invalid public key") + if value.get("kty") == "EC": + if ( + set(value) != {"kty", "crv", "alg", "x", "y", "kid"} + or value.get("crv") != "P-256" + or value.get("alg") != "ES256" + ): + raise ProvisionError("invalid public key") + x, y = ( + int.from_bytes(_b64decode(value["x"]), "big"), + int.from_bytes(_b64decode(value["y"]), "big"), + ) + ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key() + thumb = {key: value[key] for key in ("crv", "kty", "x", "y")} + elif allow_rsa and value.get("kty") == "RSA": + if ( + set(value) != {"kty", "alg", "n", "e", "kid"} + or value.get("alg") != "RS256" + ): + raise ProvisionError("invalid public key") + rsa.RSAPublicNumbers( + int.from_bytes(_b64decode(value["e"]), "big"), + int.from_bytes(_b64decode(value["n"]), "big"), + ).public_key() + thumb = {key: value[key] for key in ("e", "kty", "n")} + else: + raise ProvisionError("invalid public key") + kid = ( + base64.urlsafe_b64encode( + hashlib.sha256( + json.dumps(thumb, separators=(",", ":"), sort_keys=True).encode() + ).digest() + ) + .rstrip(b"=") + .decode() + ) + if value["kid"] != kid: + raise ProvisionError("invalid public key") + return value + except ProvisionError: + raise + except (UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError): + raise ProvisionError("invalid public key") from None + + +def _private_client_jwk(data: bytes) -> None: + try: + value = json.loads(data.decode("utf-8")) + if ( + not isinstance(value, dict) + or not isinstance(value.get("d"), str) + or not isinstance(value.get("kid"), str) + ): + raise ProvisionError("invalid client key") + if value.get("kty") == "EC": + required = {"kty", "crv", "alg", "x", "y", "d", "kid"} + if ( + set(value) != required + or value.get("crv") != "P-256" + or value.get("alg") != "ES256" + ): + raise ProvisionError("invalid client key") + scalar = int.from_bytes(_b64decode(value["d"]), "big") + public = ( + ec.derive_private_key(scalar, ec.SECP256R1()) + .public_key() + .public_numbers() + ) + if ( + int.from_bytes(_b64decode(value["x"]), "big") != public.x + or int.from_bytes(_b64decode(value["y"]), "big") != public.y + ): + raise ProvisionError("invalid client key") + public_value = { + key: value[key] for key in ("kty", "crv", "alg", "x", "y", "kid") + } + elif value.get("kty") == "RSA": + required = {"kty", "alg", "n", "e", "d", "p", "q", "dp", "dq", "qi", "kid"} + if set(value) != required or value.get("alg") != "RS256": + raise ProvisionError("invalid client key") + numbers = { + name: int.from_bytes(_b64decode(value[name]), "big") + for name in ("n", "e", "d", "p", "q", "dp", "dq", "qi") + } + rsa.RSAPrivateNumbers( + p=numbers["p"], + q=numbers["q"], + d=numbers["d"], + dmp1=numbers["dp"], + dmq1=numbers["dq"], + iqmp=numbers["qi"], + public_numbers=rsa.RSAPublicNumbers(numbers["e"], numbers["n"]), + ).private_key() + public_value = {key: value[key] for key in ("kty", "alg", "n", "e", "kid")} + else: + raise ProvisionError("invalid client key") + _public_jwk(json.dumps(public_value).encode()) + except ProvisionError: + raise + except (UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError): + raise ProvisionError("invalid client key") from None + + +def _tree_digest(root: Path) -> dict[str, tuple[str, int]]: + if not root.exists(): + return {} + result: dict[str, tuple[str, int]] = {} + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise ProvisionError("invalid existing output") + if path.is_dir(): + result[path.relative_to(root).as_posix()] = ( + "directory", + stat.S_IMODE(path.stat().st_mode), + ) + elif path.is_file(): + result[path.relative_to(root).as_posix()] = ( + _digest(path), + stat.S_IMODE(path.stat().st_mode), + ) + else: + raise ProvisionError("invalid existing output") + return result + + +def _check_install_tree( + staged: Path, + destination: Path, + *, + preserve: Callable[[str, tuple[str, int]], bool] | None = None, +) -> None: + if destination.is_symlink(): + raise ProvisionError("invalid existing output") + expected = _tree_digest(staged) + current = _tree_digest(destination) + active = { + relative: value + for relative, value in current.items() + if preserve is None or not preserve(relative, value) + } + if active and active != expected: + raise ProvisionError("existing output mismatch") + + +def _legacy_secret_tree(staged: Path, destination: Path) -> bool: + if destination.is_symlink(): + raise ProvisionError("invalid existing output") + expected = _tree_digest(staged) + current = _tree_digest(destination) + if current: + for path in destination.rglob("*"): + metadata = path.lstat() + if path.is_file() and ( + not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 + ): + raise ProvisionError("invalid existing output") + if not current or current == expected: + return False + legacy = { + relative: (digest, 0o400 if digest != "directory" and mode == 0o600 else mode) + for relative, (digest, mode) in expected.items() + } + if current != legacy: + raise ProvisionError("existing output mismatch") + return True + + +def _check_secret_install_tree(staged: Path, destination: Path) -> None: + _legacy_secret_tree(staged, destination) + + +def _install_tree( + staged: Path, + destination: Path, + *, + root_mode: int, + owner: tuple[int, int] | None = None, + preserve: Callable[[str, tuple[str, int]], bool] | None = None, +) -> None: + _check_install_tree(staged, destination, preserve=preserve) + expected = _tree_digest(staged) + destination.mkdir(parents=True, exist_ok=True) + for source in sorted(staged.rglob("*")): + relative = source.relative_to(staged) + target = destination / relative + if source.is_dir(): + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o755) + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.parent.chmod(0o755) + if target.exists(): + continue + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + descriptor = os.open(target, flags, stat.S_IMODE(source.stat().st_mode)) + with os.fdopen(descriptor, "wb") as output: + output.write(source.read_bytes()) + target.chmod(stat.S_IMODE(source.stat().st_mode)) + for source in sorted( + (path for path in staged.rglob("*") if path.is_dir()), + key=lambda path: len(path.parts), + reverse=True, + ): + (destination / source.relative_to(staged)).chmod( + stat.S_IMODE(source.stat().st_mode) + ) + destination.chmod(root_mode) + if owner is not None: + uid, gid = owner + for target in sorted(destination.rglob("*"), reverse=True): + os.chown(target, uid, gid, follow_symlinks=False) + os.chown(destination, uid, gid, follow_symlinks=False) + observed = { + relative: value + for relative, value in _tree_digest(destination).items() + if preserve is None or not preserve(relative, value) + } + if observed != expected: + raise ProvisionError("output verification failed") + + +def _install_secret_tree(staged: Path, destination: Path) -> None: + if _legacy_secret_tree(staged, destination): + for path in destination.rglob("*"): + if path.is_file(): + path.chmod(0o600) + _install_tree( + staged, + destination, + root_mode=0o700, + owner=(65532, 65532), + ) + + +def _preserve_extract_rollback(relative: str, value: tuple[str, int]) -> bool: + return ( + "/" not in relative + and ROLLBACK_RUNTIME.fullmatch(relative) is not None + and value[0] != "directory" + and value[1] == 0o444 + ) + + +def _copy_tree(source: Path, destination: Path) -> None: + shutil.copytree(source, destination) + + +def _freeze_tree(destination: Path) -> None: + for path in sorted(destination.rglob("*"), reverse=True): + path.chmod(0o555 if path.is_dir() else 0o444) + destination.chmod(0o555) + + +def _write(path: Path, value: bytes, mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(value) + path.chmod(mode) + + +def _validated_origin(value: str | None, expected: str) -> str: + try: + if value is None or value != expected: + raise ProvisionError("invalid origin") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or parsed.netloc != parsed.hostname + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.port is not None + or parsed.path + or parsed.query + or parsed.fragment + or value != f"https://{parsed.hostname}" + ): + raise ProvisionError("invalid origin") + return value + except (TypeError, ValueError): + raise ProvisionError("invalid origin") from None + + +def _patch_mint_origin(config: dict, mint_origin: str) -> None: + mint_origin = _validated_origin(mint_origin, MINT_ORIGIN) + config["issuer"] = mint_origin + config["clientAssertion"]["audience"] = f"{mint_origin}/token" + + +def _patch_relay_origin(config: dict, mint_origin: str) -> None: + mint_origin = _validated_origin(mint_origin, MINT_ORIGIN) + config["authentication"]["issuer"]["discoveryUrl"] = ( + f"{mint_origin}/.well-known/openid-configuration" + ) + + +def _patch_evidence_origins( + config: dict, cell: str, mint_origin: str, relay_origin: str | None +) -> None: + mint_origin = _validated_origin(mint_origin, MINT_ORIGIN) + expected_relay_origin = RELAY_ORIGINS.get(cell) + if (expected_relay_origin is None) != (relay_origin is None): + raise ProvisionError("invalid source origin") + if expected_relay_origin is not None: + relay_origin = _validated_origin(relay_origin, expected_relay_origin) + config["authentication"]["issuer"] = mint_origin + config["authentication"]["jwksUri"] = f"{mint_origin}/.well-known/jwks.json" + relay_sources = [ + source_config + for source_config in config["sources"].values() + if source_config["transport"] == "http-json" + ] + if (cell in RELAY_ORIGINS) != bool(relay_sources) or (relay_origin is None) != ( + cell not in RELAY_ORIGINS + ): + raise ProvisionError("invalid source origin") + for source_config in relay_sources: + source_config["baseUrl"] = relay_origin + authentication = source_config["authentication"] + authentication["tokenEndpoint"] = f"{mint_origin}/token" + authentication["clientAssertionAudience"] = f"{mint_origin}/token" + + +def _patch_runtime(path: Path, bind_host: str, extract_name: str | None = None) -> None: + config = yaml.safe_load(path.read_text(encoding="utf-8")) + config["listener"]["bindHost"] = bind_host + if extract_name is not None: + profile = next(iter(config["sourceExtracts"])) + old_path = Path(config["sourceExtracts"][profile]["path"]) + config["sourceExtracts"][profile]["path"] = str( + old_path.with_name(extract_name) + ) + path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + + +def _stage_relay( + assets: Path, + authority: str, + runtime: Path, + source: Path, + mint_origin: str, +) -> None: + relay = assets / "relays" / authority + config = yaml.safe_load((relay / "runtime.yaml").read_text(encoding="utf-8")) + _patch_relay_origin(config, mint_origin) + _write( + runtime / "runtime.yaml", + yaml.safe_dump(config, sort_keys=False).encode(), + 0o444, + ) + _copy_tree(relay / "package", runtime / "package") + _write( + source / f"{authority}.sqlite", + (relay / "source" / f"{authority}.sqlite").read_bytes(), + 0o444, + ) + _freeze_tree(source) + _freeze_tree(runtime) + + +def _load_publisher(assets: Path): + sys.path.insert(0, str(assets / "generator")) + return importlib.import_module("solmara_lab.publisher") + + +def _stage_extract( + assets: Path, cell: str, destination: Path, published_at: str, observed_at: str +) -> str: + publisher = _load_publisher(assets) + extract_id = publisher.timestamped_extract_id(cell, published_at) + with tempfile.TemporaryDirectory(prefix="solmara-extract-") as temporary: + generated = publisher.publish_extract( + Path(temporary), cell, published_at, extract_id + ) + publisher.validate_extract( + generated, + cell, + observed_at=observed_at, + expected_extract_id=extract_id, + expected_published_at=published_at, + ) + target = destination / generated.name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(generated, target) + target.chmod(0o444) + return f"{extract_id}.sqlite" + + +def _publication_time( + assets: Path, + cell: str, + runtime_output: Path, + extract_output: Path, + observed_at: str, + *, + require_fresh: bool = True, +) -> str: + runtime_file = runtime_output / "runtime.yaml" + if not runtime_file.exists(): + existing = sorted(extract_output.glob("*.sqlite")) + if not existing: + return observed_at + if len(existing) != 1: + raise ProvisionError("invalid existing extract") + try: + publisher = _load_publisher(assets) + metadata = publisher.validate_extract( + existing[0], cell, observed_at=observed_at + ) + if existing[0].name != f"{metadata.extract_id}.sqlite": + raise ProvisionError("invalid existing extract") + return metadata.published_at + except ProvisionError: + raise + except Exception: # noqa: BLE001 - dependency errors become one refusal. + raise ProvisionError("invalid existing extract") from None + try: + if runtime_file.is_symlink() or not runtime_file.is_file(): + raise ProvisionError("invalid existing extract") + config = yaml.safe_load(runtime_file.read_text(encoding="utf-8")) + profile = DIRECT[cell][0] + if set(config["sourceExtracts"]) != {profile}: + raise ProvisionError("invalid existing extract") + bound_path = Path(config["sourceExtracts"][profile]["path"]) + if bound_path.parent != Path(f"/var/lib/registry-evidence/{cell}/extracts"): + raise ProvisionError("invalid existing extract") + extract_name = bound_path.name + existing = extract_output / extract_name + with sqlite3.connect(f"file:{existing}?mode=ro", uri=True) as connection: + rows = connection.execute( + "SELECT published_at, extract_id FROM evidence_extract" + ).fetchall() + if len(rows) != 1: + raise ProvisionError("invalid existing extract") + published_at, extract_id = rows[0] + publisher = _load_publisher(assets) + publisher.validate_extract( + existing, + cell, + observed_at=observed_at if require_fresh else published_at, + expected_extract_id=extract_id, + expected_published_at=published_at, + ) + if extract_name != f"{extract_id}.sqlite": + raise ProvisionError("invalid existing extract") + return published_at + except ProvisionError: + raise + except Exception: # noqa: BLE001 - dependency errors become one refusal. + raise ProvisionError("invalid existing extract") from None + + +def _append_file(staged: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + destination.chmod(0o755) + try: + target = destination / staged.name + if target.exists(): + if target.is_symlink() or _digest(target) != _digest(staged): + raise ProvisionError("existing output mismatch") + return + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o444) + with os.fdopen(descriptor, "wb") as output: + output.write(staged.read_bytes()) + output.flush() + os.fsync(output.fileno()) + target.chmod(0o444) + finally: + destination.chmod(0o555) + + +def _replace_extract_binding( + runtime_output: Path, + cell: str, + previous_name: str, + replacement_name: str, +) -> None: + runtime_file = runtime_output / "runtime.yaml" + try: + if runtime_file.is_symlink() or not runtime_file.is_file(): + raise ProvisionError("invalid existing runtime") + original = runtime_file.read_bytes() + config = yaml.safe_load(original.decode("utf-8")) + if config["listener"]["bindHost"] != EXPECTED_BIND_HOST[cell]: + raise ProvisionError("invalid existing runtime") + if set(config["sourceExtracts"]) != {DIRECT[cell][0]}: + raise ProvisionError("invalid existing runtime") + binding = config["sourceExtracts"][DIRECT[cell][0]] + current = Path(binding["path"]) + if current.name != previous_name or current.parent != Path( + f"/var/lib/registry-evidence/{cell}/extracts" + ): + raise ProvisionError("invalid existing runtime") + binding["path"] = str(current.with_name(replacement_name)) + rendered = yaml.safe_dump(config, sort_keys=False).encode() + runtime_output.chmod(0o755) + rollback = runtime_output / f"runtime.rollback-{Path(previous_name).stem}.yaml" + if rollback.exists(): + if ( + rollback.is_symlink() + or not rollback.is_file() + or rollback.read_bytes() != original + or stat.S_IMODE(rollback.stat().st_mode) != 0o444 + ): + raise ProvisionError("invalid existing runtime") + else: + rollback_descriptor = os.open( + rollback, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o444 + ) + with os.fdopen(rollback_descriptor, "wb") as output: + output.write(original) + output.flush() + os.fsync(output.fileno()) + rollback.chmod(0o444) + descriptor, temporary_name = tempfile.mkstemp( + prefix=".runtime-", suffix=".yaml", dir=runtime_output + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(rendered) + output.flush() + os.fsync(output.fileno()) + temporary.chmod(0o444) + os.replace(temporary, runtime_file) + directory_descriptor = os.open(runtime_output, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + finally: + temporary.unlink(missing_ok=True) + except ProvisionError: + raise + except (OSError, TypeError, KeyError, yaml.YAMLError): + raise ProvisionError("invalid existing runtime") from None + finally: + try: + runtime_output.chmod(0o555) + except OSError: + pass + + +def publish_extract(args: argparse.Namespace) -> None: + assets = args.assets.resolve() + verify_assets(assets) + if not args.target.endswith("-evidence"): + raise ProvisionError("invalid target") + cell = args.target.removesuffix("-evidence") + if cell not in DIRECT: + raise ProvisionError("invalid target") + runtime_output = args.runtime_output.resolve() + extract_output = args.extract_output.resolve() + now = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + previous_publication = _publication_time( + assets, + cell, + runtime_output, + extract_output, + now, + require_fresh=False, + ) + previous_name = f"{_load_publisher(assets).timestamped_extract_id(cell, previous_publication)}.sqlite" + with tempfile.TemporaryDirectory( + prefix="solmara-extract-publication-" + ) as temporary: + staging = Path(temporary) + replacement_name = _stage_extract(assets, cell, staging, now, now) + if replacement_name == previous_name: + raise ProvisionError("extract publication is not newer") + _append_file(staging / replacement_name, extract_output) + _replace_extract_binding(runtime_output, cell, previous_name, replacement_name) + + +def _stage_evidence( + assets: Path, + cell: str, + secrets: Path, + runtime: Path, + secret_output: Path, + extract_output: Path | None, + bind_host: str, + published_at: str, + observed_at: str, + mint_origin: str, + relay_origin: str | None, +) -> None: + source = assets / "evidence" / "cells" / cell + _copy_tree(source, runtime) + public = _public_jwk(_read_secret(secrets, "signing-public.jwk"), allow_rsa=False) + bundle_config = runtime / "bundle" / "evidence.yaml" + config = yaml.safe_load(bundle_config.read_text(encoding="utf-8")) + config["signing"]["activePublicJwkFile"] = f"public-keys/{public['kid']}.jwk.json" + _patch_evidence_origins(config, cell, mint_origin, relay_origin) + bundle_config.chmod(0o644) + bundle_config.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + bundle_config.chmod(0o444) + _write( + runtime / "bundle" / "public-keys" / f"{public['kid']}.jwk.json", + json.dumps(public, indent=2, sort_keys=True).encode(), + 0o444, + ) + extract_name = ( + _stage_extract(assets, cell, extract_output, published_at, observed_at) + if extract_output is not None + else None + ) + runtime_file = runtime / "runtime.yaml" + runtime_file.chmod(0o644) + _patch_runtime(runtime_file, bind_host, extract_name) + runtime_file.chmod(0o444) + _write( + secret_output / "audit-hmac-key", _hmac_secret(secrets, "audit-hmac-key"), 0o600 + ) + _write( + secret_output / "subject-binding-hmac-key", + _hmac_secret(secrets, "subject-binding-hmac-key"), + 0o600, + ) + for client in CELL_CLIENTS[cell]: + value = _read_secret(secrets, f"{client}-client-key") + _private_client_jwk(value) + _write(secret_output / f"{client}-client-key", value, 0o600) + _write(secret_output / f"{client}-client-id", client.encode(), 0o600) + _freeze_tree(runtime) + for directory in [secret_output, *secret_output.rglob("*")]: + if directory.is_dir(): + directory.chmod(0o700) + + +def _stage_mint( + assets: Path, + secrets: Path, + runtime: Path, + secret_output: Path, + bind_host: str, + mint_origin: str, +) -> None: + public = _public_jwk(_read_secret(secrets, "signing-public.jwk"), allow_rsa=False) + config = yaml.safe_load((assets / "mint" / "mint.yaml").read_text(encoding="utf-8")) + _patch_mint_origin(config, mint_origin) + config["listener"]["address"] = bind_host + config["signing"]["activePublicJwkFile"] = f"public-keys/{public['kid']}.jwk.json" + _write( + runtime / "mint.yaml", yaml.safe_dump(config, sort_keys=False).encode(), 0o444 + ) + _write( + runtime / "public-keys" / f"{public['kid']}.jwk.json", + json.dumps(public, indent=2, sort_keys=True).encode(), + 0o444, + ) + for client, (scope, purpose) in MINT_CLIENTS.items(): + key = _public_jwk(_read_secret(secrets, f"{client}-public.jwk")) + registration = { + "clientId": client, + "principal": f"https://id.registrystack.org/solmara/client/{client}", + "authorization": {"scopes": [scope], "claims": {"purpose": purpose}}, + "keys": [key], + } + _write( + runtime / "clients" / f"{client}.yaml", + yaml.safe_dump(registration, sort_keys=False).encode(), + 0o444, + ) + demo = _public_jwk( + _read_secret(secrets, "solmara-demo-client-public.jwk"), allow_rsa=False + ) + registration = { + "clientId": "solmara-demo", + "principal": "https://id.registrystack.org/solmara/client/solmara-demo", + "evidenceAudience": "https://id.registrystack.org/solmara/audience/demo-client", + "requesterTags": ["solmara-demo"], + "keys": [demo], + } + _write( + runtime / "clients" / "solmara-demo.yaml", + yaml.safe_dump(registration, sort_keys=False).encode(), + 0o444, + ) + _write( + secret_output / "audit-hmac-key", _hmac_secret(secrets, "audit-hmac-key"), 0o600 + ) + _freeze_tree(runtime) + for directory in [secret_output, *secret_output.rglob("*")]: + if directory.is_dir(): + directory.chmod(0o700) + + +def _provision_target(args: argparse.Namespace) -> None: + assets, target = args.assets.resolve(), args.target + verify_assets(assets) + mint_origin = _validated_origin(args.mint_origin, MINT_ORIGIN) + now = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + with tempfile.TemporaryDirectory(prefix="solmara-provision-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + source = root / "source" + secret_output = root / "secrets" + extracts = root / "extracts" + if target.endswith("-relay"): + authority = target.removesuffix("-relay") + if ( + authority not in RELAYS + or args.source_output is None + or args.secret_output + or args.extract_output + or args.bind_host + or args.relay_origin + ): + raise ProvisionError("invalid target") + _stage_relay(assets, authority, runtime, source, mint_origin) + _check_install_tree(source, args.source_output.resolve()) + _check_install_tree(runtime, args.runtime_output.resolve()) + _install_tree(source, args.source_output.resolve(), root_mode=0o555) + elif target == "mint": + if ( + args.secret_output is None + or not args.bind_host + or args.source_output + or args.extract_output + or args.relay_origin + ): + raise ProvisionError("invalid target") + if args.bind_host != EXPECTED_BIND_HOST["mint"]: + raise ProvisionError("invalid bind host") + mint_secrets = _provision_secret_inventory(target) + if mint_secrets is None: + raise ProvisionError("invalid target") + _validate_secret_inventory(args.secrets.absolute(), mint_secrets) + _stage_mint( + assets, + args.secrets.absolute(), + runtime, + secret_output, + args.bind_host, + mint_origin, + ) + _check_secret_install_tree(secret_output, args.secret_output.resolve()) + _check_install_tree(runtime, args.runtime_output.resolve()) + _install_secret_tree(secret_output, args.secret_output.resolve()) + elif target.endswith("-evidence"): + cell = target.removesuffix("-evidence") + if ( + cell not in CELLS + or args.secret_output is None + or not args.bind_host + or args.source_output + ): + raise ProvisionError("invalid target") + if (cell in DIRECT) != (args.extract_output is not None): + raise ProvisionError("invalid target") + expected_relay_origin = RELAY_ORIGINS.get(cell) + if (expected_relay_origin is None) != (args.relay_origin is None): + raise ProvisionError("invalid origin") + relay_origin = ( + _validated_origin(args.relay_origin, expected_relay_origin) + if expected_relay_origin is not None + else None + ) + if args.bind_host != EXPECTED_BIND_HOST[cell]: + raise ProvisionError("invalid bind host") + cell_secrets = _provision_secret_inventory(target) + if cell_secrets is None: + raise ProvisionError("invalid target") + _validate_secret_inventory(args.secrets.absolute(), cell_secrets) + published_at = ( + _publication_time( + assets, + cell, + args.runtime_output.resolve(), + args.extract_output.resolve(), + now, + ) + if cell in DIRECT + else now + ) + _stage_evidence( + assets, + cell, + args.secrets.absolute(), + runtime, + secret_output, + extracts if cell in DIRECT else None, + args.bind_host, + published_at, + now, + mint_origin, + relay_origin, + ) + runtime_preserve = _preserve_extract_rollback if cell in DIRECT else None + _check_secret_install_tree(secret_output, args.secret_output.resolve()) + if cell in DIRECT: + _check_install_tree(extracts, args.extract_output.resolve()) + _check_install_tree( + runtime, args.runtime_output.resolve(), preserve=runtime_preserve + ) + _install_secret_tree(secret_output, args.secret_output.resolve()) + if cell in DIRECT: + _install_tree(extracts, args.extract_output.resolve(), root_mode=0o555) + else: + raise ProvisionError("invalid target") + _install_tree( + runtime, + args.runtime_output.resolve(), + root_mode=0o555, + preserve=( + _preserve_extract_rollback + if target.endswith("-evidence") + and target.removesuffix("-evidence") in DIRECT + else None + ), + ) + + +def provision(args: argparse.Namespace) -> None: + expected = _provision_secret_inventory(args.target) + if expected is None: + if args.secrets is not None: + raise ProvisionError("invalid target") + _provision_target(args) + return + if args.secrets is None: + raise ProvisionError("invalid target") + secret_root = args.secrets.absolute() + try: + _confine_secret_inventory(secret_root) + _provision_target(args) + finally: + _consume_secret_inventory(secret_root, expected) + + +def init_audit(destinations: list[Path], uid: int, gid: int) -> None: + if not destinations or uid != 65532 or gid != 65532: + raise ProvisionError("invalid audit target") + for destination in destinations: + metadata = destination.lstat() + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise ProvisionError("invalid audit target") + for destination in destinations: + os.chown(destination, uid, gid) + os.chmod(destination, 0o700) + + +def parser() -> argparse.ArgumentParser: + result = QuietParser(add_help=False) + sub = result.add_subparsers(dest="command", required=True) + ready = sub.add_parser("provision", add_help=False) + ready.add_argument("--target", required=True) + ready.add_argument("--assets", required=True, type=Path) + ready.add_argument("--secrets", type=Path) + ready.add_argument("--runtime-output", required=True, type=Path) + ready.add_argument("--source-output", type=Path) + ready.add_argument("--secret-output", type=Path) + ready.add_argument("--extract-output", type=Path) + ready.add_argument("--bind-host") + ready.add_argument("--mint-origin", required=True) + ready.add_argument("--relay-origin") + publication = sub.add_parser("publish-extract", add_help=False) + publication.add_argument("--target", required=True) + publication.add_argument("--assets", required=True, type=Path) + publication.add_argument("--runtime-output", required=True, type=Path) + publication.add_argument("--extract-output", required=True, type=Path) + audit = sub.add_parser("init-audit", add_help=False) + audit.add_argument("--destination", action="append", required=True, type=Path) + audit.add_argument("--uid", required=True, type=int) + audit.add_argument("--gid", required=True, type=int) + sub.add_parser("ready", add_help=False) + return result + + +def main(argv: list[str] | None = None) -> int: + os.umask(0o077) + try: + args = parser().parse_args(argv) + if args.command == "provision": + provision(args) + elif args.command == "publish-extract": + publish_extract(args) + elif args.command == "init-audit": + init_audit(args.destination, args.uid, args.gid) + except Exception: # noqa: BLE001 - the command boundary is deliberately value-free. + print(GENERIC_ERROR, file=sys.stderr) + return 1 + print(SUCCESS) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish-metadata.py b/scripts/publish-metadata.py index af8a3af..1d7217b 100755 --- a/scripts/publish-metadata.py +++ b/scripts/publish-metadata.py @@ -27,74 +27,188 @@ VOUCHER_REVIEW_PURPOSE = ( "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review" ) -CHILD_BENEFIT_COLLECTION_MEDIA_TYPE = "application/json" -CHILD_BENEFIT_FEDERATOR_URL = ( - "https://child-benefit-federator.solmara.registrystack.org" -) - -DATASET_OFFERING_DEFAULTS = { +DATASET_DEFAULTS = { "cra-civil": { - "evidence_type": "birth-registration-evidence", - "entity": "civil_person", - "service": "child-benefit-review", - "endpoint": "https://cra-notary.solmara.registrystack.org/v1/evaluations", - "discovery": "https://cra-notary.solmara.registrystack.org/.well-known/evidence-service", - "purposes": [CHILD_BENEFIT_PURPOSE], "concepts": [ "https://publicschema.org/crvs/Birth", "https://publicschema.org/crvs/Death", ], }, "nia-population": { - "evidence_type": "population-status-evidence", - "entity": "person", - "service": "child-benefit-review", - "endpoint": "https://nia-notary.solmara.registrystack.org/v1/evaluations", - "discovery": "https://nia-notary.solmara.registrystack.org/.well-known/evidence-service", - "purposes": [CHILD_BENEFIT_PURPOSE], "concepts": ["https://publicschema.org/Person"], }, "sro-social": { - "evidence_type": "household-poverty-evidence", - "entity": "household", - "service": "child-benefit-review", - "endpoint": "https://sro-notary.solmara.registrystack.org/v1/evaluations", - "discovery": "https://sro-notary.solmara.registrystack.org/.well-known/evidence-service", - "purposes": [CHILD_BENEFIT_PURPOSE], "concepts": [ "https://publicschema.org/Household", "https://publicschema.org/SocioEconomicProfile", ], }, "mosd-programme": { - "evidence_type": "beneficiary-enrollment-evidence", - "entity": "enrollment", - "service": "child-benefit-review", - "endpoint": "https://programme-notary.solmara.registrystack.org/v1/evaluations", - "discovery": "https://programme-notary.solmara.registrystack.org/.well-known/evidence-service", - "purposes": [CHILD_BENEFIT_PURPOSE], "concepts": ["https://publicschema.org/sp/Enrollment"], }, "sipf-pensions": { - "evidence_type": "pension-case-evidence", - "entity": "pension_case", - "service": "pension-survivor-review", - "endpoint": "https://sipf-notary.solmara.registrystack.org/v1/evaluations", - "discovery": "https://sipf-notary.solmara.registrystack.org/.well-known/evidence-service", - "purposes": [PENSION_PAYMENT_PURPOSE, SURVIVOR_BENEFIT_PURPOSE], "concepts": ["https://id.registrystack.org/solmara/semantics/pension-case"], }, "nagdi-agriculture": { - "evidence_type": "farmer-voucher-evidence", - "entity": "farmer_voucher", - "service": "nagdi-voucher-review", - "endpoint": "https://nagdi-notary.solmara.registrystack.org/v1/evaluations", - "discovery": "https://nagdi-notary.solmara.registrystack.org/.well-known/evidence-service", - "purposes": [VOUCHER_REVIEW_PURPOSE], "concepts": ["https://publicschema.org/Farm"], }, } +AUTHORITY_EVIDENCE_URLS = { + "cra": "https://cra-evidence.solmara.registrystack.org", + "nia": "https://nia-evidence.solmara.registrystack.org", + "sro": "https://sro-evidence.solmara.registrystack.org", + "mosd-programme-mis": "https://mosd-programme-evidence.solmara.registrystack.org", + "sipf": "https://sipf-evidence.solmara.registrystack.org", + "nagdi": "https://nagdi-evidence.solmara.registrystack.org", +} + +EVIDENCE_OFFERING_SPECS = [ + { + "id": "cra-child-benefit-v1-offering", + "dataset": "cra-civil", + "entity": "civil_person", + "authority": "cra", + "evidence_type": "cra-child-benefit-v1", + "service": "child-benefit-review", + "purposes": [CHILD_BENEFIT_PURPOSE], + "lookup_keys": ["uin"], + "concepts": [ + "https://id.registrystack.org/solmara/concept/birth-is-registered", + "https://id.registrystack.org/solmara/concept/child-age-under-5", + ], + "source": "immutable extract", + }, + { + "id": "cra-pension-death-v1-offering", + "dataset": "cra-civil", + "entity": "civil_person", + "authority": "cra", + "evidence_type": "cra-death-status-v1", + "service": "pension-survivor-review", + "purposes": [PENSION_PAYMENT_PURPOSE], + "lookup_keys": ["uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/person-is-deceased"], + "source": "Relay lookup", + }, + { + "id": "cra-citizen-record-v1-offering", + "dataset": "cra-civil", + "entity": "civil_person", + "authority": "cra", + "evidence_type": "civil-record-linked-v1", + "service": "citizen-self-service", + "purposes": ["https://id.registrystack.org/solmara/purpose/citizen-self-service"], + "lookup_keys": ["uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/civil-record-linked"], + "source": "Relay lookup", + }, + { + "id": "nia-child-benefit-v1-offering", + "dataset": "nia-population", + "entity": "population_person", + "authority": "nia", + "evidence_type": "population-active-v1", + "service": "child-benefit-review", + "purposes": [CHILD_BENEFIT_PURPOSE], + "lookup_keys": ["uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/population-record-active"], + "source": "immutable extract", + }, + { + "id": "nia-citizen-status-v1-offering", + "dataset": "nia-population", + "entity": "population_person", + "authority": "nia", + "evidence_type": "citizen-population-active-v1", + "service": "citizen-self-service", + "purposes": ["https://id.registrystack.org/solmara/purpose/citizen-self-service"], + "lookup_keys": ["uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/citizen-population-record-active"], + "source": "immutable extract", + }, + { + "id": "sro-child-benefit-v1-offering", + "dataset": "sro-social", + "entity": "poverty_record", + "authority": "sro", + "evidence_type": "poverty-priority-v1", + "service": "child-benefit-review", + "purposes": [CHILD_BENEFIT_PURPOSE], + "lookup_keys": ["uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/household-below-poverty-threshold"], + "source": "immutable extract", + }, + { + "id": "mosd-child-benefit-v1-offering", + "dataset": "mosd-programme", + "entity": "beneficiary_enrolment", + "authority": "mosd-programme-mis", + "evidence_type": "not-enrolled-v1", + "service": "child-benefit-review", + "purposes": [CHILD_BENEFIT_PURPOSE], + "lookup_keys": ["uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/not-already-enrolled"], + "source": "Relay lookup", + }, + { + "id": "sipf-pension-payment-v1-offering", + "dataset": "sipf-pensions", + "entity": "pension_payment", + "authority": "sipf", + "evidence_type": "pension-payment-active-v1", + "service": "pension-survivor-review", + "purposes": [PENSION_PAYMENT_PURPOSE], + "lookup_keys": ["pensioner_uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/pension-payment-active"], + "source": "Relay lookup", + }, + { + "id": "sipf-survivor-benefit-v1-offering", + "dataset": "sipf-pensions", + "entity": "survivor_case", + "authority": "sipf", + "evidence_type": "survivor-benefit-v1", + "service": "pension-survivor-review", + "purposes": [SURVIVOR_BENEFIT_PURPOSE], + "lookup_keys": ["spouse_uin"], + "concepts": ["https://id.registrystack.org/solmara/concept/survivor-is-eligible"], + "source": "Relay lookup", + }, + { + "id": "nagdi-voucher-v1-offering", + "dataset": "nagdi-agriculture", + "entity": "farmer_voucher", + "authority": "nagdi", + "evidence_type": "climate-smart-voucher-v1", + "service": "agriculture-review", + "purposes": [VOUCHER_REVIEW_PURPOSE], + "lookup_keys": ["farmer_id"], + "concepts": [ + "https://id.registrystack.org/solmara/concept/farmer-registered", + "https://id.registrystack.org/solmara/concept/data-use-authorized-for-purpose", + "https://id.registrystack.org/solmara/concept/eligible-for-climate-smart-input-voucher", + ], + "source": "Relay lookup", + }, + { + "id": "nagdi-livestock-v1-offering", + "dataset": "nagdi-agriculture", + "entity": "livestock_movement", + "authority": "nagdi", + "evidence_type": "livestock-movement-v1", + "service": "agriculture-review", + "purposes": ["https://id.registrystack.org/solmara/purpose/livestock-movement-control"], + "lookup_keys": ["farmer_id"], + "concepts": [ + "https://id.registrystack.org/solmara/concept/registered-herd", + "https://id.registrystack.org/solmara/concept/origin-district-not-quarantined-for-species", + "https://id.registrystack.org/solmara/concept/eligible-for-livestock-movement-permit", + ], + "source": "Relay lookup", + }, +] + GRAY_REGISTRIES = [ { "id": "land-cadastre", @@ -240,46 +354,11 @@ def build_bundle( normalize_dataset(dataset, fragment_index) for dataset in manifest.get("datasets", []) ] - offerings = [] - for dataset in datasets: - source_offerings = dataset.pop("source_evidence_offerings", []) - if source_offerings: - offerings.extend( - normalize_source_offering(dataset, offering, authorities) - for offering in source_offerings - ) - else: - offerings.append(synthetic_offering(dataset, authorities)) - - # NAgDI has two live evidence paths in wave 1; keep both visible to the visitor center. - if not any( - offering["id"] == "nagdi-agriculture-livestock-movement-offering" - for offering in offerings - ): - nagdi = next( - dataset for dataset in datasets if dataset["id"] == "nagdi-agriculture" - ) - livestock = synthetic_offering( - nagdi, - authorities, - evidence_type="livestock-movement-evidence", - entity="livestock_movement", - ) - livestock["id"] = "nagdi-agriculture-livestock-movement-offering" - livestock["title"] = "NAgDI livestock movement evidence offering" - livestock["description"] = ( - "Livestock movement-control predicates for permit checks." - ) - livestock["public_services"] = ["livestock-movement-control"] - livestock["purposes"] = [ - "https://id.registrystack.org/solmara/purpose/livestock-movement-control" - ] - livestock["semantics"]["concepts"] = ["https://publicschema.org/livestock-type"] - offerings.append(livestock) - - collection_offering_id = "solmara.child-benefit.authority-predicate-collection" - if not any(offering["id"] == collection_offering_id for offering in offerings): - offerings.append(child_benefit_collection_offering(authorities)) + datasets_by_id = {dataset["id"]: dataset for dataset in datasets} + offerings = [ + authority_evidence_offering(spec, datasets_by_id, authorities) + for spec in EVIDENCE_OFFERING_SPECS + ] policies = [policy_for_offering(offering) for offering in offerings] catalog = { @@ -310,7 +389,7 @@ def normalize_dataset( dataset: dict[str, Any], fragment_index: dict[str, dict[str, Any]] ) -> dict[str, Any]: fragment = fragment_index.get(dataset["id"], {}) - defaults = DATASET_OFFERING_DEFAULTS.get(dataset["id"], {}) + defaults = DATASET_DEFAULTS.get(dataset["id"], {}) entities = [ normalize_entity(item, fragment.get("purposes", []), defaults) for item in dataset.get("entities", []) @@ -326,7 +405,6 @@ def normalize_dataset( "application_profiles": fragment.get("application_profiles", []), "purposes": fragment.get("purposes", []), "entities": entities, - "source_evidence_offerings": dataset.get("evidence_offerings", []), } @@ -357,123 +435,41 @@ def normalize_entity( } -def normalize_source_offering( - dataset: dict[str, Any], offering: dict[str, Any], authorities: dict[str, Any] -) -> dict[str, Any]: - default = synthetic_offering( - dataset, - authorities, - evidence_type=offering.get("evidence_type"), - entity=offering.get("entity"), - ) - policy = offering.get("policy") if isinstance(offering.get("policy"), dict) else {} - default.update( - { - "id": offering["id"], - "iri": offering.get("iri", default["iri"]), - "title": text(offering.get("title"), default["title"]), - "description": text(offering.get("description"), default["description"]), - "lookup_keys": offering.get("lookup_keys", []), - "public_services": offering.get( - "procedure_contexts", default["public_services"] - ), - "access": offering.get("access", default["access"]), - "purposes": policy.get("purpose", dataset["purposes"]), - } - ) - default["policy"] = f"{default['id']}-policy" - return default - - -def synthetic_offering( - dataset: dict[str, Any], +def authority_evidence_offering( + spec: dict[str, Any], + datasets: dict[str, dict[str, Any]], authorities: dict[str, Any], - *, - evidence_type: str | None = None, - entity: str | None = None, ) -> dict[str, Any]: - defaults = DATASET_OFFERING_DEFAULTS.get(dataset["id"], {}) - evidence_type = evidence_type or defaults.get( - "evidence_type", f"{dataset['id']}-evidence" - ) - entity = entity or defaults.get("entity") or dataset["entities"][0]["name"] - authority = dataset.get("authority", {}) - authority_id = authority.get("id", dataset["id"]) - endpoint = defaults.get( - "endpoint", "https://metadata.solmara.registrystack.org/v1/evaluations" - ) - discovery = defaults.get( - "discovery", - "https://metadata.solmara.registrystack.org/.well-known/evidence-service", - ) - offering_id = f"{dataset['id']}-{evidence_type.replace('-evidence', '')}-offering" - purposes = defaults.get("purposes", dataset.get("purposes", [])) - return { - "id": offering_id, - "iri": f"https://id.registrystack.org/solmara/evidence-offerings/{slug(offering_id)}", - "title": f"{dataset['title']} evidence offering", - "description": f"Purpose-limited evidence predicates from {dataset['title']}.", - "dataset": dataset["id"], - "entity": entity, - "evidence_type": evidence_type, - "issuing_authority": authorities.get(authority_id, authority), - "lookup_keys": ["uin"] - if entity not in {"farmer_voucher", "livestock_movement"} - else ["farmer_id"], - "public_services": [defaults.get("service", "citizen-self-service")], - "access": { - "kind": "evidence-verification-api", - "conforms_to": "https://spec.openapis.org/oas/v3.1.0", - "endpoint_url": endpoint, - "discovery_url": discovery, - }, - "purposes": purposes, - "semantics": { - "concepts": defaults.get( - "concepts", - ["https://id.registrystack.org/solmara/semantics/registry-record"], - ), - "application_profiles": ["cpsv-ap"], - }, - "policy": f"{offering_id}-policy", - } - - -def child_benefit_collection_offering(authorities: dict[str, Any]) -> dict[str, Any]: - offering_id = "solmara.child-benefit.authority-predicate-collection" + dataset = datasets[spec["dataset"]] + authority = authorities[spec["authority"]] + base_url = AUTHORITY_EVIDENCE_URLS[spec["authority"]] return { - "id": offering_id, - "iri": "https://id.registrystack.org/solmara/evidence-offerings/child-benefit-authority-predicate-collection", - "title": "Child Benefit Authority Predicate Collection", + "id": spec["id"], + "iri": f"https://id.registrystack.org/solmara/evidence-offerings/{spec['id']}", + "title": f"{authority['name']} {humanize(spec['evidence_type'])}", "description": ( - "A transient collection of source-owned child benefit predicates. " - "It contains no copied source rows and no composed eligibility decision." + f"An independently signed {spec['source']} assertion issued by " + f"{authority['name']}." ), - "dataset": "mosd-programme", - "entity": "enrollment", - "evidence_type": "child-benefit-authority-predicate-collection-evidence", - "issuing_authority": authorities["mosd-programme-mis"], - "lookup_keys": ["uin"], - "public_services": ["child-benefit-review"], + "dataset": dataset["id"], + "entity": spec["entity"], + "evidence_type": spec["evidence_type"], + "issuing_authority": authority, + "lookup_keys": spec["lookup_keys"], + "public_services": [spec["service"]], "access": { - "kind": "authority-predicate-collection-api", - "conforms_to": "https://id.registrystack.org/solmara/contracts/authority-predicate-collection/v1", - "endpoint_url": f"{CHILD_BENEFIT_FEDERATOR_URL}/v1/evaluations", - "discovery_url": f"{CHILD_BENEFIT_FEDERATOR_URL}/v1/claims", - "media_type": CHILD_BENEFIT_COLLECTION_MEDIA_TYPE, - "ruleset": "source-owned-child-benefit-predicates-v1", + "kind": "evidence-verification-api", + "conforms_to": "https://id.registrystack.org/spec/registry-evidence/v1", + "endpoint_url": f"{base_url}/v1/evidence", + "discovery_url": f"{base_url}/v1/evidence-definitions", + "source_type": spec["source"], }, - "purposes": [CHILD_BENEFIT_PURPOSE], + "purposes": spec["purposes"], "semantics": { - "concepts": [ - "https://publicschema.org/Person", - "https://publicschema.org/crvs/Birth", - "https://publicschema.org/Household", - "https://publicschema.org/sp/Enrollment", - ], + "concepts": spec["concepts"], "application_profiles": ["cpsv-ap"], }, - "policy": "solmara-child-benefit-authority-predicate-collection-policy", + "policy": f"{spec['id']}-policy", } diff --git a/scripts/publish-relay-sources.sh b/scripts/publish-relay-sources.sh new file mode 100755 index 0000000..5da6e51 --- /dev/null +++ b/scripts/publish-relay-sources.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +publisher_image='python:3.12-slim-trixie@sha256:229a2c5bfa27522db7815ea81f9bed70af17ccb9de9fc7ad142b1877b5830d36' + +mkdir -p "$root/output/sqlite/relay" +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + --network none \ + --read-only \ + --tmpfs /tmp \ + --volume "$root:/workspace:ro" \ + --volume "$root/output/sqlite/relay:/workspace/output/sqlite/relay" \ + --workdir /workspace/generator \ + "$publisher_image" \ + python -c 'from pathlib import Path; from solmara_lab.publisher import publish_relay_sources; publish_relay_sources(Path(".."))' + diff --git a/scripts/publish-runtime-extracts.py b/scripts/publish-runtime-extracts.py new file mode 100755 index 0000000..61cd9b4 --- /dev/null +++ b/scripts/publish-runtime-extracts.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Publish and bind fresh immutable extracts in the generated local runtime.""" + +from __future__ import annotations + +import argparse +import importlib +import json +import os +import re +import stat +import sys +import tempfile +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "generator")) +publisher = importlib.import_module("solmara_lab.publisher") + +AUTHORITIES = { + "cra": ("cra-birth-extract", "cra-birth"), + "nia": ("nia-population-extract", "nia-population"), + "sro": ("sro-poverty-extract", "sro-poverty"), +} +RUNTIME_DIRECTORY = Path("runtime/evidence-cells/cells") + + +class RuntimeExtractError(RuntimeError): + """Raised when runtime publication or binding cannot be completed safely.""" + + +def current_publication_time() -> str: + return publisher.canonical_published_at(datetime.now(UTC).isoformat()) + + +def _runtime_path(root: Path, authority: str) -> Path: + return root / RUNTIME_DIRECTORY / authority / "runtime.yaml" + + +def _load_binding(runtime_path: Path, authority: str) -> tuple[str, str]: + profile, prefix = AUTHORITIES[authority] + try: + document = yaml.safe_load(runtime_path.read_text(encoding="utf-8")) + source_extracts = document["sourceExtracts"] + if set(source_extracts) != {profile}: + raise RuntimeExtractError("generated runtime has an unexpected extract profile") + binding = source_extracts[profile] + if set(binding) != {"path"} or not isinstance(binding["path"], str): + raise RuntimeExtractError("generated runtime has an invalid extract binding") + except RuntimeExtractError: + raise + except (KeyError, OSError, TypeError, UnicodeError, yaml.YAMLError): + raise RuntimeExtractError("generated runtime cannot be validated") from None + + container_path = PurePosixPath(binding["path"]) + expected_parent = PurePosixPath( + f"/var/lib/registry-evidence/{authority}/extracts" + ) + if ( + container_path.parent != expected_parent + or not container_path.name.startswith(prefix + "-") + or not container_path.name.endswith(".sqlite") + ): + raise RuntimeExtractError("generated runtime has an invalid extract binding") + return binding["path"], container_path.stem + + +def _render_binding( + runtime_path: Path, old_container_path: str, new_container_path: str +) -> bytes: + original = runtime_path.read_text(encoding="utf-8") + pattern = re.compile(rf"(? None: + directory = path.parent + original_directory_mode = stat.S_IMODE(directory.stat().st_mode) + original_file_mode = stat.S_IMODE(path.stat().st_mode) + directory.chmod(original_directory_mode | stat.S_IWUSR) + temporary: Path | None = None + try: + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=directory + ) + temporary = Path(temporary_name) + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(original_file_mode) + os.replace(temporary, path) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + directory.chmod(original_directory_mode) + + +def prepare_runtime_extracts( + root: Path = ROOT, published_at: str | None = None +) -> dict[str, dict[str, str]]: + """Select fresh extracts, publish missing replacements, and bind generated cells.""" + + root = root.resolve() + publication_time = publisher.canonical_published_at( + published_at if published_at is not None else current_publication_time() + ) + planned: dict[str, dict[str, object]] = {} + + # Validate every existing binding and every possible target before writing + # any file. A malformed or writable extract therefore leaves all bindings + # on their previously reviewed values. + for authority in AUTHORITIES: + runtime_path = _runtime_path(root, authority) + old_container_path, bound_extract_id = _load_binding(runtime_path, authority) + bound_path = publisher.extract_path(root, bound_extract_id) + selected_id = bound_extract_id + selected_path = bound_path + status = "reused" + if bound_path.exists() or bound_path.is_symlink(): + try: + publisher.validate_extract( + bound_path, + authority, + observed_at=publication_time, + expected_extract_id=bound_extract_id, + ) + except publisher.StaleExtractError: + status = "published" + except publisher.ExtractValidationError as error: + raise RuntimeExtractError( + f"{authority} bound extract failed validation: {error}" + ) from None + else: + status = "published" + + if status == "published": + selected_id = publisher.timestamped_extract_id( + authority, publication_time + ) + selected_path = publisher.extract_path(root, selected_id) + if selected_path.exists() or selected_path.is_symlink(): + try: + publisher.validate_extract( + selected_path, + authority, + observed_at=publication_time, + expected_extract_id=selected_id, + expected_published_at=publication_time, + ) + except publisher.ExtractValidationError as error: + raise RuntimeExtractError( + f"{authority} publication target failed validation: {error}" + ) from None + status = "recovered" + + new_container_path = str( + PurePosixPath(old_container_path).with_name(selected_path.name) + ) + planned[authority] = { + "runtime_path": runtime_path, + "old_container_path": old_container_path, + "new_container_path": new_container_path, + "selected_id": selected_id, + "selected_path": selected_path, + "status": status, + } + + rendered = { + authority: _render_binding( + plan["runtime_path"], + str(plan["old_container_path"]), + str(plan["new_container_path"]), + ) + for authority, plan in planned.items() + if plan["old_container_path"] != plan["new_container_path"] + } + + for authority, plan in planned.items(): + if plan["status"] != "published": + continue + selected_path = publisher.publish_extract( + root, authority, publication_time, str(plan["selected_id"]) + ) + publisher.validate_extract( + selected_path, + authority, + observed_at=publication_time, + expected_extract_id=str(plan["selected_id"]), + expected_published_at=publication_time, + ) + + for authority, content in rendered.items(): + _replace_read_only_file(Path(planned[authority]["runtime_path"]), content) + + return { + authority: { + "extractId": str(plan["selected_id"]), + "path": str(Path(plan["selected_path"]).relative_to(root)), + "status": str(plan["status"]), + } + for authority, plan in planned.items() + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Publish and bind fresh immutable Evidence extracts" + ) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument( + "--published-at", + help="explicit RFC 3339 publication time; defaults to the current UTC time", + ) + args = parser.parse_args() + result = prepare_runtime_extracts(args.root, args.published_at) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/registry-projects.sh b/scripts/registry-projects.sh deleted file mode 100755 index b92ed15..0000000 --- a/scripts/registry-projects.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/bin/sh -set -eu - -ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -VERSION_FILE="$ROOT/versions.env" -REGISTRYCTL=${REGISTRYCTL_BIN:-} - -required_version=$(sed -n 's/^REGISTRYCTL_VERSION=//p' "$VERSION_FILE") -if [ -z "$required_version" ]; then - echo "versions.env must set REGISTRYCTL_VERSION" >&2 - exit 1 -fi - -if [ -z "$REGISTRYCTL" ]; then - REGISTRYCTL=$("$ROOT/scripts/registryctl-pinned.sh" path) -fi -actual_version=$("$REGISTRYCTL" --version 2>/dev/null || true) -if [ "$actual_version" != "registryctl $required_version" ]; then - echo "registryctl $required_version is required; got ${actual_version:-no executable}" >&2 - echo "set REGISTRYCTL_BIN to the matching release binary when it is not on PATH" >&2 - exit 1 -fi -for command in check test build capabilities; do - if ! "$REGISTRYCTL" "$command" --help >/dev/null 2>&1; then - echo "registryctl $required_version with project-authoring check/test/build/capabilities is required" >&2 - echo "set REGISTRYCTL_BIN to a compatible Registry Stack build" >&2 - exit 1 - fi -done -if ! "$REGISTRYCTL" authoring editor --help >/dev/null 2>&1; then - echo "registryctl $required_version with project-authoring editor setup is required" >&2 - echo "set REGISTRYCTL_BIN to a compatible Registry Stack build" >&2 - exit 1 -fi - -projects=" -cra-civil -nia-population -sro-social -mosd-programme -sipf-pensions -nagdi-agriculture -" - -build_project_output() { - project=$1 - environment=$2 - project_directory="$ROOT/projects/$project" - echo "registryctl build: $project ($environment)" >&2 - build_report=$( - "$REGISTRYCTL" build \ - --project-dir "$project_directory" \ - --environment "$environment" \ - --format json - ) - printf '%s\n' "$build_report" | - python3 "$ROOT/scripts/registryctl-build-output.py" \ - --project-dir "$project_directory" \ - --environment "$environment" -} - -build_projects() { - environment=$1 - for project in $projects; do - build_project_output "$project" "$environment" - done -} - -check_projects() { - detail=$1 - for project in $projects; do - for environment in local hosted; do - echo "registryctl check: $project ($environment)" - if [ "$detail" = "explain" ]; then - "$REGISTRYCTL" check \ - --project-dir "$ROOT/projects/$project" \ - --environment "$environment" \ - --explain - else - "$REGISTRYCTL" check \ - --project-dir "$ROOT/projects/$project" \ - --environment "$environment" - fi - done - done -} - -inspect_capabilities() { - for project in $projects; do - for environment in local hosted; do - echo "registryctl capabilities: $project ($environment)" - "$REGISTRYCTL" capabilities \ - --project-dir "$ROOT/projects/$project" \ - --environment "$environment" - done - done -} - -sync_editor_support() { - for project in $projects; do - echo "registryctl authoring editor: $project" - "$REGISTRYCTL" authoring editor \ - --project-dir "$ROOT/projects/$project" - done -} - -stage_runtime() { - destination=$1 - for environment in local hosted; do - for project in $projects; do - build_root=$(build_project_output "$project" "$environment") - source="$build_root/private" - target="$destination/$environment/$project" - mkdir -p "$target/relay" "$target/notary" - cp -R "$source/relay/config/." "$target/relay/" - cp "$source/notary/config/notary.yaml" "$target/notary/notary.yaml" - done - done - chmod -R u=rwX,go=rX "$destination" -} - -action=${1:-} -case "$action" in - test) - for project in $projects; do - echo "registryctl test: $project" - "$REGISTRYCTL" test \ - --project-dir "$ROOT/projects/$project" \ - --format json | - python3 "$ROOT/scripts/registryctl-test-output.py" - done - ;; - check) - check_projects concise - ;; - review) - check_projects explain - ;; - capabilities) - inspect_capabilities - ;; - editor) - sync_editor_support - ;; - build) - environment=${2:-} - case "$environment" in - local | hosted) ;; - *) - echo "usage: $0 build " >&2 - exit 2 - ;; - esac - build_projects "$environment" - ;; - sync-runtime) - temporary=$(mktemp -d "${TMPDIR:-/tmp}/solmara-registry-runtime.XXXXXX") - trap 'rm -rf "$temporary"' EXIT HUP INT TERM - stage_runtime "$temporary/registry-projects" - target="$ROOT/runtime/registry-projects" - rm -rf "$target" - mkdir -p "$(dirname "$target")" - mv "$temporary/registry-projects" "$target" - ;; - check-runtime) - temporary=$(mktemp -d "${TMPDIR:-/tmp}/solmara-registry-runtime.XXXXXX") - trap 'rm -rf "$temporary"' EXIT HUP INT TERM - stage_runtime "$temporary/registry-projects" - diff -ruN "$ROOT/runtime/registry-projects" "$temporary/registry-projects" - ;; - *) - echo "usage: $0 |sync-runtime|check-runtime>" >&2 - exit 2 - ;; -esac diff --git a/scripts/registryctl-build-output.py b/scripts/registryctl-build-output.py deleted file mode 100755 index 5c805ed..0000000 --- a/scripts/registryctl-build-output.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -"""Validate a registryctl build report and print its generated output root.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any - - -REPORT_SCHEMA = "registryctl.project_command.v1" -MAX_REPORT_BYTES = 8 * 1024 * 1024 - - -class BuildReportError(ValueError): - """A safe validation error for a registryctl build report.""" - - -def reject_duplicate_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise BuildReportError("the JSON report contains a duplicate field") - result[key] = value - return result - - -def parse_build_output( - raw: bytes, - *, - project_directory: Path, - environment: str, -) -> Path: - if not raw: - raise BuildReportError("registryctl returned an empty report") - if len(raw) > MAX_REPORT_BYTES: - raise BuildReportError("the JSON report exceeds the size limit") - try: - report = json.loads(raw, object_pairs_hook=reject_duplicate_fields) - except (json.JSONDecodeError, UnicodeDecodeError, RecursionError) as error: - raise BuildReportError("registryctl did not return strict UTF-8 JSON") from error - if not isinstance(report, dict): - raise BuildReportError("the JSON report is not an object") - if report.get("schema_version") != REPORT_SCHEMA: - raise BuildReportError("the JSON report has an unsupported schema") - if report.get("status") != "built": - raise BuildReportError("registryctl did not report a completed build") - if report.get("environment") != environment: - raise BuildReportError("the JSON report has the wrong environment binding") - if not isinstance(report.get("project"), str) or not report["project"]: - raise BuildReportError("the JSON report has no project identity") - - output_value = report.get("output") - if not isinstance(output_value, str) or not output_value: - raise BuildReportError("the JSON report has no output root") - output = Path(output_value) - - try: - project_root = project_directory.resolve(strict=True) - candidate = output if output.is_absolute() else project_root / output - output_root = candidate.resolve(strict=True) - output_root.relative_to(project_root) - except (FileNotFoundError, RuntimeError, ValueError) as error: - raise BuildReportError( - "the JSON report output root is not a real project-owned directory" - ) from error - if not output_root.is_dir(): - raise BuildReportError("the JSON report output root is not a directory") - - relay_config = output_root / "private" / "relay" / "config" - notary_config = output_root / "private" / "notary" / "config" / "notary.yaml" - if not relay_config.is_dir() or not notary_config.is_file(): - raise BuildReportError( - "the generated Relay or Notary configuration closure is incomplete" - ) - for required in (relay_config, notary_config): - try: - required.resolve(strict=True).relative_to(output_root) - except (FileNotFoundError, RuntimeError, ValueError) as error: - raise BuildReportError( - "a generated configuration path escapes the build output root" - ) from error - - return output_root - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--project-dir", required=True, type=Path) - parser.add_argument("--environment", required=True) - args = parser.parse_args(argv) - raw = sys.stdin.buffer.read(MAX_REPORT_BYTES + 1) - try: - output = parse_build_output( - raw, - project_directory=args.project_dir, - environment=args.environment, - ) - except BuildReportError as error: - print(f"registryctl build report invalid: {error}", file=sys.stderr) - return 1 - print(output) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/registryctl-pinned.sh b/scripts/registryctl-pinned.sh deleted file mode 100755 index c813a48..0000000 --- a/scripts/registryctl-pinned.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/sh -set -eu - -ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -VERSION_FILE="$ROOT/versions.env" - -version=$(sed -n 's/^REGISTRYCTL_VERSION=//p' "$VERSION_FILE") -if [ -z "$version" ]; then - echo "versions.env must set REGISTRYCTL_VERSION" >&2 - exit 1 -fi - -verify_version() { - actual=$("$1" --version 2>/dev/null || true) - [ "$actual" = "registryctl $version" ] -} - -if [ -n "${REGISTRYCTL_BIN:-}" ]; then - if ! verify_version "$REGISTRYCTL_BIN"; then - echo "REGISTRYCTL_BIN must point to registryctl $version" >&2 - exit 1 - fi - registryctl=$REGISTRYCTL_BIN -elif command -v registryctl >/dev/null 2>&1 && verify_version "$(command -v registryctl)"; then - registryctl=$(command -v registryctl) -else - case "$(uname -s):$(uname -m)" in - Linux:x86_64) platform=linux-amd64 ;; - Linux:aarch64 | Linux:arm64) platform=linux-arm64 ;; - Darwin:arm64) platform=macos-arm64 ;; - *) - echo "registryctl $version has no published binary for $(uname -s) $(uname -m)" >&2 - echo "set REGISTRYCTL_BIN to a verified compatible binary" >&2 - exit 1 - ;; - esac - - asset="registryctl-v${version}-${platform}" - tools="$ROOT/output/tools" - registryctl="$tools/$asset" - if ! verify_version "$registryctl"; then - mkdir -p "$tools" - temporary=$(mktemp -d "${TMPDIR:-/tmp}/solmara-registryctl.XXXXXX") - trap 'rm -rf "$temporary"' EXIT HUP INT TERM - base="https://github.com/registrystack/registry-stack/releases/download/v${version}" - curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ - "$base/SHA256SUMS" -o "$temporary/SHA256SUMS" - curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ - "$base/$asset" -o "$temporary/$asset" - expected=$(awk -v asset="$asset" '$2 == asset { print $1 }' "$temporary/SHA256SUMS") - if [ -z "$expected" ]; then - echo "SHA256SUMS does not cover $asset" >&2 - exit 1 - fi - if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$temporary/$asset" | awk '{print $1}') - else - actual=$(shasum -a 256 "$temporary/$asset" | awk '{print $1}') - fi - if [ "$actual" != "$expected" ]; then - echo "downloaded $asset failed its published SHA-256 check" >&2 - exit 1 - fi - chmod 0755 "$temporary/$asset" - mv "$temporary/$asset" "$registryctl" - if ! verify_version "$registryctl"; then - echo "downloaded $asset did not report registryctl $version" >&2 - exit 1 - fi - fi -fi - -case "${1:-}" in - path) - [ "$#" -eq 1 ] || { echo "usage: $0 path" >&2; exit 2; } - printf '%s\n' "$registryctl" - ;; - run) - shift - [ "$#" -gt 0 ] || { echo "usage: $0 run " >&2; exit 2; } - exec "$registryctl" "$@" - ;; - *) - echo "usage: $0 >" >&2 - exit 2 - ;; -esac diff --git a/scripts/registryctl-test-output.py b/scripts/registryctl-test-output.py deleted file mode 100755 index 4e15ddf..0000000 --- a/scripts/registryctl-test-output.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -"""Validate a registryctl test report and require governed request witnesses.""" - -from __future__ import annotations - -import json -import sys -from typing import Any - - -REPORT_SCHEMA = "registryctl.project_command.v1" -REQUEST_BINDING_REQUIREMENT = "request_to_consultation_binding" -MAX_REPORT_BYTES = 16 * 1024 * 1024 - - -class TestReportError(ValueError): - """A safe validation error for a registryctl test report.""" - - -def reject_duplicate_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise TestReportError("the JSON report contains a duplicate field") - result[key] = value - return result - - -def validate_test_report(raw: bytes) -> tuple[str, int, int]: - if not raw: - raise TestReportError("registryctl returned an empty report") - if len(raw) > MAX_REPORT_BYTES: - raise TestReportError("the JSON report exceeds the size limit") - try: - report = json.loads(raw, object_pairs_hook=reject_duplicate_fields) - except (json.JSONDecodeError, UnicodeDecodeError, RecursionError) as error: - raise TestReportError("registryctl did not return strict UTF-8 JSON") from error - if not isinstance(report, dict): - raise TestReportError("the JSON report is not an object") - if report.get("schema_version") != REPORT_SCHEMA: - raise TestReportError("the JSON report has an unsupported schema") - if report.get("status") != "passed": - raise TestReportError("registryctl did not report a passing fixture suite") - project = report.get("project") - if not isinstance(project, str) or not project: - raise TestReportError("the JSON report has no project identity") - - fixtures = report.get("fixtures") - if ( - not isinstance(fixtures, list) - or not fixtures - or any( - not isinstance(fixture, dict) or fixture.get("passed") is not True - for fixture in fixtures - ) - ): - raise TestReportError("the JSON report contains a non-passing fixture") - - coverage = report.get("fixture_coverage") - targets = coverage.get("targets") if isinstance(coverage, dict) else None - if not isinstance(targets, list) or not targets: - raise TestReportError("the JSON report has no fixture coverage targets") - for target in targets: - requirements = target.get("requirements") if isinstance(target, dict) else None - if not isinstance(requirements, list): - raise TestReportError("a fixture coverage target has no requirements") - bindings = [ - requirement - for requirement in requirements - if isinstance(requirement, dict) - and requirement.get("requirement") == REQUEST_BINDING_REQUIREMENT - ] - if len(bindings) != 1 or bindings[0].get("state") != "covered": - raise TestReportError( - "every fixture target must cover request-to-consultation binding" - ) - evidence = bindings[0].get("evidence") - if ( - not isinstance(evidence, list) - or not evidence - or any( - not isinstance(item, dict) - or item.get("kind") != "authored_fixture" - for item in evidence - ) - ): - raise TestReportError( - "request-to-consultation binding requires authored fixture evidence" - ) - return project, len(fixtures), len(targets) - - -def main() -> int: - raw = sys.stdin.buffer.read(MAX_REPORT_BYTES + 1) - try: - project, fixture_count, target_count = validate_test_report(raw) - except TestReportError as error: - print(f"registryctl test report invalid: {error}", file=sys.stderr) - return 1 - print( - f"PASS: {project}: {fixture_count}/{fixture_count} fixtures passed; " - f"request witnesses cover {target_count}/{target_count} targets" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/relay_workload_identity_agent.py b/scripts/relay_workload_identity_agent.py deleted file mode 100755 index a6b7174..0000000 --- a/scripts/relay_workload_identity_agent.py +++ /dev/null @@ -1,755 +0,0 @@ -#!/usr/bin/env python3 -"""Issue short-lived Relay workload tokens for co-located consumers. - -The agent binds only to IPv4 loopback. It publishes the public half of every -configured Ed25519 JWK from one endpoint and atomically maintains one access -token file per identity. Tokens may identify either the local loopback issuer -or a separately published HTTPS issuer whose public-only JWKS contains the same -keys. Private JWK values are read indirectly: each bounded -WORKLOAD_IDENTITIES_JSON entry names the environment variable containing its -JWK JSON. -""" - -from __future__ import annotations - -import base64 -import binascii -import hashlib -import ipaddress -import json -import os -import re -import secrets -import signal -import stat -import sys -import tempfile -import threading -import time -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any, Never, cast -from urllib.parse import urlsplit - -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat - - -LOOPBACK_HOST = "127.0.0.1" -DEFAULT_PORT = 8090 -DEFAULT_TOKEN_TTL_SECONDS = 300 -DEFAULT_ROTATE_BEFORE_SECONDS = 60 -DEFAULT_ROTATION_INTERVAL_SECONDS = 5 -DEFAULT_TOKEN_UID = 65534 -DEFAULT_TOKEN_GID = 65534 - -MAX_IDENTITIES = 8 -MAX_IDENTITIES_JSON_BYTES = 64 * 1024 -MAX_JWK_BYTES = 16 * 1024 -MAX_TEXT_LENGTH = 256 -MAX_PATH_LENGTH = 4096 -MAX_SCOPE_COUNT = 32 -MAX_SCOPE_LENGTH = 256 -MAX_TOKEN_TTL_SECONDS = 900 -MAX_TOKEN_FILE_BYTES = 16 * 1024 -MAX_ROTATION_INTERVAL_SECONDS = 60 -MAX_ID = 2_147_483_647 - -ENV_NAME_PATTERN = re.compile(r"[A-Z][A-Z0-9_]{0,127}\Z") -TOKEN_VALUE_PATTERN = re.compile(r"[\x21-\x7e]+\Z") -BASE64URL_PATTERN = re.compile(r"[A-Za-z0-9_-]+\Z") -DNS_NAME_PATTERN = re.compile( - r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+" - r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z" -) - - -class ConfigurationError(ValueError): - """Raised when workload identity configuration is unsafe or incomplete.""" - - -def _reject_constant(_value: str) -> Never: - raise ConfigurationError("JSON contains a non-finite number") - - -def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise ConfigurationError("JSON contains a duplicate field") - result[key] = value - return result - - -def _required_text( - environ: Mapping[str, str], - name: str, - *, - maximum: int = MAX_TEXT_LENGTH, -) -> str: - value = environ.get(name) - if value is None or not value or len(value) > maximum: - raise ConfigurationError(f"{name} is missing or outside its size bound") - return value - - -def _bounded_integer( - environ: Mapping[str, str], - name: str, - default: int, - *, - minimum: int, - maximum: int, -) -> int: - raw = environ.get(name, str(default)) - if not raw or len(raw) > 10 or not raw.isascii() or not raw.isdecimal(): - raise ConfigurationError(f"{name} must be a bounded decimal integer") - value = int(raw) - if value < minimum or value > maximum: - raise ConfigurationError(f"{name} is outside its allowed range") - return value - - -def _document_integer( - document: Mapping[str, Any], - field: str, - default: int, - *, - minimum: int, - maximum: int, -) -> int: - value = document.get(field, default) - if isinstance(value, bool) or not isinstance(value, int): - raise ConfigurationError(f"workload identity {field} must be an integer") - if value < minimum or value > maximum: - raise ConfigurationError( - f"workload identity {field} is outside its allowed range" - ) - return value - - -def _document_token(document: Mapping[str, Any], field: str) -> str: - value = document.get(field) - if ( - not isinstance(value, str) - or not value - or len(value) > MAX_TEXT_LENGTH - or TOKEN_VALUE_PATTERN.fullmatch(value) is None - ): - raise ConfigurationError( - f"workload identity {field} must contain bounded visible ASCII without spaces" - ) - return value - - -def _decode_base64url(value: object, field: str) -> bytes: - if not isinstance(value, str) or BASE64URL_PATTERN.fullmatch(value) is None: - raise ConfigurationError(f"private JWK {field} is not base64url") - try: - raw = base64.b64decode( - value + "=" * (-len(value) % 4), altchars=b"-_", validate=True - ) - except (binascii.Error, ValueError) as error: - raise ConfigurationError(f"private JWK {field} is not base64url") from error - if _base64url(raw) != value: - raise ConfigurationError(f"private JWK {field} is not canonical base64url") - return raw - - -def _base64url(value: bytes) -> str: - return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -@dataclass(frozen=True) -class KeyMaterial: - private_key: Ed25519PrivateKey - public_jwk: dict[str, str] - - @classmethod - def from_json(cls, encoded: str) -> KeyMaterial: - if not encoded or len(encoded.encode("utf-8")) > MAX_JWK_BYTES: - raise ConfigurationError("private JWK is missing or outside its size bound") - try: - document = json.loads( - encoded, - object_pairs_hook=_unique_object, - parse_constant=_reject_constant, - ) - except (json.JSONDecodeError, UnicodeError) as error: - raise ConfigurationError("private JWK is not valid JSON") from error - if not isinstance(document, dict): - raise ConfigurationError("private JWK must be a JSON object") - - allowed_fields = {"alg", "crv", "d", "kid", "kty", "use", "x"} - if set(document) - allowed_fields: - raise ConfigurationError("private JWK contains unsupported fields") - if document.get("kty") != "OKP": - raise ConfigurationError("private JWK kty must be OKP") - if document.get("crv") != "Ed25519": - raise ConfigurationError("private JWK crv must be Ed25519") - if document.get("alg") != "EdDSA": - raise ConfigurationError("private JWK alg must be EdDSA") - if document.get("use") not in (None, "sig"): - raise ConfigurationError("private JWK use must be sig when present") - - kid = document.get("kid") - if ( - not isinstance(kid, str) - or not kid - or len(kid) > MAX_TEXT_LENGTH - or TOKEN_VALUE_PATTERN.fullmatch(kid) is None - ): - raise ConfigurationError("private JWK kid is invalid") - - private_bytes = _decode_base64url(document.get("d"), "d") - public_bytes = _decode_base64url(document.get("x"), "x") - if len(private_bytes) != 32 or len(public_bytes) != 32: - raise ConfigurationError("private JWK key material must be 32 bytes") - private_key = Ed25519PrivateKey.from_private_bytes(private_bytes) - derived_public = private_key.public_key().public_bytes( - Encoding.Raw, PublicFormat.Raw - ) - if not secrets.compare_digest(derived_public, public_bytes): - raise ConfigurationError( - "private JWK public and private values do not match" - ) - - public_jwk = { - "alg": "EdDSA", - "crv": "Ed25519", - "kid": kid, - "kty": "OKP", - "x": document["x"], - } - if document.get("use") == "sig": - public_jwk["use"] = "sig" - return cls(private_key=private_key, public_jwk=public_jwk) - - -@dataclass(frozen=True) -class IdentityConfig: - audience: str - azp: str - subject: str - scope: str - token_file: Path - token_uid: int - token_gid: int - key_material: KeyMaterial - - -@dataclass(frozen=True) -class Config: - bind_host: str - port: int - issuer: str - identities: tuple[IdentityConfig, ...] - token_ttl_seconds: int - rotate_before_seconds: int - rotation_interval_seconds: int - - @classmethod - def from_environ(cls, environ: Mapping[str, str]) -> Config: - bind_host = environ.get("WORKLOAD_BIND_HOST", LOOPBACK_HOST) - if bind_host != LOOPBACK_HOST: - raise ConfigurationError("WORKLOAD_BIND_HOST must be IPv4 loopback") - port = _bounded_integer( - environ, "WORKLOAD_PORT", DEFAULT_PORT, minimum=1, maximum=65535 - ) - - try: - issuer = _required_text(environ, "WORKLOAD_ISSUER") - parsed_issuer = urlsplit(issuer) - issuer_port = parsed_issuer.port - except ValueError as error: - raise ConfigurationError("WORKLOAD_ISSUER is not a valid URL") from error - has_unsafe_url_component = ( - bool(parsed_issuer.path) - or bool(parsed_issuer.query) - or bool(parsed_issuer.fragment) - or parsed_issuer.username is not None - or parsed_issuer.password is not None - ) - local_issuer = ( - parsed_issuer.scheme == "http" - and parsed_issuer.hostname == bind_host - and issuer_port == port - and parsed_issuer.netloc == f"{bind_host}:{port}" - and not has_unsafe_url_component - ) - hosted_hostname = parsed_issuer.hostname or "" - try: - ipaddress.ip_address(hosted_hostname) - except ValueError: - hosted_hostname_is_ip = False - else: - hosted_hostname_is_ip = True - hosted_issuer = ( - parsed_issuer.scheme == "https" - and not hosted_hostname_is_ip - and DNS_NAME_PATTERN.fullmatch(hosted_hostname) is not None - and issuer_port in (None, 443) - and parsed_issuer.netloc - in (hosted_hostname, f"{hosted_hostname}:443") - and not has_unsafe_url_component - ) - if not local_issuer and not hosted_issuer: - raise ConfigurationError( - "WORKLOAD_ISSUER must be the exact loopback listener or a " - "path-free HTTPS DNS origin" - ) - - token_ttl_seconds = _bounded_integer( - environ, - "WORKLOAD_TOKEN_TTL_SECONDS", - DEFAULT_TOKEN_TTL_SECONDS, - minimum=30, - maximum=MAX_TOKEN_TTL_SECONDS, - ) - rotate_before_seconds = _bounded_integer( - environ, - "WORKLOAD_ROTATE_BEFORE_SECONDS", - DEFAULT_ROTATE_BEFORE_SECONDS, - minimum=5, - maximum=token_ttl_seconds - 5, - ) - rotation_interval_seconds = _bounded_integer( - environ, - "WORKLOAD_ROTATION_INTERVAL_SECONDS", - DEFAULT_ROTATION_INTERVAL_SECONDS, - minimum=1, - maximum=min(MAX_ROTATION_INTERVAL_SECONDS, rotate_before_seconds), - ) - default_token_uid = _bounded_integer( - environ, - "WORKLOAD_TOKEN_UID", - DEFAULT_TOKEN_UID, - minimum=0, - maximum=MAX_ID, - ) - default_token_gid = _bounded_integer( - environ, - "WORKLOAD_TOKEN_GID", - DEFAULT_TOKEN_GID, - minimum=0, - maximum=MAX_ID, - ) - - encoded_identities = _required_text( - environ, - "WORKLOAD_IDENTITIES_JSON", - maximum=MAX_IDENTITIES_JSON_BYTES, - ) - if len(encoded_identities.encode("utf-8")) > MAX_IDENTITIES_JSON_BYTES: - raise ConfigurationError( - "WORKLOAD_IDENTITIES_JSON is outside its size bound" - ) - try: - identity_documents = json.loads( - encoded_identities, - object_pairs_hook=_unique_object, - parse_constant=_reject_constant, - ) - except (json.JSONDecodeError, UnicodeError) as error: - raise ConfigurationError( - "WORKLOAD_IDENTITIES_JSON is not valid JSON" - ) from error - if ( - not isinstance(identity_documents, list) - or not identity_documents - or len(identity_documents) > MAX_IDENTITIES - ): - raise ConfigurationError( - f"WORKLOAD_IDENTITIES_JSON must contain 1..{MAX_IDENTITIES} identities" - ) - - identities: list[IdentityConfig] = [] - allowed_fields = { - "audience", - "azp", - "private_jwk_env", - "scopes", - "subject", - "token_file", - "token_gid", - "token_uid", - } - for document in identity_documents: - if not isinstance(document, dict): - raise ConfigurationError("each workload identity must be a JSON object") - if set(document) - allowed_fields: - raise ConfigurationError( - "workload identity contains unsupported fields" - ) - - audience = _document_token(document, "audience") - azp = _document_token(document, "azp") - subject = _document_token(document, "subject") - - scopes = document.get("scopes") - if ( - not isinstance(scopes, list) - or not scopes - or len(scopes) > MAX_SCOPE_COUNT - or any( - not isinstance(item, str) - or not item - or len(item) > MAX_SCOPE_LENGTH - or TOKEN_VALUE_PATTERN.fullmatch(item) is None - for item in scopes - ) - or len(set(scopes)) != len(scopes) - ): - raise ConfigurationError( - "workload identity scopes are not a bounded list" - ) - scope = " ".join(scopes) - - token_file_raw = document.get("token_file") - if ( - not isinstance(token_file_raw, str) - or len(token_file_raw) > MAX_PATH_LENGTH - ): - raise ConfigurationError("workload identity token_file is invalid") - token_file = Path(token_file_raw) - if ( - not token_file.is_absolute() - or ".." in token_file.parts - or os.path.normpath(token_file_raw) != token_file_raw - or not token_file.name - or not token_file.parent.is_dir() - ): - raise ConfigurationError( - "workload identity token_file is not a safe target" - ) - - token_uid = _document_integer( - document, - "token_uid", - default_token_uid, - minimum=0, - maximum=MAX_ID, - ) - token_gid = _document_integer( - document, - "token_gid", - default_token_gid, - minimum=0, - maximum=MAX_ID, - ) - key_environment_name = document.get("private_jwk_env") - if ( - not isinstance(key_environment_name, str) - or ENV_NAME_PATTERN.fullmatch(key_environment_name) is None - ): - raise ConfigurationError( - "workload identity private_jwk_env is not a valid variable name" - ) - key_material = KeyMaterial.from_json( - _required_text(environ, key_environment_name, maximum=MAX_JWK_BYTES) - ) - - identities.append( - IdentityConfig( - audience=audience, - azp=azp, - subject=subject, - scope=scope, - token_file=token_file, - token_uid=token_uid, - token_gid=token_gid, - key_material=key_material, - ) - ) - - uniqueness_fields: tuple[tuple[str, list[object]], ...] = ( - ("azp", [identity.azp for identity in identities]), - ("subject", [identity.subject for identity in identities]), - ( - "kid", - [identity.key_material.public_jwk["kid"] for identity in identities], - ), - ( - "public key", - [identity.key_material.public_jwk["x"] for identity in identities], - ), - ("token_file", [identity.token_file for identity in identities]), - ) - for field, values in uniqueness_fields: - if len(set(values)) != len(values): - raise ConfigurationError( - f"workload identity {field} values must be unique" - ) - - return cls( - bind_host=bind_host, - port=port, - issuer=issuer, - identities=tuple(identities), - token_ttl_seconds=token_ttl_seconds, - rotate_before_seconds=rotate_before_seconds, - rotation_interval_seconds=rotation_interval_seconds, - ) - - -def _encode_json(value: object) -> bytes: - return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") - - -def _mint_token( - config: Config, identity: IdentityConfig, issued_at: int -) -> tuple[str, int]: - expires_at = issued_at + config.token_ttl_seconds - header = { - "alg": "EdDSA", - "kid": identity.key_material.public_jwk["kid"], - "typ": "at+jwt", - } - claims = { - "aud": identity.audience, - "azp": identity.azp, - "exp": expires_at, - "iat": issued_at, - "iss": config.issuer, - "jti": secrets.token_urlsafe(18), - "nbf": issued_at, - "scope": identity.scope, - "sub": identity.subject, - } - signing_input = b".".join( - ( - _base64url(_encode_json(header)).encode(), - _base64url(_encode_json(claims)).encode(), - ) - ) - signature = identity.key_material.private_key.sign(signing_input) - return f"{signing_input.decode('ascii')}.{_base64url(signature)}", expires_at - - -def _atomic_write_token(path: Path, token: str, *, uid: int, gid: int) -> None: - target_directory = str(path.parent) - descriptor, temporary_path = tempfile.mkstemp( - dir=target_directory, prefix=f".{path.name}.", suffix=".tmp" - ) - try: - os.fchmod(descriptor, 0o600) - os.fchown(descriptor, uid, gid) - with os.fdopen(descriptor, "wb", closefd=True) as output: - descriptor = -1 - output.write(token.encode("ascii")) - output.write(b"\n") - output.flush() - os.fsync(output.fileno()) - os.replace(temporary_path, path) - temporary_path = "" - directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) - directory_descriptor = os.open(target_directory, directory_flags) - try: - os.fsync(directory_descriptor) - finally: - os.close(directory_descriptor) - finally: - if descriptor >= 0: - os.close(descriptor) - if temporary_path: - try: - os.unlink(temporary_path) - except FileNotFoundError: - pass - - -class IdentityState: - def __init__( - self, config: Config, *, clock: Callable[[], float] = time.time - ) -> None: - self.config = config - self._clock = clock - self._expires_at = [0] * len(config.identities) - self._published_hashes: list[bytes | None] = [None] * len(config.identities) - self._lock = threading.RLock() - - def jwks_document(self) -> dict[str, list[dict[str, str]]]: - return { - "keys": [ - dict(identity.key_material.public_jwk) - for identity in self.config.identities - ] - } - - def rotate(self) -> None: - with self._lock: - issued_at = int(self._clock()) - for index, identity in enumerate(self.config.identities): - token, expires_at = _mint_token(self.config, identity, issued_at) - _atomic_write_token( - identity.token_file, - token, - uid=identity.token_uid, - gid=identity.token_gid, - ) - self._expires_at[index] = expires_at - self._published_hashes[index] = hashlib.sha256( - token.encode("ascii") + b"\n" - ).digest() - - def rotate_if_due(self) -> bool: - with self._lock: - issued_at = int(self._clock()) - rotated = False - for index, identity in enumerate(self.config.identities): - if ( - issued_at - < self._expires_at[index] - self.config.rotate_before_seconds - ): - continue - token, expires_at = _mint_token(self.config, identity, issued_at) - _atomic_write_token( - identity.token_file, - token, - uid=identity.token_uid, - gid=identity.token_gid, - ) - self._expires_at[index] = expires_at - self._published_hashes[index] = hashlib.sha256( - token.encode("ascii") + b"\n" - ).digest() - rotated = True - return rotated - - def ready(self) -> bool: - with self._lock: - now = int(self._clock()) - for index, identity in enumerate(self.config.identities): - if ( - not identity.key_material.public_jwk - or self._expires_at[index] <= now - or self._published_hashes[index] is None - ): - return False - try: - file_status = os.lstat(identity.token_file) - except OSError: - return False - if not ( - stat.S_ISREG(file_status.st_mode) - and stat.S_IMODE(file_status.st_mode) == 0o600 - and file_status.st_size > 0 - and file_status.st_size <= MAX_TOKEN_FILE_BYTES - and file_status.st_uid == identity.token_uid - and file_status.st_gid == identity.token_gid - ): - return False - try: - published = identity.token_file.read_bytes() - except OSError: - return False - if not secrets.compare_digest( - hashlib.sha256(published).digest(), self._published_hashes[index] - ): - return False - return True - - -class AgentHTTPServer(ThreadingHTTPServer): - daemon_threads = True - - def __init__( - self, - server_address: tuple[str, int], - state: IdentityState, - ) -> None: - self.identity_state = state - super().__init__(server_address, AgentRequestHandler) - - -class AgentRequestHandler(BaseHTTPRequestHandler): - server_version = "solmara-workload-identity" - sys_version = "" - - def do_GET(self) -> None: # noqa: N802 - server = cast(AgentHTTPServer, self.server) - if self.path == "/.well-known/jwks.json": - self._send_json(HTTPStatus.OK, server.identity_state.jwks_document()) - return - if self.path == "/health": - ready = server.identity_state.ready() - self._send_json( - HTTPStatus.OK if ready else HTTPStatus.SERVICE_UNAVAILABLE, - {"status": "ready" if ready else "not_ready"}, - ) - return - self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - - def _send_json(self, status_code: HTTPStatus, document: object) -> None: - body = _encode_json(document) - self.send_response(status_code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(body) - - def log_message(self, _format: str, *args: object) -> None: - return - - -def _rotation_loop(state: IdentityState, stop_event: threading.Event) -> None: - while not stop_event.wait(state.config.rotation_interval_seconds): - try: - state.rotate_if_due() - except Exception: - # Health becomes not ready when any last published token expires or - # is no longer a correctly owned, nonempty regular file. - continue - - -def serve(config: Config) -> None: - state = IdentityState(config) - state.rotate() - stop_event = threading.Event() - server = AgentHTTPServer((config.bind_host, config.port), state) - rotation_thread = threading.Thread( - target=_rotation_loop, - args=(state, stop_event), - name="token-rotation", - daemon=True, - ) - rotation_thread.start() - - def request_stop(_signal_number: int, _frame: object) -> None: - stop_event.set() - threading.Thread(target=server.shutdown, daemon=True).start() - - signal.signal(signal.SIGTERM, request_stop) - signal.signal(signal.SIGINT, request_stop) - try: - server.serve_forever(poll_interval=0.25) - finally: - stop_event.set() - server.server_close() - rotation_thread.join(timeout=config.rotation_interval_seconds + 1) - - -def main() -> int: - try: - config = Config.from_environ(os.environ) - serve(config) - except ConfigurationError as error: - print( - f"workload identity agent configuration rejected: {error}", file=sys.stderr - ) - return 2 - except Exception: - print( - "workload identity agent stopped after an operational failure", - file=sys.stderr, - ) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/review.sh b/scripts/review.sh deleted file mode 100755 index e666dcd..0000000 --- a/scripts/review.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -if [ "$#" -ne 0 ]; then - echo "usage: scripts/review.sh" >&2 - exit 2 -fi - -"$root/scripts/check-fiction.sh" -"$root/scripts/check-image-pins.py" -"$root/scripts/check-config-secrets.py" -uv run --project "$root" "$root/scripts/publish-metadata.py" --check -uv run --project "$root" "$root/scripts/metadata-lint.py" - -if git -C "$root" grep -I -n \ - -E "(BEGIN (RSA|OPENSSH|EC|PRIVATE) KEY|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9_]{20,})" \ - -- .; then - echo "Potential secret material found in repository files." >&2 - exit 1 -fi - -cat <<'CHECKLIST' -Security checklist for reviewer: -1. Purpose-gated Relay entities require Data-Purpose where sensitive data is exposed. -2. Raw tokens appear only in generated .env, never committed configs. -3. Notary source connections use token_env and scoped Relay tokens. -4. Redactable fields are not disclosed through predicate channels. -5. Run `just smoke` against the live stack: its child-benefit application gate verifies authority-owned - predicate success, unsupported-purpose denial, non-composition, and rejection of raw-source results. -6. Audit hash secrets are environment-backed. -7. Fiction lint is green. -CHECKLIST diff --git a/scripts/seed-esignet.py b/scripts/seed-esignet.py index 523de92..ae185a5 100755 --- a/scripts/seed-esignet.py +++ b/scripts/seed-esignet.py @@ -236,14 +236,9 @@ def main() -> int: seed_esignet(jwk, key_hash) clear_esignet_client_cache() account_source = os.environ.get("ESIGNET_ACCOUNT_SOURCE_LABEL", "NIA population Relay profile solmara-nia-userinfo") - demo_subject = os.environ.get("ESIGNET_DEMO_SUBJECT") - demo_otp = os.environ.get("ESIGNET_DEMO_OTP", "111111") print(f"Seeded eSignet client {CLIENT_ID}.") print(f"Relay-backed account source: {account_source}.") - if demo_subject: - print(f"Demo subject available through Relay: {demo_subject}. Local static OTP: {demo_otp}.") - else: - print(f"Local static OTP: {demo_otp}.") + print("Demo credentials are configured without being written to logs.") if os.environ.get("ESIGNET_SEED_STAY_READY") == "true": Path("/tmp/ready").write_text("ready\n", encoding="utf-8") while True: diff --git a/scripts/smoke-child-benefit-application.py b/scripts/smoke-child-benefit-application.py deleted file mode 100755 index 91bcb83..0000000 --- a/scripts/smoke-child-benefit-application.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke child-benefit application composition over authority Notaries. - -The legacy-named child-benefit service is an application evidence collector, -not a Notary. It uses the ordinary Registry Notary HTTP API to collect -minimized predicates from the CRA, NIA, SRO, and Programme authority Notaries. -""" - -from __future__ import annotations - -import json -import os -import shlex -import sys -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from scenarios.common import ( # noqa: E402 - CHILD_BENEFIT_AS_OF_DATE, - PURPOSES, - auth_headers, - evaluation_body, - http_json, - joined_url, -) - - -POSITIVE_SUBJECT = "2300010248" -APPLICATION_URL_ENV = "CHILD_BENEFIT_FEDERATOR_URL" -APPLICATION_URL_DEFAULT = "http://127.0.0.1:4321" -APPLICATION_TOKEN_ENV = "CHILD_BENEFIT_FEDERATOR_TOKEN" -UNSUPPORTED_PURPOSE = "https://id.registrystack.org/solmara/purpose/unsupported-application-smoke" -EXPECTED_CLAIM_OWNERS = { - "birth-is-registered": "cra-notary", - "child-age-under-5": "cra-notary", - "population-record-active": "nia-notary", - "household-below-poverty-threshold": "sro-notary", - "not-already-enrolled": "programme-notary", -} - - -def main() -> int: - load_dotenv(ROOT / ".env") - token = os.environ.get(APPLICATION_TOKEN_ENV, "") - if not token: - print( - f"smoke-child-benefit-application: missing {APPLICATION_TOKEN_ENV}; run `just generate` before live smoke", - file=sys.stderr, - ) - return 1 - - base_url = os.environ.get(APPLICATION_URL_ENV, APPLICATION_URL_DEFAULT) - claims = list(EXPECTED_CLAIM_OWNERS) - failures = application_evidence_failures(base_url, token, POSITIVE_SUBJECT, claims) - failures.extend(wrong_purpose_failures(base_url, token, POSITIVE_SUBJECT)) - failures.extend(raw_household_denial_failures(base_url, token, POSITIVE_SUBJECT)) - if failures: - for failure in failures: - print(f"smoke-child-benefit-application: {failure}", file=sys.stderr) - return 1 - - print( - "smoke-child-benefit-application: authority-owned evidence, application non-composition, " - "unsupported-purpose denial, and raw-source denial checks passed" - ) - return 0 - - -def load_dotenv(path: Path) -> None: - if not path.exists(): - return - for raw_line in path.read_text().splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, raw_value = line.split("=", 1) - key = key.strip() - if not key or key in os.environ: - continue - if raw_value == "": - os.environ[key] = "" - continue - parts = shlex.split(raw_value, posix=True) - os.environ[key] = parts[0] if parts else "" - - -def application_evidence_failures( - base_url: str, token: str, subject: str, claims: list[str] -) -> list[str]: - response = http_json( - "POST", - joined_url(base_url, "/v1/evaluations"), - auth_headers(token, PURPOSES["child_benefit"], "application/json"), - evaluation_body( - subject, - claims, - scheme="solmara_uin", - format="application/json", - variables={"as_of_date": CHILD_BENEFIT_AS_OF_DATE}, - ), - ) - return validated_evidence_failures(response.status, response.body, subject, claims) - - -def validated_evidence_failures( - status: int | None, body: Any, subject: str, claims: list[str] -) -> list[str]: - if status != 200: - return [f"positive application request returned HTTP {status}, expected 200"] - if not isinstance(body, dict): - return ["positive application response was not a JSON object"] - - failures: list[str] = [] - for obsolete in ("federation_trace", "federator", "federation"): - if obsolete in body: - failures.append(f"positive application response retained obsolete {obsolete}") - - orchestration = body.get("orchestration") - if not isinstance(orchestration, dict): - failures.append("positive application response omitted orchestration ownership") - else: - if orchestration.get("service_id") != "child-benefit-federator": - failures.append("positive application response identified the wrong application") - if orchestration.get("decision") != "not_composed": - failures.append("positive application response crossed the programme decision boundary") - - results = body.get("results") - result_by_claim = ( - { - item.get("claim_id"): item - for item in results - if isinstance(item, dict) and isinstance(item.get("claim_id"), str) - } - if isinstance(results, list) - else {} - ) - if ( - not isinstance(results, list) - or set(result_by_claim) != set(claims) - or len(results) != len(claims) - ): - failures.append("positive application response did not return exactly the requested predicates") - for claim_id in claims: - result = result_by_claim.get(claim_id) - if not isinstance(result, dict): - continue - if any(key in result for key in ("error", "source_record", "raw")): - failures.append(f"{claim_id} leaked an error or raw source representation") - if result.get("satisfied") is not True or result.get("disclosure") != "predicate": - failures.append(f"positive application response did not satisfy {claim_id} as a predicate") - if result.get("notary_service_id") != EXPECTED_CLAIM_OWNERS[claim_id]: - failures.append(f"{claim_id} was not attributed to its authority Notary") - - source_trace = body.get("source_trace") - expected_services = {EXPECTED_CLAIM_OWNERS[claim_id] for claim_id in claims} - traced_services: set[str] = set() - if not isinstance(source_trace, list): - failures.append("positive application response omitted its ordinary source trace") - else: - for item in source_trace: - if not isinstance(item, dict): - failures.append("positive application source trace contained an invalid entry") - continue - service_id = item.get("service_id") - if isinstance(service_id, str): - traced_services.add(service_id) - summary = item.get("response_summary") - if not isinstance(summary, dict) or summary.get("status") != 200: - failures.append(f"{service_id or 'unknown authority'} source trace did not record HTTP 200") - if traced_services != expected_services: - failures.append("positive application source trace did not cover the requested authority Notaries") - - if subject in json.dumps(body, sort_keys=True): - failures.append("positive application response echoed the raw subject identifier") - return failures - - -def wrong_purpose_failures(base_url: str, token: str, subject: str) -> list[str]: - response = http_json( - "POST", - joined_url(base_url, "/v1/evaluations"), - auth_headers(token, UNSUPPORTED_PURPOSE, "application/json"), - evaluation_body( - subject, - ["birth-is-registered"], - scheme="solmara_uin", - format="application/json", - ), - ) - return denial_failures( - "unsupported-purpose request", - response.status, - response.headers, - response.body, - expected_status=403, - expected_code="pdp.purpose_not_permitted", - ) - - -def denial_failures( - label: str, - status: int | None, - headers: dict[str, str], - body: Any, - *, - expected_status: int, - expected_code: str, -) -> list[str]: - failures: list[str] = [] - if status != expected_status: - failures.append(f"{label} returned HTTP {status}, expected {expected_status}") - content_type = headers.get("content-type", "").split(";", 1)[0].strip().lower() - if content_type != "application/problem+json": - failures.append( - f"{label} returned {content_type or 'no content type'}, expected application/problem+json" - ) - code = body.get("code") if isinstance(body, dict) else None - if code != expected_code: - failures.append(f"{label} returned problem code {code!r}, expected {expected_code!r}") - return failures - - -def raw_household_denial_failures( - base_url: str, token: str, subject: str -) -> list[str]: - response = http_json( - "POST", - joined_url(base_url, "/v1/evaluations"), - auth_headers(token, PURPOSES["child_benefit"], "application/json"), - evaluation_body( - subject, - ["household-poverty-score"], - scheme="solmara_uin", - disclosure="value", - format="application/json", - ), - ) - failures = denial_failures( - "raw household request", - response.status, - response.headers, - response.body, - expected_status=403, - expected_code="pdp.purpose_not_permitted", - ) - serialized = json.dumps(response.body, sort_keys=True) - if "raw_household_score" in serialized or subject in serialized: - failures.append("raw household denial reflected protected source data") - return failures - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-esignet-login.mjs b/scripts/smoke-esignet-login.mjs new file mode 100644 index 0000000..e038f46 --- /dev/null +++ b/scripts/smoke-esignet-login.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import { chromium } from '../portal/node_modules/@playwright/test/index.mjs'; + +const portalUrl = (process.env.SOLMARA_PORTAL_PUBLIC_BASE_URL || 'http://127.0.0.1:4300').replace(/\/$/, ''); +const subject = process.env.ESIGNET_DEMO_SUBJECT || '2300018263'; +const otp = process.env.ESIGNET_DEMO_OTP || '111111'; + +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage(); + const deadline = Date.now() + 60_000; + while (true) { + await page.goto(`${portalUrl}/auth/login`); + try { + await page.getByRole('button', { name: 'Verify with OTP' }).waitFor({ timeout: 5_000 }); + break; + } catch { + // eSignet may still be starting. Retry the whole authorization request. + } + if (Date.now() >= deadline) { + throw new Error('eSignet login did not become ready'); + } + await page.waitForTimeout(1_000); + } + await page.getByRole('button', { name: 'Verify with OTP' }).click(); + await page.getByRole('textbox', { name: 'UIN/VID' }).fill(subject); + const sendOtpResponse = page.waitForResponse((response) => response.url().includes('/authorization/send-otp')); + await page.getByRole('button', { name: 'Get OTP' }).click(); + const sendOtp = await (await sendOtpResponse).json(); + if (Array.isArray(sendOtp.errors) && sendOtp.errors.length) { + throw new Error('eSignet send-otp failed'); + } + const otpInputs = page.locator('input[type="tel"]'); + if (await otpInputs.count() === 1) { + await otpInputs.fill(otp); + } else { + for (const digit of otp) { + await otpInputs.first().press(digit); + await page.waitForTimeout(100); + } + } + await page.getByRole('button', { name: /verify|continue/i }).click(); + const servicesUrl = `${portalUrl}/services`; + await page.waitForURL((url) => url.href === servicesUrl || url.pathname.endsWith('/consent'), { + timeout: 60_000, + }); + if (page.url() !== servicesUrl) { + const consent = page.getByRole('button', { name: /allow|consent|continue|accept/i }); + await consent.first().waitFor({ timeout: 10_000 }); + const claimCheckboxes = page.locator('input[type="checkbox"]'); + const allClaims = page.getByRole('checkbox', { name: 'voluntary_claims' }); + if (await allClaims.count()) { + await allClaims.check({ force: true }); + } else { + for (let index = 0; index < await claimCheckboxes.count(); index += 1) { + const checkbox = claimCheckboxes.nth(index); + if (!(await checkbox.isChecked())) await checkbox.check({ force: true }); + } + } + await consent.first().click(); + } + await page.waitForURL(servicesUrl, { timeout: 60_000 }); + console.log('smoke-esignet-login: PASS'); +} finally { + await browser.close(); +} diff --git a/scripts/smoke-esignet.py b/scripts/smoke-esignet.py index 506599b..dbb7431 100755 --- a/scripts/smoke-esignet.py +++ b/scripts/smoke-esignet.py @@ -127,6 +127,7 @@ def check_esignet_discovery(targets: SmokeTargets, timeout: float) -> None: root_issuer = root_doc.get("issuer") root_oauth_issuer = root_oauth_doc.get("issuer") ui_issuer = ui_doc.get("issuer") + expected_token_endpoint = f"{targets.esignet_url}/v1/esignet/oauth/v2/token" if not isinstance(service_issuer, str) or not service_issuer: raise SmokeFailure("service discovery omitted issuer") if root_issuer != service_issuer: @@ -141,6 +142,14 @@ def check_esignet_discovery(targets: SmokeTargets, timeout: float) -> None: raise SmokeFailure( "UI discovery issuer does not match service discovery issuer" ) + for name, document in ( + ("service", service_doc), + ("root OpenID", root_doc), + ("root OAuth", root_oauth_doc), + ("UI", ui_doc), + ): + if document.get("token_endpoint") != expected_token_endpoint: + raise SmokeFailure(f"{name} discovery token endpoint is not the public endpoint") def wait_for_json( diff --git a/scripts/smoke-hosted-provisioner-image.py b/scripts/smoke-hosted-provisioner-image.py new file mode 100755 index 0000000..34bd362 --- /dev/null +++ b/scripts/smoke-hosted-provisioner-image.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Exercise hosted Evidence and Mint provisioning through the built image.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import uuid +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +LOCAL = ROOT / "config" / "evidence" / "local" / "cells" +GENERIC_ERROR = "hosted provisioner image smoke failed" +MINT_ORIGIN = "https://mint-authority-cells.solmara.registrystack.org" +CRA_RELAY_ORIGIN = "https://cra-relay-authority-cells.solmara.registrystack.org" + + +def _build_cells_module(): + path = ROOT / "evidence" / "scripts" / "build-cells.py" + spec = importlib.util.spec_from_file_location("build_cells", path) + if spec is None or spec.loader is None: + raise RuntimeError("unavailable helper") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _provisioner_module(): + path = ROOT / "scripts" / "provision-hosted-runtime.py" + spec = importlib.util.spec_from_file_location("provision_hosted_runtime", path) + if spec is None or spec.loader is None: + raise RuntimeError("unavailable helper") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_json(path: Path, value: object) -> None: + path.write_text(json.dumps(value, sort_keys=True), encoding="utf-8") + path.chmod(0o400) + + +def _copy_secret(source: Path, destination: Path) -> None: + shutil.copyfile(source, destination) + destination.chmod(0o400) + + +def _run( + image: str, arguments: list[str], mounts: list[tuple[Path, str, bool]] +) -> None: + secret_mounts = [ + mount for mount in mounts if mount[1] == "/tmp/solmara-provisioning" + ] + if len(secret_mounts) != 1 or not secret_mounts[0][2]: + raise RuntimeError("invalid secret mount") + secret_root = secret_mounts[0][0] + secret_paths = sorted(secret_root.iterdir()) + if not secret_paths: + raise RuntimeError("missing secrets") + environment = os.environ.copy() + secrets: dict[str, dict[str, str]] = {} + service_secrets: list[dict[str, object]] = [] + for index, path in enumerate(secret_paths): + if not path.is_file() or path.is_symlink(): + raise RuntimeError("invalid secret") + secret_name = f"input-{index}" + environment_name = f"SOLMARA_SMOKE_SECRET_{index}" + environment[environment_name] = path.read_text(encoding="utf-8") + secrets[secret_name] = {"environment": environment_name} + service_secrets.append( + { + "source": secret_name, + "target": f"/tmp/solmara-provisioning/{path.name}", + "uid": "0", + "gid": "0", + "mode": 0o400, + } + ) + volumes = [ + { + "type": "bind", + "source": str(source), + "target": target, + "read_only": readonly, + } + for source, target, readonly in mounts + if target != "/tmp/solmara-provisioning" + ] + compose = { + "services": { + "provision": { + "image": image, + "platform": "linux/amd64", + "pull_policy": "never", + "network_mode": "none", + "read_only": False, + "user": "0:0", + "cap_drop": ["ALL"], + "cap_add": ["CHOWN", "DAC_OVERRIDE", "FOWNER"], + "security_opt": ["no-new-privileges:true"], + "volumes": volumes, + "secrets": service_secrets, + "command": arguments, + } + }, + "secrets": secrets, + } + project = f"solmara-provisioner-smoke-{uuid.uuid4().hex}" + with tempfile.TemporaryDirectory( + prefix="solmara-provisioner-compose-" + ) as temporary: + compose_file = Path(temporary) / "compose.yaml" + compose_file.write_text( + yaml.safe_dump(compose, sort_keys=True), encoding="utf-8" + ) + command = [ + "docker", + "compose", + "--project-name", + project, + "--file", + str(compose_file), + ] + try: + subprocess.run( + [ + *command, + "up", + "--abort-on-container-exit", + "--exit-code-from", + "provision", + ], + check=True, + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + finally: + subprocess.run( + [*command, "down", "--volumes", "--remove-orphans"], + check=False, + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def smoke(image: str, state_root: Path) -> None: + helper = _build_cells_module() + provisioner = _provisioner_module() + cra_input = state_root / "cra-input" + cra_input.mkdir(parents=True) + _write_json( + cra_input / "signing-public.jwk", + helper.public_jwk(LOCAL / "cra" / "secrets" / "signing.jwk"), + ) + for name in ("audit-hmac-key", "subject-binding-hmac-key"): + _copy_secret(LOCAL / "cra" / "secrets" / name, cra_input / name) + for client in ("cra-pension-evidence", "cra-citizen-evidence"): + _copy_secret( + LOCAL / "cra" / "secrets" / f"{client}-client-key", + cra_input / f"{client}-client-key", + ) + cra = state_root / "cra" + for name in ("runtime", "secrets", "extracts"): + (cra / name).mkdir(parents=True) + cra_mounts = [ + (cra_input, "/tmp/solmara-provisioning", True), + (cra / "runtime", "/provisioned/runtime", False), + (cra / "secrets", "/provisioned/secrets", False), + (cra / "extracts", "/provisioned/extracts", False), + ] + cra_arguments = [ + "provision", + "--target", + "cra-evidence", + "--assets", + "/opt/solmara-hosted-assets", + "--secrets", + "/tmp/solmara-provisioning", + "--runtime-output", + "/provisioned/runtime", + "--secret-output", + "/provisioned/secrets", + "--extract-output", + "/provisioned/extracts", + "--bind-host", + provisioner.EXPECTED_BIND_HOST["cra"], + "--mint-origin", + MINT_ORIGIN, + "--relay-origin", + CRA_RELAY_ORIGIN, + ] + _run(image, cra_arguments, cra_mounts) + _run(image, cra_arguments, cra_mounts) + + mint_input = state_root / "mint-input" + mint_input.mkdir() + _write_json( + mint_input / "signing-public.jwk", + helper.public_jwk(LOCAL / "mint" / "secrets" / "signing.jwk"), + ) + _copy_secret( + LOCAL / "mint" / "secrets" / "audit-hmac-key", + mint_input / "audit-hmac-key", + ) + clients = { + "cra": ("cra-pension-evidence", "cra-citizen-evidence"), + "mosd-programme": ("mosd-child-benefit-evidence",), + "sipf": ("sipf-pension-evidence", "sipf-survivor-evidence"), + "nagdi": ("nagdi-voucher-evidence", "nagdi-livestock-evidence"), + } + for cell, names in clients.items(): + for client in names: + _write_json( + mint_input / f"{client}-public.jwk", + helper.public_jwk(LOCAL / cell / "secrets" / f"{client}-client-key"), + ) + _write_json( + mint_input / "nia-esignet-public.jwk", + helper.public_jwk(LOCAL / "mint" / "clients" / "nia-esignet-rsa-client-key"), + ) + _write_json( + mint_input / "solmara-demo-client-public.jwk", + helper.public_jwk(LOCAL / "mint" / "clients" / "solmara-demo-client-key"), + ) + mint = state_root / "mint" + for name in ("runtime", "secrets"): + (mint / name).mkdir(parents=True) + mint_mounts = [ + (mint_input, "/tmp/solmara-provisioning", True), + (mint / "runtime", "/provisioned/runtime", False), + (mint / "secrets", "/provisioned/secrets", False), + ] + mint_arguments = [ + "provision", + "--target", + "mint", + "--assets", + "/opt/solmara-hosted-assets", + "--secrets", + "/tmp/solmara-provisioning", + "--runtime-output", + "/provisioned/runtime", + "--secret-output", + "/provisioned/secrets", + "--bind-host", + provisioner.EXPECTED_BIND_HOST["mint"], + "--mint-origin", + MINT_ORIGIN, + ] + _run(image, mint_arguments, mint_mounts) + _run(image, mint_arguments, mint_mounts) + + cra_config = yaml.safe_load((cra / "runtime/bundle/evidence.yaml").read_text()) + if not ( + cra / "runtime/bundle" / cra_config["signing"]["activePublicJwkFile"] + ).is_file(): + raise RuntimeError("missing Evidence public key") + if len(list((cra / "extracts").glob("*.sqlite"))) != 1: + raise RuntimeError("invalid Evidence extract publication") + mint_config = yaml.safe_load((mint / "runtime/mint.yaml").read_text()) + if not (mint / "runtime" / mint_config["signing"]["activePublicJwkFile"]).is_file(): + raise RuntimeError("missing Mint public key") + if len(list((mint / "runtime/clients").glob("*.yaml"))) != 9: + raise RuntimeError("invalid Mint client publication") + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--image", required=True) + parser.add_argument("--state-root", type=Path) + try: + arguments = parser.parse_args() + state_root = arguments.state_root or Path( + tempfile.mkdtemp(prefix="solmara-hosted-provisioner-smoke-") + ) + state_root.mkdir(parents=True, exist_ok=True) + smoke(arguments.image, state_root.resolve()) + except Exception: # noqa: BLE001 - CI output is a public redaction boundary. + print(GENERIC_ERROR, file=sys.stderr) + return 1 + print("hosted provisioner image smoke passed") + return 0 + + +if __name__ == "__main__": + os.umask(0o077) + raise SystemExit(main()) diff --git a/scripts/smoke-hosted.py b/scripts/smoke-hosted.py deleted file mode 100755 index 13a9f67..0000000 --- a/scripts/smoke-hosted.py +++ /dev/null @@ -1,569 +0,0 @@ -#!/usr/bin/env python3 -"""Public hosted smoke checks for Solmara Lab.""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -import urllib.error -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any -from urllib.parse import urljoin - - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_DOMAIN = "solmara.registrystack.org" -EXPECTED_SCENARIOS = { - "birth-to-child-benefit", - "death-to-pension-survivor", - "farmer-climate-smart-voucher", - "citizen-self-service", -} -EXPECTED_CHILD_BENEFIT_CLAIMS = { - "birth-is-registered", - "population-record-active", - "child-age-under-5", - "household-below-poverty-threshold", - "not-already-enrolled", -} -EXPECTED_CHILD_BENEFIT_NOTARIES = { - "cra-notary", - "nia-notary", - "sro-notary", - "programme-notary", -} -EXPECTED_CHILD_BENEFIT_ATTRIBUTION = { - "birth-is-registered": "cra-notary", - "child-age-under-5": "cra-notary", - "population-record-active": "nia-notary", - "household-below-poverty-threshold": "sro-notary", - "not-already-enrolled": "programme-notary", -} - - -@dataclass(frozen=True) -class ServiceTarget: - name: str - base_url: str - health_path: str = "/healthz" - env_name: str | None = None - - -@dataclass(frozen=True) -class HostedTargets: - home_url: str - portal_url: str - metadata_url: str - esignet_url: str - esignet_ui_url: str - wallet_url: str - relays: tuple[ServiceTarget, ...] - notaries: tuple[ServiceTarget, ...] - applications: tuple[ServiceTarget, ...] - - -class SmokeFailure(Exception): - """A stable smoke failure with a short operator-facing message.""" - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(normalize_argv(sys.argv[1:] if argv is None else argv)) - targets = default_targets(args.domain, args.scheme) - env = hosted_env(os.environ, targets) - env["SOLMARA_SMOKE_READY_TIMEOUT_SECONDS"] = str(args.timeout) - - checks: list[tuple[str, Any]] = [ - ( - "public routes and service health", - lambda: check_public_routes(targets, args.timeout), - ), - ( - "Visitor Center scenario runner proxy", - lambda: check_home_demo(targets.home_url, args.timeout), - ), - ( - "eSignet backend", - lambda: run_command( - esignet_smoke_command(targets), - env, - cwd=ROOT, - ), - ), - ( - "Relay source endpoints", - lambda: run_command( - [sys.executable, str(ROOT / "scripts" / "smoke-relay-sources.py")], - env, - cwd=ROOT, - ), - ), - ( - "Notary scenario evaluations", - lambda: run_command( - [sys.executable, str(ROOT / "scripts" / "smoke-live.py")], - env, - cwd=ROOT, - ), - ), - ( - "published demo token refusals", - lambda: run_command( - [sys.executable, str(ROOT / "scripts" / "smoke-published-tokens.py")], - env, - cwd=ROOT, - ), - ), - ( - "portal live BFF", - lambda: run_command( - [sys.executable, str(ROOT / "scripts" / "smoke-portal-compose.py")], - env, - cwd=ROOT, - ), - ), - ] - - if args.browser: - checks.extend( - [ - ( - "Visitor Center browser e2e", - lambda: run_command( - ["pnpm", "e2e"], - { - **env, - "SOLMARA_HOME_E2E_MODE": "live", - "PLAYWRIGHT_BASE_URL": targets.home_url, - }, - cwd=ROOT / "home", - ), - ), - ( - "portal browser e2e", - lambda: run_command( - ["pnpm", "e2e"], - { - **env, - "SOLMARA_PORTAL_E2E_MODE": "hosted", - "PLAYWRIGHT_BASE_URL": targets.portal_url, - }, - cwd=ROOT / "portal", - ), - ), - ] - ) - - for name, check in checks: - print(f"check: {name}", flush=True) - try: - check() - except SmokeFailure as error: - print(f"FAILED: {name}: {error}", file=sys.stderr) - return 1 - except subprocess.CalledProcessError as error: - print(f"FAILED: {name}: command exited {error.returncode}", file=sys.stderr) - return error.returncode or 1 - - suffix = " with browser e2e" if args.browser else "" - print(f"smoke-hosted: Solmara hosted smoke passed{suffix}") - return 0 - - -def esignet_smoke_command(targets: HostedTargets) -> list[str]: - return [ - sys.executable, - str(ROOT / "scripts" / "smoke-esignet.py"), - "--esignet-url", - targets.esignet_url, - "--esignet-ui-url", - targets.esignet_ui_url, - ] - - -def parse_args(argv: list[str] | None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--domain", - default=os.environ.get("SOLMARA_HOSTED_DOMAIN", DEFAULT_DOMAIN), - help=f"root hosted domain, default {DEFAULT_DOMAIN}", - ) - parser.add_argument( - "--scheme", - default=os.environ.get("SOLMARA_HOSTED_SCHEME", "https"), - choices=("http", "https"), - help="public URL scheme, default https", - ) - parser.add_argument( - "--timeout", - type=float, - default=float(os.environ.get("SOLMARA_SMOKE_READY_TIMEOUT_SECONDS", "90")), - help="seconds to wait for each public endpoint", - ) - parser.add_argument( - "--browser", - action="store_true", - default=truthy(os.environ.get("SOLMARA_HOSTED_SMOKE_BROWSER")), - help="also run hosted Playwright e2e for the Visitor Center and portal", - ) - return parser.parse_args(argv) - - -def normalize_argv(argv: list[str]) -> list[str]: - return argv[1:] if argv[:1] == ["--"] else argv - - -def default_targets(domain: str, scheme: str = "https") -> HostedTargets: - clean_domain = normalize_domain(domain) - - def public_url(host: str) -> str: - return f"{scheme}://{host}" - - def subdomain(name: str) -> str: - return public_url(f"{name}.{clean_domain}") - - return HostedTargets( - home_url=public_url(clean_domain), - portal_url=subdomain("portal"), - metadata_url=subdomain("metadata"), - esignet_url=subdomain("esignet"), - esignet_ui_url=subdomain("esignet-ui"), - wallet_url=subdomain("wallet"), - relays=( - ServiceTarget( - "CRA civil relay", - subdomain("cra-relay"), - env_name="SOLMARA_CRA_RELAY_URL", - ), - ServiceTarget( - "NIA population relay", - subdomain("nia-relay"), - env_name="SOLMARA_NIA_RELAY_URL", - ), - ServiceTarget( - "SRO social relay", - subdomain("sro-relay"), - env_name="SOLMARA_SRO_RELAY_URL", - ), - ServiceTarget( - "Programme MIS relay", - subdomain("mosd-programme-relay"), - env_name="SOLMARA_PROGRAMME_RELAY_URL", - ), - ServiceTarget( - "SIPF pensions relay", - subdomain("sipf-relay"), - env_name="SOLMARA_SIPF_RELAY_URL", - ), - ServiceTarget( - "NAgDI agriculture relay", - subdomain("nagdi-relay"), - env_name="SOLMARA_NAGDI_RELAY_URL", - ), - ), - notaries=( - ServiceTarget( - "CRA Notary", - subdomain("cra-notary"), - health_path="/ready", - env_name="CRA_NOTARY_URL", - ), - ServiceTarget( - "NIA Notary", - subdomain("nia-notary"), - health_path="/ready", - env_name="NIA_NOTARY_URL", - ), - ServiceTarget( - "SRO Notary", - subdomain("sro-notary"), - health_path="/ready", - env_name="SRO_NOTARY_URL", - ), - ServiceTarget( - "Programme Notary", - subdomain("programme-notary"), - health_path="/ready", - env_name="PROGRAMME_NOTARY_URL", - ), - ServiceTarget( - "SIPF Notary", - subdomain("sipf-notary"), - health_path="/ready", - env_name="SIPF_NOTARY_URL", - ), - ServiceTarget( - "NAgDI notary", - subdomain("nagdi-notary"), - health_path="/ready", - env_name="NAGDI_NOTARY_URL", - ), - ), - applications=( - ServiceTarget( - "child benefit evidence collector", - subdomain("child-benefit-federator"), - health_path="/health", - env_name="CHILD_BENEFIT_FEDERATOR_URL", - ), - ), - ) - - -def normalize_domain(domain: str) -> str: - clean = domain.strip() - if clean.startswith("http://"): - clean = clean.removeprefix("http://") - if clean.startswith("https://"): - clean = clean.removeprefix("https://") - return clean.strip("/") - - -def hosted_env( - base_env: os._Environ[str] | dict[str, str], targets: HostedTargets -) -> dict[str, str]: - env = dict(base_env) - env.update( - { - "PORTAL_URL": targets.portal_url, - "SOLMARA_PORTAL_URL": targets.portal_url, - "STATIC_METADATA_URL": targets.metadata_url, - "SOLMARA_ESIGNET_PUBLIC_BASE_URL": targets.esignet_url, - "SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL": targets.esignet_ui_url, - "SOLMARA_WALLET_URL": targets.wallet_url, - "SOLMARA_PORTAL_EXPECT_AUTH_REQUIRED": "1", - } - ) - for target in (*targets.relays, *targets.notaries, *targets.applications): - if target.env_name: - env[target.env_name] = target.base_url - return env - - -def check_public_routes(targets: HostedTargets, timeout: float) -> None: - checks = [ - ServiceTarget("Visitor Center", targets.home_url, "/"), - ServiceTarget("portal", targets.portal_url, "/"), - ServiceTarget("Walt wallet", targets.wallet_url, "/"), - ServiceTarget("static metadata", targets.metadata_url, "/metadata/index.json"), - *targets.relays, - *targets.notaries, - *targets.applications, - ] - failures: list[str] = [] - for target in checks: - url = joined_url(target.base_url, target.health_path) - result = wait_for_http("GET", url, timeout=timeout) - if not result.ok: - failures.append(f"{target.name} at {url}: {result.detail}") - if failures: - raise SmokeFailure("; ".join(failures)) - - -def check_home_demo(home_url: str, timeout: float) -> None: - scenarios = request_json( - "GET", joined_url(home_url, "/api/scenarios"), timeout=timeout - ) - if scenarios.status != 200: - raise SmokeFailure(f"/api/scenarios returned {scenarios.detail}") - scenario_body = scenarios.body if isinstance(scenarios.body, dict) else {} - scenario_items = scenario_body.get("scenarios", []) - scenario_ids = {item.get("id") for item in scenario_items if isinstance(item, dict)} - missing = sorted(EXPECTED_SCENARIOS - scenario_ids) - if missing: - raise SmokeFailure(f"/api/scenarios missing {', '.join(missing)}") - - positive = request_json( - "POST", - joined_url( - home_url, "/api/scenarios/birth-to-child-benefit/steps/positive/run" - ), - body={}, - timeout=timeout, - ) - positive_result = result_payload(positive) - positive_status = nested(positive_result, "response_source", "status") - if positive.status != 200 or positive_status != 200: - raise SmokeFailure( - f"child positive step returned outer={positive.status}, inner={positive_status}" - ) - positive_body = nested(positive_result, "response_source", "body") - if not isinstance(positive_body, dict): - raise SmokeFailure("child positive step did not return a predicate bundle") - orchestration = positive_body.get("orchestration") - if ( - not isinstance(orchestration, dict) - or orchestration.get("service_id") != "child-benefit-federator" - ): - raise SmokeFailure( - "child positive step did not identify the evidence-collector application" - ) - if orchestration.get("decision") != "not_composed": - raise SmokeFailure("child positive step composed an eligibility decision") - results = positive_body.get("results") - claim_ids = ( - { - result.get("claim_id") - for result in results - if isinstance(result, dict) and isinstance(result.get("claim_id"), str) - } - if isinstance(results, list) - else set() - ) - if claim_ids != EXPECTED_CHILD_BENEFIT_CLAIMS: - raise SmokeFailure( - f"child positive step returned unexpected predicates, claim_ids={sorted(claim_ids)!r}" - ) - if "eligible-for-child-benefit" in claim_ids: - raise SmokeFailure("child positive step returned a composed eligibility claim") - attribution = ( - { - result.get("claim_id"): result.get("notary_service_id") - for result in results - if isinstance(result, dict) - and isinstance(result.get("claim_id"), str) - and isinstance(result.get("notary_service_id"), str) - } - if isinstance(results, list) - else {} - ) - if attribution != EXPECTED_CHILD_BENEFIT_ATTRIBUTION: - raise SmokeFailure( - f"child positive step returned incorrect authority attribution, attribution={attribution!r}" - ) - trace = positive_body.get("source_trace") - source_notaries = ( - { - item.get("service_id") - for item in trace - if isinstance(item, dict) and isinstance(item.get("service_id"), str) - } - if isinstance(trace, list) - else set() - ) - if source_notaries != EXPECTED_CHILD_BENEFIT_NOTARIES: - raise SmokeFailure( - f"child positive step returned an incomplete source trace, source_notaries={sorted(source_notaries)!r}" - ) - if "credential" in positive_result: - raise SmokeFailure("child positive step unexpectedly issued a credential") - - denial = request_json( - "POST", - joined_url( - home_url, "/api/scenarios/birth-to-child-benefit/steps/purpose-denial/run" - ), - body={}, - timeout=timeout, - ) - denial_result = result_payload(denial) - denial_status = nested(denial_result, "response_source", "status") - denial_code = nested(denial_result, "response_source", "body", "code") - if ( - denial.status != 200 - or not isinstance(denial_status, int) - or not 400 <= denial_status < 500 - ): - raise SmokeFailure( - f"child purpose-denial step returned outer={denial.status}, inner={denial_status}" - ) - if not denial_code: - raise SmokeFailure( - "child purpose-denial step did not return a stable problem code" - ) - - -def result_payload(response: "HttpResult") -> dict[str, Any]: - if response.status != 200 or not isinstance(response.body, dict): - raise SmokeFailure(f"scenario step returned {response.detail}") - result = response.body.get("result") - if not isinstance(result, dict): - raise SmokeFailure("scenario step response missing result object") - return result - - -@dataclass(frozen=True) -class HttpResult: - status: int | None - body: Any - error: str = "" - - @property - def ok(self) -> bool: - return self.status is not None and 200 <= self.status < 300 - - @property - def detail(self) -> str: - if self.status is not None: - return f"HTTP {self.status}" - return self.error or "no response" - - -def wait_for_http(method: str, url: str, timeout: float) -> HttpResult: - deadline = time.monotonic() + timeout - last = HttpResult(None, {}, "timeout") - while time.monotonic() < deadline: - last = request_json(method, url, timeout=min(8.0, max(1.0, timeout))) - if last.ok: - return last - time.sleep(1) - return last - - -def request_json( - method: str, url: str, body: Any | None = None, timeout: float = 8.0 -) -> HttpResult: - data = json.dumps(body).encode("utf-8") if body is not None else None - headers = {"Accept": "application/json", "User-Agent": "solmara-hosted-smoke/1.0"} - if data is not None: - headers["Content-Type"] = "application/json" - request = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - return HttpResult(response.status, parse_body(response.read())) - except urllib.error.HTTPError as error: - body = parse_body(error.read()) - error.close() - return HttpResult(error.code, body) - except Exception as error: # noqa: BLE001 - return HttpResult(None, {}, error.__class__.__name__) - - -def parse_body(raw: bytes) -> Any: - if not raw: - return {} - text = raw.decode("utf-8", errors="replace") - try: - return json.loads(text) - except json.JSONDecodeError: - return text - - -def joined_url(base_url: str, path: str) -> str: - return urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) - - -def nested(value: Any, *keys: str) -> Any: - current = value - for key in keys: - if not isinstance(current, dict): - return None - current = current.get(key) - return current - - -def run_command(command: list[str], env: dict[str, str], cwd: Path) -> None: - subprocess.run(command, cwd=cwd, env=env, check=True) - - -def truthy(value: str | None) -> bool: - return str(value or "").lower() in {"1", "true", "yes", "on"} - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-live.py b/scripts/smoke-live.py deleted file mode 100755 index bfe01f4..0000000 --- a/scripts/smoke-live.py +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke the running Solmara Lab Notary topology.""" - -from __future__ import annotations - -import importlib -import os -import shlex -import sys -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from scenarios.common import PURPOSES, auth_headers, http_json, joined_url # noqa: E402 - - -@dataclass(frozen=True) -class NotaryService: - name: str - url_env: str - default_url: str - token_env: str - purpose: str - claim_ids: tuple[str, ...] - - -@dataclass(frozen=True) -class ScenarioCase: - name: str - module_name: str - step_id: str - expected_status: int | tuple[int, ...] | range - expected_values: dict[str, bool] - - -SERVICES = ( - NotaryService( - "CRA Notary", - "CRA_NOTARY_URL", - "http://127.0.0.1:4325", - "CRA_CHILD_BENEFIT_CLIENT_TOKEN", - PURPOSES["child_benefit"], - ("birth-is-registered", "child-age-under-5"), - ), - NotaryService( - "NIA Notary", - "NIA_NOTARY_URL", - "http://127.0.0.1:4326", - "NIA_CHILD_BENEFIT_CLIENT_TOKEN", - PURPOSES["child_benefit"], - ("population-record-active",), - ), - NotaryService( - "SRO Notary", - "SRO_NOTARY_URL", - "http://127.0.0.1:4327", - "SRO_CHILD_BENEFIT_CLIENT_TOKEN", - PURPOSES["child_benefit"], - ("household-below-poverty-threshold",), - ), - NotaryService( - "Programme Notary", - "PROGRAMME_NOTARY_URL", - "http://127.0.0.1:4328", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - PURPOSES["child_benefit"], - ("not-already-enrolled",), - ), - NotaryService( - "SIPF Notary", - "SIPF_NOTARY_URL", - "http://127.0.0.1:4322", - "SIPF_PENSION_CLIENT_TOKEN", - PURPOSES["pension_payment"], - ("pension-payment-active", "survivor-is-eligible"), - ), - NotaryService( - "NAgDI Notary", - "NAGDI_NOTARY_URL", - "http://127.0.0.1:4323", - "NAGDI_NOTARY_TOKEN", - PURPOSES["voucher"], - ( - "farmer-registered", - "data-use-authorized-for-purpose", - "eligible-for-climate-smart-input-voucher", - "registered-herd", - "origin-district-not-quarantined-for-species", - "eligible-for-livestock-movement-permit", - ), - ), -) - -SCENARIO_CASES = ( - ScenarioCase( - "child eligible", - "child_benefit", - "positive", - 200, - { - "birth-is-registered": True, - "population-record-active": True, - "child-age-under-5": True, - "household-below-poverty-threshold": True, - "not-already-enrolled": True, - }, - ), - ScenarioCase( - "child deceased control", - "child_benefit", - "deceased-control", - 200, - {"child-age-under-5": False}, - ), - ScenarioCase( - "child poverty control", - "child_benefit", - "poverty-control", - 200, - {"household-below-poverty-threshold": False}, - ), - ScenarioCase("child unregistered control", "child_benefit", "unregistered-control", 200, {"birth-is-registered": False}), - ScenarioCase( - "child duplicate enrollment control", - "child_benefit", - "duplicate-control", - 200, - {"not-already-enrolled": False}, - ), - ScenarioCase("child unsupported purpose denial", "child_benefit", "purpose-denial", range(400, 500), {}), - ScenarioCase( - "pension stop payment", - "pension_survivor", - "stop-payment", - 200, - {"person-is-deceased": True, "pension-payment-active": True}, - ), - ScenarioCase( - "pension stale death control", - "pension_survivor", - "stale-control", - 200, - {"person-is-deceased": False}, - ), - ScenarioCase( - "pension survivor benefit", - "pension_survivor", - "survivor-benefit", - 200, - {"survivor-is-eligible": True}, - ), - ScenarioCase( - "pension dissolved marriage control", - "pension_survivor", - "dissolved-control", - 200, - {"survivor-is-eligible": False}, - ), - ScenarioCase("pension over-disclosure denial", "pension_survivor", "cause-of-death-denial", range(400, 500), {}), - ScenarioCase( - "farmer voucher eligible", - "farmer_voucher", - "positive", - 200, - {"eligible-for-climate-smart-input-voucher": True}, - ), - ScenarioCase( - "farmer missing authorization control", - "farmer_voucher", - "authorization-control", - 200, - {"eligible-for-climate-smart-input-voucher": False}, - ), - ScenarioCase( - "farmer redeemed control", - "farmer_voucher", - "redeemed-control", - 200, - {"eligible-for-climate-smart-input-voucher": False}, - ), - ScenarioCase( - "livestock movement permit eligible", - "farmer_voucher", - "movement-permit", - 200, - {"eligible-for-livestock-movement-permit": True}, - ), - ScenarioCase("livestock purpose denial", "farmer_voucher", "purpose-denial", range(400, 500), {}), - ScenarioCase( - "citizen self-service summary", - "citizen", - "positive", - 200, - { - "citizen-population-record-active": True, - "civil-record-linked": True, - }, - ), - ScenarioCase("citizen unsupported purpose denial", "citizen", "purpose-denial", range(400, 500), {}), -) - - -def main() -> int: - load_dotenv(ROOT / ".env") - failures: list[str] = [] - - for service in SERVICES: - failures.extend(check_service(service)) - - for case in SCENARIO_CASES: - failures.extend(check_case(case)) - - if failures: - for failure in failures: - print(f"smoke-live: {failure}", file=sys.stderr) - return 1 - - print(f"smoke-live: {len(SERVICES)} services and {len(SCENARIO_CASES)} scenario checks passed") - return 0 - - -def load_dotenv(path: Path) -> None: - if not path.exists(): - return - for raw_line in path.read_text().splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, raw_value = line.split("=", 1) - key = key.strip() - if not key or key in os.environ: - continue - if raw_value == "": - os.environ[key] = "" - continue - parts = shlex.split(raw_value, posix=True) - os.environ[key] = parts[0] if parts else "" - - -def check_service(service: NotaryService) -> list[str]: - failures: list[str] = [] - base_url = os.environ.get(service.url_env, service.default_url) - if not os.environ.get(service.token_env): - return [f"{service.name}: missing {service.token_env}; run `just generate` before live smoke"] - - readiness = wait_for_readiness(base_url, service.name) - if readiness is not None: - failures.append(readiness) - - headers = auth_headers(os.environ[service.token_env], service.purpose, "application/json") - result = http_json("GET", joined_url(base_url, "/v1/claims"), headers, timeout=5.0) - if result.status != 200: - failures.append(f"{service.name}: GET /v1/claims returned {result.status}; body={compact_body(result.body)}") - return failures - - ids = catalog_claim_ids(result.body) - missing = sorted(set(service.claim_ids) - ids) - if missing: - failures.append(f"{service.name}: missing claims from catalogue: {', '.join(missing)}") - return failures - - -def wait_for_readiness(base_url: str, name: str) -> str | None: - url = joined_url(base_url, "/ready") - deadline = time.monotonic() + float(os.environ.get("SOLMARA_SMOKE_READY_TIMEOUT_SECONDS", "90")) - last_status: int | None = None - last_error = "" - while time.monotonic() < deadline: - result = http_json("GET", url, {}, timeout=2.0) - last_status = result.status - last_error = result.error - if result.status in {200, 204}: - return None - time.sleep(1) - detail = f"status {last_status}" if last_status is not None else last_error or "no response" - return f"{name}: /ready did not become ready at {url} ({detail})" - - -def check_case(case: ScenarioCase) -> list[str]: - module = importlib.import_module(f"scenarios.{case.module_name}") - result = module.run_step({}, case.step_id) - response = result.get("response_source", {}) - status = response.get("status") - if not status_matches(status, case.expected_status): - return [f"{case.name}: expected HTTP {format_expected(case.expected_status)}, got {status}; body={compact_body(response.get('body'))}"] - - if not case.expected_values: - return [] - - values = claim_values(response.get("body", {})) - failures = [] - for claim_id, expected in case.expected_values.items(): - actual = values.get(claim_id) - if actual is not expected: - failures.append(f"{case.name}: expected {claim_id}={expected}, got {actual}; body={compact_body(response.get('body'))}") - return failures - - -def status_matches(status: Any, expected: int | tuple[int, ...] | range) -> bool: - if not isinstance(status, int): - return False - if isinstance(expected, int): - return status == expected - return status in expected - - -def format_expected(expected: int | tuple[int, ...] | range) -> str: - if isinstance(expected, range): - return f"{expected.start}-{expected.stop - 1}" - if isinstance(expected, tuple): - return ",".join(str(item) for item in expected) - return str(expected) - - -def catalog_claim_ids(body: Any) -> set[str]: - if not isinstance(body, dict): - return set() - claims = body.get("data", []) - if not isinstance(claims, list): - return set() - return {claim.get("id") for claim in claims if isinstance(claim, dict) and isinstance(claim.get("id"), str)} - - -def claim_values(body: Any) -> dict[str, bool | None]: - if not isinstance(body, dict): - return {} - results = body.get("results", []) - if not isinstance(results, list): - return {} - values: dict[str, bool | None] = {} - for item in results: - if not isinstance(item, dict): - continue - claim_id = item.get("claim_id") - if not isinstance(claim_id, str): - continue - value = item.get("value") - values[claim_id] = value if isinstance(value, bool) else item.get("satisfied") - return values - - -def compact_body(body: Any) -> str: - text = str(body) - return text if len(text) <= 500 else text[:497] + "..." - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-nia-attribute-release.py b/scripts/smoke-nia-attribute-release.py deleted file mode 100755 index cbc2cd6..0000000 --- a/scripts/smoke-nia-attribute-release.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -"""Prove the NIA eSignet attribute-release profile against the live Relay.""" - -from __future__ import annotations - -import json -import os -import sys -import urllib.error -import urllib.request -from typing import Any - - -PURPOSE = "https://id.registrystack.org/solmara/purpose/esignet-identity-verification" -PROFILE_PATH = ( - "/v1/attribute-releases/solmara-nia-userinfo/versions/v1/resolve" -) -SUBJECT = "2300018263" - - -def main() -> int: - token = os.environ.get("NIA_ESIGNET_RELAY_TOKEN", "") - if not token: - print( - "smoke-nia-attribute-release: missing NIA_ESIGNET_RELAY_TOKEN", - file=sys.stderr, - ) - return 1 - - base_url = os.environ.get("SOLMARA_NIA_RELAY_URL", "http://127.0.0.1:4312") - request = urllib.request.Request( - f"{base_url.rstrip('/')}{PROFILE_PATH}", - data=json.dumps( - {"subject": {"id_type": "national_id", "value": SUBJECT}} - ).encode("utf-8"), - headers={ - "Accept": "application/json", - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Data-Purpose": PURPOSE, - }, - method="POST", - ) - - try: - with urllib.request.urlopen(request, timeout=8.0) as response: - status = response.status - body = parse_json(response.read()) - except urllib.error.HTTPError as error: - status = error.code - body = parse_json(error.read()) - except Exception as error: - print( - "smoke-nia-attribute-release: request failed " - f"({error.__class__.__name__})", - file=sys.stderr, - ) - return 1 - - failure = validate_response(status, body) - if failure: - print(f"smoke-nia-attribute-release: {failure}", file=sys.stderr) - return 1 - - print( - "smoke-nia-attribute-release: governed eSignet identity resolution passed" - ) - return 0 - - -def parse_json(raw: bytes) -> Any: - try: - return json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - return {} - - -def validate_response(status: int, body: Any) -> str | None: - if status != 200: - code = body.get("code") if isinstance(body, dict) else None - return f"expected HTTP 200, got {status}" + (f" {code}" if code else "") - if not isinstance(body, dict): - return "response was not a JSON object" - if body.get("profile_id") != "solmara-nia-userinfo": - return "response profile_id did not match" - if body.get("profile_version") != "v1": - return "response profile_version did not match" - claims = body.get("claims") - if not isinstance(claims, dict): - return "response omitted the minimized claims object" - if claims.get("individual_id") != SUBJECT or claims.get("name") != "Elena Dela Cruz": - return "response claims did not match the synthetic eSignet subject" - if "source" in body: - return "response disclosed source metadata" - return None - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-programme-acceptance.py b/scripts/smoke-programme-acceptance.py new file mode 100755 index 0000000..ad40106 --- /dev/null +++ b/scripts/smoke-programme-acceptance.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Run sanitized programme acceptance against the live Scenario Runner HTTP API.""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from collections import Counter +from dataclasses import dataclass +from typing import Any, Callable + + +RUNNER_URL = os.environ.get("SOLMARA_SCENARIO_RUNNER_URL", "http://127.0.0.1:8080") +FEDERATOR_URL = os.environ.get( + "SOLMARA_CHILD_BENEFIT_FEDERATOR_URL", + os.environ.get("CHILD_BENEFIT_FEDERATOR_URL", "http://child-benefit-federator:8080"), +) +READY_TIMEOUT_SECONDS = float(os.environ.get("SOLMARA_SMOKE_READY_TIMEOUT_SECONDS", "90")) + +CRA = "did:web:id.registrystack.org:solmara:authority:cra" +NIA = "did:web:id.registrystack.org:solmara:authority:nia" +SRO = "did:web:id.registrystack.org:solmara:authority:sro" +MOSD = "did:web:id.registrystack.org:solmara:authority:mosd-programme-mis" +SIPF = "did:web:id.registrystack.org:solmara:authority:sipf" +NAGDI = "did:web:id.registrystack.org:solmara:authority:nagdi" + +CHILD_CLAIMS = { + "birth-is-registered": CRA, + "child-age-under-5": CRA, + "population-record-active": NIA, + "household-below-poverty-threshold": SRO, + "not-already-enrolled": MOSD, +} +PENSION_CLAIMS = { + "person-is-deceased": CRA, + "pension-payment-active": SIPF, +} +SURVIVOR_CLAIMS = {"survivor-is-eligible": SIPF} +VOUCHER_CLAIMS = { + "farmer-registered": NAGDI, + "data-use-authorized-for-purpose": NAGDI, + "eligible-for-climate-smart-input-voucher": NAGDI, +} +LIVESTOCK_CLAIMS = { + "registered-herd": NAGDI, + "origin-district-not-quarantined-for-species": NAGDI, + "eligible-for-livestock-movement-permit": NAGDI, +} + + +@dataclass(frozen=True) +class HttpResult: + status: int | None + body: Any + + +@dataclass(frozen=True) +class Check: + label: str + run: Callable[[], bool] + + +def joined_url(base: str, path: str) -> str: + return f"{base.rstrip('/')}/{path.lstrip('/')}" + + +def request_json(method: str, url: str, body: Any | None = None, timeout: float = 30.0) -> HttpResult: + data = json.dumps(body, separators=(",", ":")).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return HttpResult(response.status, parse_json(response.read())) + except urllib.error.HTTPError as error: + try: + return HttpResult(error.code, parse_json(error.read())) + finally: + error.close() + except Exception: + return HttpResult(None, {}) + + +def parse_json(raw: bytes) -> Any: + if not raw: + return {} + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=closed_object) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError): + return {} + + +def closed_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON member") + result[key] = value + return result + + +def wait_for_runner() -> bool: + deadline = time.monotonic() + READY_TIMEOUT_SECONDS + while time.monotonic() < deadline: + result = request_json("GET", joined_url(RUNNER_URL, "/health"), timeout=2.0) + if result.status == 200 and result.body == {"service": "scenario-runner", "status": "ok"}: + return True + time.sleep(1) + return False + + +def run_scenario(scenario: str, step: str) -> dict[str, Any] | None: + result = request_json( + "POST", + joined_url(RUNNER_URL, f"/v1/scenarios/{scenario}/steps/{step}/run"), + {}, + ) + if result.status != 200 or not isinstance(result.body, dict): + return None + if result.body.get("scenario_id") != scenario: + return None + payload = result.body.get("result") + return payload if isinstance(payload, dict) else None + + +def verified_claims( + payload: dict[str, Any] | None, + expected_claims: dict[str, str], + expected_services: dict[str, tuple[str, str]], +) -> bool: + if payload is None or payload.get("response_source") != {"status": 200, "code": "ok"}: + return False + results = payload.get("results") + traces = payload.get("source_trace") + presentations = payload.get("presentations") + if not isinstance(results, list) or not isinstance(traces, list) or not isinstance(presentations, list): + return False + if len(results) != len(expected_claims) or len(traces) != len(expected_services): + return False + + actual_claims: dict[str, str] = {} + expected_identity_by_issuer = { + issuer: source for issuer, source in expected_services.values() + } + for item in results: + if not isinstance(item, dict) or item.get("satisfied") is not True or item.get("value") is not True: + return False + claim = item.get("claim_id") + presentation = item.get("presentation") + if not isinstance(claim, str) or not isinstance(presentation, dict): + return False + issuer = presentation.get("issuer") + if ( + set(presentation) != {"authority", "issuer", "provider", "source"} + or not all(isinstance(presentation.get(key), str) for key in presentation) + or not isinstance(issuer, str) + or presentation.get("source") != expected_identity_by_issuer.get(issuer) + or claim in actual_claims + ): + return False + actual_claims[claim] = issuer + if actual_claims != expected_claims: + return False + + actual_presentation_issuers: list[str] = [] + for presentation in presentations: + if ( + not isinstance(presentation, dict) + or set(presentation) != {"authority", "issuer", "provider", "source"} + or not all(isinstance(presentation.get(key), str) for key in presentation) + or presentation.get("source") + != expected_identity_by_issuer.get(presentation.get("issuer")) + ): + return False + actual_presentation_issuers.append(presentation["issuer"]) + if Counter(actual_presentation_issuers) != Counter( + issuer for issuer, _ in expected_services.values() + ): + return False + + actual_services: set[str] = set() + trace_issuers: set[str] = set() + for trace in traces: + if ( + not isinstance(trace, dict) + or set(trace) + != {"authority", "service_id", "issuer", "provider", "source", "status"} + or not all( + isinstance(trace.get(key), str) + for key in ("authority", "service_id", "issuer", "provider", "source") + ) + or trace.get("status") != 200 + ): + return False + service = trace.get("service_id") + if not isinstance(service, str) or service not in expected_services or service in actual_services: + return False + expected_identity = expected_services.get(service) + if expected_identity is None or (trace.get("issuer"), trace.get("source")) != expected_identity: + return False + actual_services.add(service) + trace_issuers.add(expected_identity[0]) + return actual_services == set(expected_services) and trace_issuers == set(expected_claims.values()) + + +def child_benefit_positive() -> bool: + return verified_claims( + run_scenario("birth-to-child-benefit", "positive"), + CHILD_CLAIMS, + { + "cra-evidence": (CRA, "immutable extract"), + "nia-evidence": (NIA, "immutable extract"), + "sro-evidence": (SRO, "immutable extract"), + "mosd-programme-evidence": (MOSD, "Relay lookup"), + }, + ) + + +def pension_stop() -> bool: + payload = run_scenario("death-to-pension-survivor", "stop-payment") + return bool( + verified_claims( + payload, + PENSION_CLAIMS, + { + "cra-evidence": (CRA, "Relay lookup"), + "sipf-evidence": (SIPF, "Relay lookup"), + }, + ) + and payload is not None + and payload.get("derived_decisions") + == {"pension-payment-should-stop": True, "owner": "pension-review-application"} + and excludes_cause_of_death(payload) + ) + + +def pension_survivor() -> bool: + payload = run_scenario("death-to-pension-survivor", "survivor-benefit") + return verified_claims( + payload, + SURVIVOR_CLAIMS, + {"sipf-evidence": (SIPF, "Relay lookup")}, + ) and excludes_cause_of_death(payload) + + +def agriculture_voucher() -> bool: + return verified_claims( + run_scenario("farmer-climate-smart-voucher", "positive"), + VOUCHER_CLAIMS, + {"nagdi-evidence": (NAGDI, "Relay lookup")}, + ) + + +def agriculture_livestock() -> bool: + return verified_claims( + run_scenario("farmer-climate-smart-voucher", "movement-permit"), + LIVESTOCK_CLAIMS, + {"nagdi-evidence": (NAGDI, "Relay lookup")}, + ) + + +def excludes_cause_of_death(payload: Any) -> bool: + serialized = json.dumps(payload, sort_keys=True).lower() + return not any( + marker in serialized + for marker in ("cause-of-death", "cause_of_death", "causeofdeath", "cause of death") + ) + + +def generic_scenario_refusal(scenario: str, step: str) -> bool: + payload = run_scenario(scenario, step) + if payload is None: + return False + response = payload.get("response_source") + return bool( + isinstance(response, dict) + and set(response) == {"status", "code"} + and isinstance(response.get("status"), int) + and 400 <= response["status"] < 500 + and response.get("code") == "request_refused" + and payload.get("results") == [] + and payload.get("presentations") == [] + ) + + +def application_unauthorized() -> bool: + result = request_json("GET", joined_url(FEDERATOR_URL, "/v1/claims"), timeout=8.0) + body = result.body + return bool( + result.status == 401 + and isinstance(body, dict) + and set(body) == {"type", "title", "status", "code", "detail"} + and body.get("status") == 401 + and body.get("code") == "authentication_required" + and body.get("type") + == "https://id.registrystack.org/problems/solmara/authentication_required" + ) + + +def checks() -> tuple[Check, ...]: + return ( + Check("runner-ready", wait_for_runner), + Check("child-benefit-four-authorities-five-concepts", child_benefit_positive), + Check("pension-cra-sipf-stop-decision", pension_stop), + Check("pension-survivor-minimized-assertion", pension_survivor), + Check("agriculture-voucher", agriculture_voucher), + Check("agriculture-livestock", agriculture_livestock), + Check( + "child-benefit-wrong-purpose-generic-refusal", + lambda: generic_scenario_refusal("birth-to-child-benefit", "purpose-denial"), + ), + Check( + "pension-unauthorized-requirement-generic-refusal", + lambda: generic_scenario_refusal("death-to-pension-survivor", "cause-of-death-denial"), + ), + Check( + "agriculture-wrong-purpose-generic-refusal", + lambda: generic_scenario_refusal("farmer-climate-smart-voucher", "purpose-denial"), + ), + Check("programme-application-unauthorized-generic-refusal", application_unauthorized), + ) + + +def main() -> int: + failed = False + for check in checks(): + try: + passed = check.run() + except Exception: + passed = False + print(f"programme-acceptance: {'PASS' if passed else 'FAIL'} {check.label}") + failed = failed or not passed + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke-published-tokens.py b/scripts/smoke-published-tokens.py deleted file mode 100755 index ce121fa..0000000 --- a/scripts/smoke-published-tokens.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""Off-script skeptic smoke (spec DoD 9). - -Use the published demo tokens exactly as the engineer door hands them out and -confirm the two off-script attempts a skeptic would make get clean refusals with -stable problem codes: - -1. A wrong-purpose evaluation returns 403 with `pdp.purpose_not_permitted`. -2. A raw-row read attempt (disclosure="raw") is refused with a stable code and - never returns a 2xx that could leak a source row. - -Message text is never asserted; only stable codes and status ranges. -""" - -from __future__ import annotations - -import os -import shlex -import sys -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from scenarios.common import ( # noqa: E402 - PURPOSES, - auth_headers, - evaluation_body, - http_json, - joined_url, -) - -# The published demo tokens the engineer door exposes: name -> (url env, token env). -CHILD_URL_ENV = "CHILD_BENEFIT_FEDERATOR_URL" -CHILD_URL_DEFAULT = "http://127.0.0.1:4321" -CHILD_TOKEN_ENV = "CHILD_BENEFIT_FEDERATOR_TOKEN" - -POSITIVE_SUBJECT = "2300010248" -CLAIM_IDS = ["birth-is-registered"] - - -def problem_code(body: Any) -> str: - if not isinstance(body, dict): - return "" - code = body.get("code") - if isinstance(code, str) and code: - return code - type_uri = body.get("type") - if isinstance(type_uri, str) and "/" in type_uri: - return type_uri.rstrip("/").rsplit("/", 1)[-1] - return "" - - -def main() -> int: - load_dotenv(ROOT / ".env") - token = os.environ.get(CHILD_TOKEN_ENV) - if not token: - print(f"smoke-published-tokens: missing {CHILD_TOKEN_ENV}; run `just generate` before live smoke", file=sys.stderr) - return 1 - - base_url = os.environ.get(CHILD_URL_ENV, CHILD_URL_DEFAULT) - eval_url = joined_url(base_url, "/v1/evaluations") - failures: list[str] = [] - - # 1. Wrong purpose: ask the child-benefit application under a pension purpose. - wrong_headers = auth_headers(token, PURPOSES["pension_payment"], "application/json") - wrong_body = evaluation_body(POSITIVE_SUBJECT, CLAIM_IDS, scheme="solmara_uin", format="application/json") - wrong = http_json("POST", eval_url, wrong_headers, wrong_body, timeout=8.0) - wrong_code = problem_code(wrong.body) - if wrong.status != 403: - failures.append(f"wrong-purpose: expected HTTP 403, got {wrong.status}") - if wrong_code != "pdp.purpose_not_permitted": - failures.append(f"wrong-purpose: expected code pdp.purpose_not_permitted, got '{wrong_code}'") - - # 2. Raw-row read attempt: ask for the raw source row under a permitted purpose. - raw_headers = auth_headers(token, PURPOSES["child_benefit"], "application/json") - raw_body = evaluation_body( - POSITIVE_SUBJECT, CLAIM_IDS, scheme="solmara_uin", disclosure="raw", format="application/json" - ) - raw = http_json("POST", eval_url, raw_headers, raw_body, timeout=8.0) - raw_code = problem_code(raw.body) - if not (isinstance(raw.status, int) and 400 <= raw.status < 500): - failures.append(f"raw-row attempt: expected a 4xx refusal, got {raw.status}") - if raw_code != "pdp.purpose_not_permitted": - failures.append( - "raw-row attempt: expected code pdp.purpose_not_permitted, " - f"got '{raw_code}'" - ) - - if failures: - for failure in failures: - print(f"smoke-published-tokens: {failure}", file=sys.stderr) - return 1 - - print( - "smoke-published-tokens: published-token wrong-purpose (403 " - f"{wrong_code}) and raw-row attempt ({raw.status} {raw_code}) both refused cleanly" - ) - return 0 - - -def load_dotenv(path: Path) -> None: - if not path.exists(): - return - for raw_line in path.read_text().splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, raw_value = line.split("=", 1) - key = key.strip() - if not key or key in os.environ: - continue - if raw_value == "": - os.environ[key] = "" - continue - parts = shlex.split(raw_value, posix=True) - os.environ[key] = parts[0] if parts else "" - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-relay-sources.py b/scripts/smoke-relay-sources.py deleted file mode 100755 index 114408c..0000000 --- a/scripts/smoke-relay-sources.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -"""Probe public Relay readiness and its unauthenticated request boundary. - -Authority Notaries reach their paired Relays with short-lived workload -identity tokens through the separate private consultation Relay processes. -This public-endpoint smoke deliberately has no token: it proves that all six -public Relays are ready and refuse unauthenticated requests with the stable -missing-credential problem. Notary live smokes separately exercise the private -consultation path. -""" - -from __future__ import annotations - -import json -import os -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -OUTPUT = ROOT / "output" / "smoke" / "relay-sources.json" -HttpResult = tuple[int | None, dict[str, str], Any, str] -RelayReadiness = tuple[HttpResult, HttpResult] - - -@dataclass(frozen=True) -class RelayBoundary: - name: str - base_url_env: str - default_base_url: str - consultation_profile: str - - -RELAYS = ( - RelayBoundary( - "CRA Relay", - "SOLMARA_CRA_RELAY_URL", - "http://127.0.0.1:4311", - "solmara-cra-civil.cra-child-benefit.civil", - ), - RelayBoundary( - "NIA Relay", - "SOLMARA_NIA_RELAY_URL", - "http://127.0.0.1:4312", - "solmara-nia-population.nia-child-benefit.population", - ), - RelayBoundary( - "SRO Relay", - "SOLMARA_SRO_RELAY_URL", - "http://127.0.0.1:4313", - "solmara-sro-social.child-benefit.household", - ), - RelayBoundary( - "Programme Relay", - "SOLMARA_PROGRAMME_RELAY_URL", - "http://127.0.0.1:4314", - "solmara-mosd-programme.child-benefit.enrollment", - ), - RelayBoundary( - "SIPF Relay", - "SOLMARA_SIPF_RELAY_URL", - "http://127.0.0.1:4315", - "solmara-sipf-pensions.sipf-pension-payment-review.pension", - ), - RelayBoundary( - "NAgDI Relay", - "SOLMARA_NAGDI_RELAY_URL", - "http://127.0.0.1:4316", - "solmara-nagdi-agriculture.voucher.farmer", - ), -) - - -def main() -> int: - OUTPUT.parent.mkdir(parents=True, exist_ok=True) - failures: list[str] = [] - results: list[dict[str, Any]] = [] - readiness = wait_for_relays() - - for relay in RELAYS: - result = run_probe(relay, *readiness[relay.name]) - results.append(result) - if result["status"] != "ok": - failures.append( - f"{relay.name}: {result['status']} ({result.get('detail', 'no detail')})" - ) - - OUTPUT.write_text( - json.dumps(results, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) - if failures: - for failure in failures: - print(f"smoke-relay-sources: {failure}", file=sys.stderr) - print( - f"smoke-relay-sources: wrote {OUTPUT.relative_to(ROOT)}", file=sys.stderr - ) - return 1 - - print( - f"smoke-relay-sources: {len(RELAYS)} public Relay readiness and authentication-boundary checks passed; " - f"wrote {OUTPUT.relative_to(ROOT)}" - ) - return 0 - - -def wait_for_relays() -> dict[str, RelayReadiness]: - deadline = time.monotonic() + float( - os.environ.get("SOLMARA_SMOKE_READY_TIMEOUT_SECONDS", "90") - ) - latest: dict[str, RelayReadiness] = {} - while True: - for relay in RELAYS: - base_url = os.environ.get(relay.base_url_env, relay.default_base_url) - latest[relay.name] = ( - http_get(joined_url(base_url, "/healthz")), - http_get(joined_url(base_url, "/ready")), - ) - if all( - health[0] == 200 and ready[0] == 200 - for health, ready in latest.values() - ) or time.monotonic() >= deadline: - return latest - time.sleep(1) - - -def run_probe( - relay: RelayBoundary, - health: HttpResult, - ready: HttpResult, -) -> dict[str, Any]: - base_url = os.environ.get(relay.base_url_env, relay.default_base_url) - if health[0] != 200: - return { - "name": relay.name, - "status": "health_unavailable", - "detail": status_detail(health), - } - - if ready[0] != 200: - return { - "name": relay.name, - "status": "not_ready", - "detail": status_detail(ready), - } - - profile = urllib.parse.quote(relay.consultation_profile, safe=".-_") - denial = http_get(joined_url(base_url, f"/v1/consultations/{profile}")) - denial_failure = validate_unauthenticated_denial(denial) - if denial_failure: - return { - "name": relay.name, - "status": "consultation_boundary_failed", - "detail": denial_failure, - } - - return { - "name": relay.name, - "status": "ok", - "liveness_status": health[0], - "readiness_status": ready[0], - "unauthenticated_consultation_status": denial[0], - "unauthenticated_consultation_code": "auth.missing_credential", - } - - -def http_get(url: str) -> HttpResult: - request = urllib.request.Request( - url, headers={"Accept": "application/json"}, method="GET" - ) - try: - with urllib.request.urlopen(request, timeout=5.0) as response: - return ( - response.status, - {key.lower(): value for key, value in response.headers.items()}, - parse_json(response.read()), - "", - ) - except urllib.error.HTTPError as error: - return ( - error.code, - {key.lower(): value for key, value in error.headers.items()}, - parse_json(error.read()), - "", - ) - except Exception as error: - return None, {}, {}, error.__class__.__name__ - - -def validate_unauthenticated_denial( - response: HttpResult, -) -> str | None: - status, headers, body, error = response - if status != 401: - return f"expected HTTP 401, got {status or error}" - content_type = headers.get("content-type", "").split(";", 1)[0].strip().lower() - if content_type != "application/problem+json": - return f"expected application/problem+json, got {content_type or 'no content type'}" - code = body.get("code") if isinstance(body, dict) else None - if code != "auth.missing_credential": - return f"expected auth.missing_credential, got {code!r}" - if isinstance(body, dict) and any( - key in body for key in ("data", "outputs", "results", "source_record") - ): - return "unauthenticated denial included source-shaped data" - return None - - -def joined_url(base_url: str, path: str) -> str: - return f"{base_url.rstrip('/')}/{path.lstrip('/')}" - - -def parse_json(raw: bytes) -> Any: - if not raw: - return {} - try: - return json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - return {"unparsed": raw.decode("utf-8", errors="replace")[:200]} - - -def status_detail(response: HttpResult) -> str: - status, _, body, error = response - if status is None: - return error or "no response" - code = body.get("code") if isinstance(body, dict) else None - return f"HTTP {status}" + (f" {code}" if code else "") - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-story-previews.py b/scripts/smoke-story-previews.py deleted file mode 100755 index bb7d230..0000000 --- a/scripts/smoke-story-previews.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Render every guided story request without sending network calls.""" - -from __future__ import annotations - -import importlib -import json -import sys -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[1] -SCENARIOS = [ - "scenarios.child_benefit", - "scenarios.pension_survivor", - "scenarios.farmer_voucher", - "scenarios.citizen", -] - - -def validate_request(module_name: str, step_id: str, request: dict[str, Any]) -> None: - method = request.get("method") - url = request.get("url") - headers = request.get("headers") - if method == "MULTI": - if url != "solmara://authority-notaries": - raise ValueError( - f"{module_name}:{step_id}: expected authority collection URL, got {url!r}" - ) - if not isinstance(headers, dict) or "Data-Purpose" not in headers: - raise ValueError(f"{module_name}:{step_id}: missing Data-Purpose") - requests = request.get("requests") - if not isinstance(requests, list) or not requests: - raise ValueError( - f"{module_name}:{step_id}: authority collection has no requests" - ) - for index, authority_request in enumerate(requests): - if not isinstance(authority_request, dict): - raise ValueError( - f"{module_name}:{step_id}: authority request {index} is invalid" - ) - validate_request( - module_name, - f"{step_id}/authority-{index + 1}", - authority_request, - ) - return - if method not in {"GET", "POST"}: - raise ValueError(f"{module_name}:{step_id}: invalid method {method!r}") - if not isinstance(url, str) or not url.startswith("http://127.0.0.1:"): - raise ValueError(f"{module_name}:{step_id}: expected local URL, got {url!r}") - if not isinstance(headers, dict) or "Data-Purpose" not in headers: - raise ValueError(f"{module_name}:{step_id}: missing Data-Purpose") - for header in ("Authorization", "x-api-key"): - if header in headers and "runtime token hidden" not in headers[header] and "runtime token missing" not in headers[header]: - raise ValueError(f"{module_name}:{step_id}: {header} header was not redacted") - if method == "POST" and "body" not in request: - raise ValueError(f"{module_name}:{step_id}: POST preview is missing a body") - - -def main() -> int: - output: dict[str, Any] = {} - for module_name in SCENARIOS: - module = importlib.import_module(module_name) - story = module.story() - previews = [] - for step in story["steps"]: - step_id = step["id"] - request = module.preview_step({}, step_id) - validate_request(module_name, step_id, request) - previews.append({"step_id": step_id, "request": request}) - output[story["id"]] = previews - - out_path = ROOT / "output" / "smoke" / "story-previews.json" - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_text(json.dumps(output, indent=2, sort_keys=True) + "\n", encoding="utf-8") - print(f"Wrote {out_path.relative_to(ROOT)}") - return 0 - - -if __name__ == "__main__": - sys.path.insert(0, str(ROOT)) - raise SystemExit(main()) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 79a5f69..25e387b 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -1,45 +1,30 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -mkdir -p "$root/output/smoke" +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +set -a +. "$root/versions.env" +. "$root/.env" +set +a -ran=0 -"$root/scripts/smoke-story-previews.py" -ran=1 +uv run --project "$root" "$root/scripts/check-signer-public-keys.py" -if [ "${SOLMARA_SMOKE_LIVE:-1}" != "0" ]; then - ran=1 - "$root/scripts/smoke-relay-sources.py" - compose=( - docker compose - --env-file "$root/versions.env" - --env-file "$root/.env" - -f "$root/compose.yaml" - ) - nia_esignet_relay_token=$( - "${compose[@]}" exec -T nia-workload-agent \ - cat /run/esignet-secrets/solmara-esignet-relay-token - ) - NIA_ESIGNET_RELAY_TOKEN="$nia_esignet_relay_token" \ - "$root/scripts/smoke-nia-attribute-release.py" - unset nia_esignet_relay_token - # The signing smokes need cryptography from the locked project environment. - uv run --locked --project "$root" "$root/scripts/smoke-live.py" - uv run --locked --project "$root" "$root/scripts/notary_state_restart.py" - uv run --locked --project "$root" "$root/scripts/smoke-child-benefit-application.py" - "$root/scripts/smoke-published-tokens.py" - "$root/scripts/smoke-portal-compose.py" -fi +python3 - <<'PY' +import json +import os +import urllib.request -for script in "$root"/scripts/stories/*.sh; do - if [ -x "$script" ]; then - ran=1 - "$script" - fi -done +for name, url in { + "home": f"http://127.0.0.1:{os.getenv('SOLMARA_HOME_PORT', '4301')}/", + "portal": f"http://127.0.0.1:{os.getenv('SOLMARA_PORTAL_PORT', '4300')}/", +}.items(): + with urllib.request.urlopen(url, timeout=10) as response: + if response.status != 200: + raise SystemExit(f"{name} returned {response.status}") -if [ "$ran" -eq 0 ]; then - echo "No story smoke scripts are installed yet." >&2 - exit 1 -fi +for authority, port in {"cra": 4311, "nia": 4312, "mosd": 4314, "sipf": 4315, "nagdi": 4316}.items(): + with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=10) as response: + body = json.load(response) + if body.get("status") != "ok": + raise SystemExit(f"{authority} Relay is not healthy") +PY diff --git a/scripts/start-esignet-relay.sh b/scripts/start-esignet-relay.sh index 49b3598..d8d2926 100755 --- a/scripts/start-esignet-relay.sh +++ b/scripts/start-esignet-relay.sh @@ -8,6 +8,19 @@ keystore_password="${REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD:?missing REG key_alias="${REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS:?missing REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS}" key_password="${REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD:?missing REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD}" +if [[ -n "${REGISTRY_TLS_CA_CERT:-}" ]]; then + truststore=/tmp/registry-esignet-truststore.p12 + rm -f "$truststore" + keytool -importcert -noprompt \ + -alias registry-runtime-ca \ + -file "$REGISTRY_TLS_CA_CERT" \ + -keystore "$truststore" \ + -storetype PKCS12 \ + -storepass changeit \ + >/dev/null + export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS:-} -Djavax.net.ssl.trustStore=$truststore -Djavax.net.ssl.trustStorePassword=changeit" +fi + mkdir -p "$(dirname "$keystore_path")" if [[ -f "$keystore_path" ]] && ! keytool -list \ diff --git a/scripts/test_build_registry_stack_runtime.py b/scripts/test_build_registry_stack_runtime.py new file mode 100644 index 0000000..c66b838 --- /dev/null +++ b/scripts/test_build_registry_stack_runtime.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BUILDER = ROOT / "scripts" / "build-registry-stack-runtime.sh" + + +class RegistryStackRuntimeBuilderTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + self.root = Path(self.directory.name) + scripts = self.root / "scripts" + binaries = self.root / "bin" + scripts.mkdir() + binaries.mkdir() + shutil.copy2(BUILDER, scripts / BUILDER.name) + + release_check = scripts / "check-registry-stack-release-pin.py" + release_check.write_text("#!/usr/bin/env sh\nexit 0\n", encoding="utf-8") + release_check.chmod(0o755) + + self.digests = { + "RELAY": "1" * 64, + "EVIDENCE": "2" * 64, + "MINT": "3" * 64, + } + self.relayctl_asset = self.root / "relayctl-release-asset" + self.relayctl_asset.write_bytes(b"published relayctl fixture") + relayctl_sha256 = hashlib.sha256(self.relayctl_asset.read_bytes()).hexdigest() + (self.root / "versions.env").write_text( + "REGISTRY_STACK_REQUIRED_VERSION=0.22.0\n" + "REGISTRY_STACK_SOURCE_COMMIT=" + "a" * 40 + "\n" + f"REGISTRY_STACK_RELEASE_RELAY_DIGEST={self.digests['RELAY']}\n" + f"REGISTRY_RELAY_IMAGE=ghcr.io/registrystack/relay@sha256:{self.digests['RELAY']}\n" + f"SOLMARA_EVIDENCE_IMAGE=ghcr.io/registrystack/evidence@sha256:{self.digests['EVIDENCE']}\n" + f"SOLMARA_MINT_IMAGE=ghcr.io/registrystack/mint@sha256:{self.digests['MINT']}\n" + "REGISTRY_RELAYCTL_IMAGE=solmara-lab-relayctl:v0.22.0\n" + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL=https://example.invalid/relayctl\n" + f"REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256={relayctl_sha256}\n", + encoding="utf-8", + ) + + self.log = self.root / "docker.log" + docker = binaries / "docker" + docker.write_text( + """#!/usr/bin/env python3 +import os +import sys +from pathlib import Path + +arguments = sys.argv[1:] +with Path(os.environ["FAKE_DOCKER_LOG"]).open("a", encoding="utf-8") as log: + log.write(" ".join(arguments) + "\\n") + +if arguments[:3] == ["buildx", "imagetools", "inspect"]: + component = arguments[3].split("/")[-1].split(":", 1)[0].upper() + print(os.environ[f"TAG_DIGEST_{component}"]) +elif arguments[:2] == ["image", "inspect"]: + if os.environ.get("FORCE_RELAYCTL_BUILD") == "1" and arguments[-1].startswith("solmara-lab-relayctl:"): + raise SystemExit(0) + output_format = arguments[arguments.index("--format") + 1] + if ".Architecture" in output_format: + print("amd64") + elif "org.opencontainers.image.revision" in output_format: + print("a" * 40) + elif "org.opencontainers.image.version" in output_format: + print("0.22.0") + elif "org.opencontainers.image.source" in output_format: + print("https://github.com/registrystack/registry-stack") +elif arguments[0] == "pull": + pass +elif arguments[:2] == ["buildx", "build"]: + if os.environ.get("FORCE_RELAYCTL_BUILD") != "1": + raise SystemExit("relayctl should have been satisfied by the cached image") +else: + raise SystemExit(f"unexpected docker invocation: {arguments}") +""", + encoding="utf-8", + ) + docker.chmod(0o755) + curl = binaries / "curl" + curl.write_text( + """#!/usr/bin/env python3 +import os +import shutil +import sys + +if os.environ.get("FAIL_IF_CURL") == "1": + raise SystemExit("curl must not run when a verified asset file is supplied") +arguments = sys.argv[1:] +output = arguments[arguments.index("--output") + 1] +shutil.copyfile(os.environ["FAKE_RELAYCTL_SOURCE"], output) +""", + encoding="utf-8", + ) + curl.chmod(0o755) + self.environment = { + **os.environ, + "PATH": f"{binaries}:{os.environ['PATH']}", + "FAKE_DOCKER_LOG": str(self.log), + "FAKE_RELAYCTL_SOURCE": str(self.relayctl_asset), + **{ + f"TAG_DIGEST_{component}": f"sha256:{digest}" + for component, digest in self.digests.items() + }, + } + self.environment.pop("REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_FILE", None) + + def tearDown(self) -> None: + self.directory.cleanup() + + def run_builder(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["sh", str(self.root / "scripts" / BUILDER.name)], + cwd=self.root, + env=self.environment, + check=False, + capture_output=True, + text=True, + ) + + def test_all_official_version_tags_match_their_pinned_digests(self) -> None: + result = self.run_builder() + + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text(encoding="utf-8") + for component in ("relay", "evidence", "mint"): + self.assertIn( + f"buildx imagetools inspect ghcr.io/registrystack/{component}:v0.22.0", + calls, + ) + + def test_mismatched_official_tag_digest_fails_closed(self) -> None: + self.environment["TAG_DIGEST_EVIDENCE"] = "sha256:" + "9" * 64 + + result = self.run_builder() + + self.assertEqual(result.returncode, 1) + self.assertIn( + "published evidence tag does not match the pinned digest", + result.stderr, + ) + calls = self.log.read_text(encoding="utf-8") + self.assertNotIn("ghcr.io/registrystack/mint:v0.22.0", calls) + + def test_relayctl_is_verified_on_the_host_before_the_minimal_image_build(self) -> None: + self.environment["FORCE_RELAYCTL_BUILD"] = "1" + + result = self.run_builder() + + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text(encoding="utf-8") + self.assertIn("buildx build --load --platform linux/amd64", calls) + self.assertIn("--target relayctl", calls) + self.assertNotIn("REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL", calls) + self.assertNotIn("REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256", calls) + + def test_preverified_relayctl_file_avoids_a_second_network_download(self) -> None: + self.environment["FORCE_RELAYCTL_BUILD"] = "1" + self.environment["FAIL_IF_CURL"] = "1" + self.environment["REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_FILE"] = str( + self.relayctl_asset + ) + + result = self.run_builder() + + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/scripts/test_contract_generation_proof.py b/scripts/test_contract_generation_proof.py deleted file mode 100644 index 7395093..0000000 --- a/scripts/test_contract_generation_proof.py +++ /dev/null @@ -1,316 +0,0 @@ -from __future__ import annotations - -import contextlib -import importlib.util -import io -import os -import shutil -import subprocess -import sys -import tempfile -import unittest -from unittest import mock -from pathlib import Path - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] - - -def load_proof(): - spec = importlib.util.spec_from_file_location( - "contract_generation_proof", ROOT / "scripts" / "contract-generation-proof.py" - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load contract-generation-proof.py") - module = importlib.util.module_from_spec(spec) - sys.modules["contract_generation_proof"] = module - spec.loader.exec_module(module) - return module - - -class ContractGenerationProofTests(unittest.TestCase): - def setUp(self) -> None: - self.proof = load_proof() - - def test_successor_is_a_revision_only_change(self) -> None: - with tempfile.TemporaryDirectory() as directory: - project = Path(directory) / "sro-social" - source = ROOT / "projects" / "sro-social" - shutil.copytree(source, project) - integration = ( - project - / "integrations" - / "child-benefit-household-by-uin" - / "integration.yaml" - ) - before = yaml.safe_load(integration.read_text(encoding="utf-8")) - self.proof.make_successor(project) - after = yaml.safe_load(integration.read_text(encoding="utf-8")) - self.assertEqual(before | {"revision": 2}, after) - - def test_proof_copy_is_readable_by_the_unprivileged_runtime(self) -> None: - with tempfile.TemporaryDirectory() as directory: - runtime = Path(directory) / "runtime" - nested = runtime / "artifacts" - nested.mkdir(parents=True, mode=0o700) - config = runtime / "relay.yaml" - config.write_text("server: {}\n", encoding="utf-8") - config.chmod(0o600) - artifact = nested / "contract.json" - artifact.write_text("{}\n", encoding="utf-8") - artifact.chmod(0o600) - - self.proof.make_runtime_readable(runtime) - - self.assertEqual(runtime.stat().st_mode & 0o777, 0o755) - self.assertEqual(nested.stat().st_mode & 0o777, 0o755) - self.assertEqual(config.stat().st_mode & 0o777, 0o644) - self.assertEqual(artifact.stat().st_mode & 0o777, 0o644) - - def test_proof_uses_the_digest_pinned_canonical_relay(self) -> None: - relay = "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64 - self.assertEqual( - self.proof.relay_image({"REGISTRY_RELAY_IMAGE": relay}), - relay, - ) - - def test_proof_rejects_a_mutable_canonical_relay_reference(self) -> None: - with self.assertRaisesRegex( - self.proof.ProofFailure, - "must pin the canonical Relay release by digest", - ): - self.proof.relay_image( - { - "REGISTRY_RELAY_IMAGE": "ghcr.io/registrystack/registry-relay:v0.15.2" - } - ) - - def test_mixed_override_replaces_only_notary_generation(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - generation = root / "green" - (generation / "relay").mkdir(parents=True) - (generation / "notary").mkdir() - override = root / "mixed.yaml" - self.proof.write_override(override, generation, relay=False, notary=True) - services = yaml.safe_load(override.read_text(encoding="utf-8"))["services"] - self.assertEqual(set(services), {"sro-notary", "sro-notary-state-install"}) - self.assertNotIn("sro-social-relay-consultation", services) - self.assertIn( - f"{generation / 'notary' / 'notary.yaml'}:/etc/registry-notary/notary.yaml:ro", - services["sro-notary"]["volumes"], - ) - - def test_mixed_generation_asserts_the_stable_notary_problem_code(self) -> None: - self.assertEqual( - self.proof.MIXED_GENERATION_PROBLEM_CODE, - "notary.relay.profile_mismatch", - ) - - def test_complete_override_replaces_private_consultation_generation(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - generation = root / "green" - (generation / "relay").mkdir(parents=True) - (generation / "notary").mkdir() - override = root / "green.yaml" - self.proof.write_override( - override, - generation, - relay=True, - notary=True, - ) - services = yaml.safe_load(override.read_text(encoding="utf-8"))[ - "services" - ] - self.assertIn("sro-relay-state-bootstrap", services) - self.assertIn("sro-social-relay-consultation", services) - self.assertNotIn("sro-social-relay", services) - - def test_success_response_must_be_minimized_and_subject_free(self) -> None: - response = { - "results": [ - { - "claim_id": self.proof.CLAIM_ID, - "disclosure": "predicate", - "satisfied": True, - } - ] - } - self.assertTrue( - self.proof.successful_evaluation(response, self.proof.BLUE_SUBJECT) - ) - response["debug"] = self.proof.BLUE_SUBJECT - self.assertFalse( - self.proof.successful_evaluation(response, self.proof.BLUE_SUBJECT) - ) - - def test_sensitive_scan_reports_category_without_value(self) -> None: - secret = b"synthetic-secret-value" - with tempfile.TemporaryDirectory() as directory: - artifact = Path(directory) / "captured.log" - artifact.write_bytes(b"prefix " + secret + b" suffix") - with self.assertRaises(self.proof.ProofFailure) as rejected: - self.proof.scan_paths([artifact], {"test credential": secret}) - message = str(rejected.exception) - self.assertIn("test credential", message) - self.assertNotIn(secret.decode("utf-8"), message) - - def test_failed_command_output_is_actionable_bounded_and_redacted(self) -> None: - secret = "runtime-secret-that-must-not-appear" - output_lines = "\n".join(f"diagnostic line {index}" for index in range(40)) - command = ( - 'printf "%s\\n" "explanation: registry image was unavailable" ' - '"$INHERITED_TOKEN" "2300027390"; ' - f'printf "%s\\n" "{output_lines}"; exit 7' - ) - with mock.patch.dict(os.environ, {"INHERITED_TOKEN": secret}, clear=False): - with self.assertRaises(self.proof.ProofFailure) as rejected: - self.proof.run(["/bin/sh", "-c", command]) - message = str(rejected.exception) - self.assertIn("explanation: registry image was unavailable", message) - self.assertIn("command output (redacted and bounded)", message) - self.assertNotIn(secret, message) - self.assertNotIn(self.proof.BLUE_SUBJECT, message) - diagnostic = message.split("command output (redacted and bounded):\n", 1)[1] - self.assertLessEqual( - len(diagnostic.splitlines()), self.proof.MAX_DIAGNOSTIC_LINES - ) - self.assertLessEqual( - len(diagnostic.encode("utf-8")), self.proof.MAX_DIAGNOSTIC_BYTES - ) - - def test_failed_generation_reports_redacted_bootstrap_logs(self) -> None: - secret = "bootstrap-secret-that-must-not-appear" - compose = ["docker", "compose", "--project-name", "proof"] - with mock.patch.object( - self.proof, - "run", - side_effect=[ - subprocess.CompletedProcess(compose, 1, "service failed"), - subprocess.CompletedProcess( - compose, - 0, - f"sro-relay-state-bootstrap | migration rejected {secret}", - ), - ], - ) as mocked_run: - with self.assertRaises(self.proof.ProofFailure) as rejected: - self.proof.start_generation( - compose, - {"REGISTRY_RELAY_STATE_MIGRATION_PASSWORD": secret}, - ) - message = str(rejected.exception) - self.assertIn("failed service logs", message) - self.assertIn("migration rejected", message) - self.assertNotIn(secret, message) - self.assertEqual(mocked_run.call_count, 2) - self.assertEqual( - mocked_run.call_args_list[1].args[0][-4:], - [ - "postgres", - "registry-postgresql-bootstrap", - "sro-relay-state-bootstrap", - "sro-social-relay-consultation", - ], - ) - - def test_cleanup_failures_are_fatal_without_a_primary_failure(self) -> None: - failures = ( - lambda: subprocess.CompletedProcess(["docker"], 1, "cleanup rejected"), - lambda: (_ for _ in ()).throw( - subprocess.TimeoutExpired(["docker"], timeout=1) - ), - lambda: (_ for _ in ()).throw(OSError("unsafe operating-system detail")), - ) - for cleanup in failures: - with self.subTest(cleanup=cleanup): - with self.assertRaises(self.proof.ProofFailure): - self.proof.preserve_cleanup_failure( - cleanup, - environment={}, - primary_failure_active=False, - ) - - def test_cleanup_failure_does_not_replace_primary_and_is_safely_reported(self) -> None: - secret = "cleanup-secret-that-must-not-appear" - stderr = io.StringIO() - with contextlib.redirect_stderr(stderr): - with self.assertRaisesRegex(self.proof.ProofFailure, "primary proof failure"): - try: - raise self.proof.ProofFailure("primary proof failure") - finally: - self.proof.preserve_cleanup_failure( - lambda: subprocess.CompletedProcess( - ["docker"], - 1, - f"cleanup explanation; credential={secret}; subject={self.proof.GREEN_SUBJECT}", - ), - environment={"CLEANUP_TOKEN": secret}, - primary_failure_active=sys.exc_info()[0] is not None, - ) - diagnostic = stderr.getvalue() - self.assertIn("secondary cleanup failure", diagnostic) - self.assertIn("cleanup explanation", diagnostic) - self.assertNotIn(secret, diagnostic) - self.assertNotIn(self.proof.GREEN_SUBJECT, diagnostic) - - def test_mixed_timeout_preserves_primary_when_emergency_cleanup_fails(self) -> None: - secret = "emergency-cleanup-secret" - cleanup_failures = ( - lambda: subprocess.CompletedProcess( - ["docker"], - 1, - f"remove failed; credential={secret}; subject={self.proof.BLUE_SUBJECT}", - ), - lambda: (_ for _ in ()).throw( - subprocess.TimeoutExpired(["docker", "rm"], timeout=1) - ), - ) - for emergency_cleanup in cleanup_failures: - with self.subTest(emergency_cleanup=emergency_cleanup): - stderr = io.StringIO() - with contextlib.redirect_stderr(stderr): - with self.assertRaisesRegex( - self.proof.ProofFailure, - "mixed-generation Notary unexpectedly kept serving", - ): - self.proof.raise_mixed_notary_timeout( - "bounded-mixed-notary", - subprocess.TimeoutExpired( - ["docker", "compose"], timeout=45 - ), - environment={"EMERGENCY_TOKEN": secret}, - emergency_cleanup=emergency_cleanup, - ) - diagnostic = stderr.getvalue() - self.assertIn("secondary cleanup failure", diagnostic) - self.assertNotIn(secret, diagnostic) - self.assertNotIn(self.proof.BLUE_SUBJECT, diagnostic) - - def test_workflows_use_the_pinned_compiler_and_live_proof(self) -> None: - ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") - candidate = (ROOT / ".github" / "workflows" / "release-candidate.yml").read_text( - encoding="utf-8" - ) - self.assertIn("just registry-projects-runtime-check", ci) - self.assertNotIn("just contract-generation-proof", ci) - self.assertIn("just registry-projects-runtime-check", candidate) - self.assertIn("just contract-generation-proof", candidate) - - def test_clean_checkout_journey_is_one_documented_target(self) -> None: - justfile = (ROOT / "justfile").read_text(encoding="utf-8") - readme = (ROOT / "README.md").read_text(encoding="utf-8") - self.assertRegex( - justfile, - r"up-generated:\n just generate\n just registry-projects-runtime-check\n just up", - ) - quick_start = readme.split("## Quick Start", 1)[1].split("```bash", 1)[1].split("```", 1)[0] - self.assertIn("just up-generated", quick_start) - self.assertNotIn("just contract-generation-proof", quick_start) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_gen_secrets.py b/scripts/test_gen_secrets.py new file mode 100644 index 0000000..ae37f05 --- /dev/null +++ b/scripts/test_gen_secrets.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +import sys +from unittest import mock + +SCRIPT = Path(__file__).with_name("gen-secrets.py") +sys.path.insert(0, str(SCRIPT.parent)) +SPEC = importlib.util.spec_from_file_location("gen_secrets", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class SecretGenerationTests(unittest.TestCase): + def test_p256_key_is_private_and_thumbprint_bound(self) -> None: + jwk = json.loads(MODULE.p256_jwk()) + self.assertEqual((jwk["kty"], jwk["crv"], jwk["alg"]), ("EC", "P-256", "ES256")) + self.assertIn("d", jwk) + self.assertEqual(len(jwk["kid"]), 43) + + def test_rsa_key_is_private_and_thumbprint_bound(self) -> None: + jwk = json.loads(MODULE.rsa_jwk()) + self.assertEqual((jwk["kty"], jwk["alg"]), ("RSA", "RS256")) + self.assertEqual(set(jwk), {"kty", "alg", "n", "e", "d", "p", "q", "dp", "dq", "qi", "kid"}) + self.assertEqual(len(jwk["kid"]), 43) + + def test_operator_material_is_create_only_and_per_cell(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + previous = MODULE.LOCAL + MODULE.LOCAL = Path(temporary) + try: + first = MODULE.ensure_operator_material() + signing = MODULE.LOCAL / "cells/cra/secrets/signing.jwk" + before = signing.read_bytes() + second = MODULE.ensure_operator_material() + self.assertEqual(signing.read_bytes(), before) + self.assertEqual(first, second) + self.assertTrue((MODULE.LOCAL / "cells/nagdi/secrets/subject-binding-hmac-key").exists()) + client_id = MODULE.LOCAL / "cells/cra/secrets/cra-pension-evidence-client-id" + self.assertEqual(client_id.read_bytes(), b"cra-pension-evidence") + self.assertEqual(client_id.stat().st_mode & 0o777, 0o600) + esignet_key = MODULE.LOCAL / "cells/mint/clients/nia-esignet-rsa-client-key" + self.assertEqual(json.loads(esignet_key.read_text())["kty"], "RSA") + self.assertEqual(esignet_key.stat().st_mode & 0o777, 0o600) + finally: + MODULE.LOCAL = previous + + def test_client_identifier_migrates_only_the_previous_trailing_newline(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + client_id = Path(temporary) / "client-id" + client_id.write_bytes(b"registered-client\n") + MODULE.ensure_client_identifier(client_id, "registered-client") + self.assertEqual(client_id.read_bytes(), b"registered-client") + + client_id.write_bytes(b"different-client") + with self.assertRaises(ValueError): + MODULE.ensure_client_identifier(client_id, "registered-client") + self.assertEqual(client_id.read_bytes(), b"different-client") + + def test_generated_environment_preserves_runtime_secrets(self) -> None: + existing = { + key: f"stable-{index}" + for index, key in enumerate(MODULE.RANDOM_ENV_KEYS) + } + existing["PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64"] = "stable-portal-key" + operator = { + "NIA_ESIGNET_CLIENT_PRIVATE_JWK": "operator-jwk", + "SOLMARA_EVIDENCE_CLIENT_KEY": "/operator/client-key", + } + with ( + mock.patch.object(MODULE, "raw_key", side_effect=AssertionError("rotated")), + mock.patch.object( + MODULE, + "rsa_private_key_b64", + side_effect=AssertionError("rotated"), + ), + ): + values = MODULE.compose_environment_values(existing, operator) + for key, value in existing.items(): + self.assertEqual(values[key], value) + self.assertEqual(values["NIA_ESIGNET_CLIENT_PRIVATE_JWK"], "operator-jwk") + + def test_generated_environment_rejects_duplicate_or_empty_values(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / ".env" + path.write_text("KEY=value\nKEY=other\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate"): + MODULE.load_environment(path) + path.write_text("KEY=\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "invalid"): + MODULE.load_environment(path) + path.write_text("KEY='unterminated\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "malformed"): + MODULE.load_environment(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_authority_rollout.py b/scripts/test_hosted_authority_rollout.py new file mode 100644 index 0000000..7ec038a --- /dev/null +++ b/scripts/test_hosted_authority_rollout.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("hosted-authority-rollout.py") +SPEC = importlib.util.spec_from_file_location("authority_rollout", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class HostedAuthorityRolloutTests(unittest.TestCase): + def test_every_phase_is_recoverable(self) -> None: + for phase in ("side-by-side", "switch", "disable"): + plan = MODULE.operation(phase) + self.assertEqual(plan["volumePolicy"], "retain") + self.assertEqual(plan["destructiveCommand"], "none") + + def test_unknown_phase_is_refused(self) -> None: + with self.assertRaises(ValueError): + MODULE.operation("delete") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_esignet_topology.py b/scripts/test_hosted_esignet_topology.py new file mode 100644 index 0000000..f956194 --- /dev/null +++ b/scripts/test_hosted_esignet_topology.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +COMPOSE_PATH = ROOT / "compose.coolify.esignet.yaml" +CORE_COMPOSE_PATH = ROOT / "compose.coolify.yaml" +RENDERER = ROOT / "docker" / "esignet-ui" / "render-hosted-nginx.sh" +TEMPLATE = ROOT / "config" / "esignet" / "nginx-hosted.conf" +LOCAL_COMPOSE_PATH = ROOT / "compose.esignet.yaml" +LOCAL_TEMPLATE = ROOT / "config" / "esignet" / "nginx.conf" + + +class HostedEsignetTopologyTests(unittest.TestCase): + def test_mint_and_relay_dependency_origins_are_not_operator_overridable( + self, + ) -> None: + compose_text = "\n".join( + path.read_text(encoding="utf-8") + for path in (CORE_COMPOSE_PATH, COMPOSE_PATH) + ) + for variable in ( + "SOLMARA_MINT_PUBLIC_BASE_URL", + "SOLMARA_MINT_PUBLIC_HOST", + "SOLMARA_CRA_RELAY_PUBLIC_BASE_URL", + "SOLMARA_NIA_RELAY_PUBLIC_BASE_URL", + "SOLMARA_MOSD_RELAY_PUBLIC_BASE_URL", + "SOLMARA_SIPF_RELAY_PUBLIC_BASE_URL", + "SOLMARA_NAGDI_RELAY_PUBLIC_BASE_URL", + ): + self.assertNotIn(variable, compose_text) + + def test_compose_is_a_standalone_esignet_app(self) -> None: + compose = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8")) + services = compose["services"] + self.assertEqual( + set(services), + { + "esignet-database", + "esignet-redis", + "esignet", + "esignet-ui", + "esignet-seed", + }, + ) + self.assertNotIn("portal", services) + self.assertEqual( + services["esignet"]["environment"]["REGISTRY_MINT_TOKEN_ENDPOINT"], + "https://mint-authority-cells.solmara.registrystack.org/token", + ) + self.assertEqual( + services["esignet"]["environment"]["REGISTRY_RELAY_BASE_URL"], + "https://nia-relay-authority-cells.solmara.registrystack.org", + ) + self.assertEqual( + services["esignet-seed"]["environment"][ + "ESIGNET_CLIENT_REDIRECT_URIS_JSON" + ], + '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback"]', + ) + + def test_renderer_accepts_only_dns_hosts_and_preserves_security_headers( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "nginx.conf" + environment = os.environ | { + "SOLMARA_ESIGNET_PUBLIC_HOST": "login-authority-cells.example.org", + "SOLMARA_ESIGNET_UI_PUBLIC_HOST": "login-ui-authority-cells.example.org", + } + subprocess.run( + [str(RENDERER), str(TEMPLATE), str(output)], + check=True, + env=environment, + capture_output=True, + text=True, + ) + rendered = output.read_text(encoding="utf-8") + self.assertNotIn("__ESIGNET_", rendered) + self.assertIn( + "proxy_set_header Host login-authority-cells.example.org;", rendered + ) + self.assertIn( + "proxy_set_header X-Forwarded-Host login-authority-cells.example.org;", + rendered, + ) + self.assertIn("default-src 'none'", rendered) + self.assertIn( + "connect-src 'self' https://login-authority-cells.example.org https://login-ui-authority-cells.example.org;", + rendered, + ) + + def test_renderer_rejects_directive_injection(self) -> None: + invalid_hosts = ( + "login.example.org;return 200", + "login.example.org/path", + "login.example.org example.net", + "UPPER.example.org", + "localhost", + ) + for invalid_host in invalid_hosts: + with ( + self.subTest(invalid_host=invalid_host), + tempfile.TemporaryDirectory() as directory, + ): + result = subprocess.run( + [str(RENDERER), str(TEMPLATE), str(Path(directory) / "nginx.conf")], + env=os.environ + | { + "SOLMARA_ESIGNET_PUBLIC_HOST": invalid_host, + "SOLMARA_ESIGNET_UI_PUBLIC_HOST": "ui.example.org", + }, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 78) + self.assertEqual( + result.stderr, + "eSignet hosted nginx host configuration is invalid\n", + ) + self.assertNotIn(invalid_host, result.stderr) + + def test_renderer_passes_through_a_template_without_host_placeholders(self) -> None: + environment = { + key: value + for key, value in os.environ.items() + if key + not in ("SOLMARA_ESIGNET_PUBLIC_HOST", "SOLMARA_ESIGNET_UI_PUBLIC_HOST") + } + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "nginx.conf" + result = subprocess.run( + [str(RENDERER), str(LOCAL_TEMPLATE), str(output)], + env=environment, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + output.read_text(encoding="utf-8"), + LOCAL_TEMPLATE.read_text(encoding="utf-8"), + ) + + def test_renderer_rejects_absent_hosts_when_the_template_has_placeholders( + self, + ) -> None: + environment = { + key: value + for key, value in os.environ.items() + if key + not in ("SOLMARA_ESIGNET_PUBLIC_HOST", "SOLMARA_ESIGNET_UI_PUBLIC_HOST") + } + with tempfile.TemporaryDirectory() as temporary_directory: + result = subprocess.run( + [ + str(RENDERER), + str(TEMPLATE), + str(Path(temporary_directory) / "nginx.conf"), + ], + env=environment, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 78) + self.assertEqual( + result.stderr, + "eSignet hosted nginx host configuration is invalid\n", + ) + + def test_local_esignet_ui_services_build_a_placeholder_free_template(self) -> None: + compose = yaml.safe_load(LOCAL_COMPOSE_PATH.read_text(encoding="utf-8")) + services = compose["services"] + for name in ("esignet-ui", "esignet-edge"): + with self.subTest(service=name): + template = ROOT / services[name]["build"]["args"]["ESIGNET_NGINX_CONF"] + self.assertNotIn( + "__ESIGNET_", template.read_text(encoding="utf-8") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_evidence_routes.py b/scripts/test_hosted_evidence_routes.py new file mode 100644 index 0000000..f585eec --- /dev/null +++ b/scripts/test_hosted_evidence_routes.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +TEST_IMAGE = "example.invalid/solmara@sha256:" + "a" * 64 + +EVIDENCE_ROUTES = { + "cra-evidence": ( + ROOT / "compose.coolify.interior.yaml", + "SOLMARA_CRA_EVIDENCE_PUBLIC_HOST", + "cra-evidence.solmara.registrystack.org", + ), + "nia-evidence": ( + ROOT / "compose.coolify.interior.yaml", + "SOLMARA_NIA_EVIDENCE_PUBLIC_HOST", + "nia-evidence.solmara.registrystack.org", + ), + "sro-evidence": ( + ROOT / "compose.coolify.social-development.yaml", + "SOLMARA_SRO_EVIDENCE_PUBLIC_HOST", + "sro-evidence.solmara.registrystack.org", + ), + "mosd-programme-evidence": ( + ROOT / "compose.coolify.social-development.yaml", + "SOLMARA_MOSD_PROGRAMME_EVIDENCE_PUBLIC_HOST", + "mosd-programme-evidence.solmara.registrystack.org", + ), + "sipf-evidence": ( + ROOT / "compose.coolify.labour-pensions.yaml", + "SOLMARA_SIPF_EVIDENCE_PUBLIC_HOST", + "sipf-evidence.solmara.registrystack.org", + ), + "nagdi-evidence": ( + ROOT / "compose.coolify.agriculture.yaml", + "SOLMARA_NAGDI_EVIDENCE_PUBLIC_HOST", + "nagdi-evidence.solmara.registrystack.org", + ), +} + +RELAY_ROUTES = { + "cra-relay": ( + ROOT / "compose.coolify.interior.yaml", + "cra-relay-authority-cells.solmara.registrystack.org", + ), + "nia-relay": ( + ROOT / "compose.coolify.interior.yaml", + "nia-relay-authority-cells.solmara.registrystack.org", + ), + "mosd-relay": ( + ROOT / "compose.coolify.social-development.yaml", + "mosd-programme-relay-authority-cells.solmara.registrystack.org", + ), + "sipf-relay": ( + ROOT / "compose.coolify.labour-pensions.yaml", + "sipf-relay-authority-cells.solmara.registrystack.org", + ), + "nagdi-relay": ( + ROOT / "compose.coolify.agriculture.yaml", + "nagdi-relay-authority-cells.solmara.registrystack.org", + ), +} + + +def compose_environment() -> dict[str, str]: + environment = os.environ | { + "SOLMARA_AUTHORITY_PROVISIONER_IMAGE": TEST_IMAGE, + "REGISTRY_RELAY_IMAGE": TEST_IMAGE, + "SOLMARA_EVIDENCE_IMAGE": TEST_IMAGE, + "CRA_RELAY_AUDIT_KEY": "test", + "NIA_RELAY_AUDIT_KEY": "test", + "MOSD_RELAY_AUDIT_KEY": "test", + "SIPF_RELAY_AUDIT_KEY": "test", + "SIPF_RELAY_CURSOR_KEY": "test", + "NAGDI_RELAY_AUDIT_KEY": "test", + "NAGDI_RELAY_CURSOR_KEY": "test", + } + for _, variable, _ in EVIDENCE_ROUTES.values(): + environment.pop(variable, None) + return environment + + +def render_compose(path: Path, overrides: dict[str, str] | None = None) -> dict: + environment = compose_environment() + environment.update(overrides or {}) + with tempfile.NamedTemporaryFile() as empty_env: + result = subprocess.run( + [ + "docker", + "compose", + "--env-file", + empty_env.name, + "-f", + str(path), + "config", + "--format", + "json", + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + +class HostedEvidenceRouteTests(unittest.TestCase): + def test_raw_compose_declares_canonical_evidence_defaults(self) -> None: + for service, (path, variable, canonical_host) in EVIDENCE_ROUTES.items(): + with self.subTest(service=service): + compose = yaml.safe_load(path.read_text(encoding="utf-8")) + self.assertEqual( + compose["services"][service]["labels"]["solmara.lab.host"], + f"${{{variable}:-{canonical_host}}}", + ) + + def test_canonical_evidence_defaults_render_across_apps(self) -> None: + rendered = { + path: render_compose(path) + for path in {route[0] for route in EVIDENCE_ROUTES.values()} + } + for service, (path, _, canonical_host) in EVIDENCE_ROUTES.items(): + with self.subTest(service=service): + self.assertEqual( + rendered[path]["services"][service]["labels"]["solmara.lab.host"], + canonical_host, + ) + + def test_staging_evidence_hosts_render_without_changing_relay_routes(self) -> None: + overrides = { + variable: f"{service}.staging.example.org" + for service, (_, variable, _) in EVIDENCE_ROUTES.items() + } + rendered = { + path: render_compose(path, overrides) + for path in {route[0] for route in EVIDENCE_ROUTES.values()} + } + for service, (path, variable, _) in EVIDENCE_ROUTES.items(): + with self.subTest(service=service): + self.assertEqual( + rendered[path]["services"][service]["labels"]["solmara.lab.host"], + overrides[variable], + ) + + for service, (path, permanent_host) in RELAY_ROUTES.items(): + with self.subTest(service=service): + self.assertEqual( + rendered[path]["services"][service]["labels"]["solmara.lab.host"], + permanent_host, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_home_topology.py b/scripts/test_hosted_home_topology.py new file mode 100644 index 0000000..70d4670 --- /dev/null +++ b/scripts/test_hosted_home_topology.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path +from urllib.parse import urlparse + +import yaml + + +ROOT = Path(__file__).parents[1] +COMPOSE_PATH = ROOT / "compose.coolify.yaml" + + +def interpolation_default(value: str) -> str: + match = re.fullmatch(r"\$\{[A-Z0-9_]+:-(.+)\}", value) + return match.group(1) if match else value + + +class HostedHomeTopologyTests(unittest.TestCase): + def test_home_build_context_exposes_only_public_generated_inputs(self) -> None: + patterns = (ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() + self.assertIn("!generator/output/**", patterns) + self.assertIn("output/*", patterns) + self.assertIn("!output/smoke", patterns) + self.assertIn("output/smoke/*", patterns) + self.assertIn("!output/smoke/.gitkeep", patterns) + self.assertNotIn("!output/**", patterns) + + def test_home_uses_the_declared_public_topology_without_secrets(self) -> None: + compose = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8")) + home = compose["services"]["home"] + environment = home["environment"] + + expected_origins = { + "PORTAL_URL": "${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}", + "STATIC_METADATA_URL": "${SOLMARA_METADATA_PUBLIC_BASE_URL:-https://metadata.solmara.registrystack.org}", + "CRA_CIVIL_RELAY_URL": "https://cra-relay-authority-cells.solmara.registrystack.org", + "NIA_POPULATION_RELAY_URL": "https://nia-relay-authority-cells.solmara.registrystack.org", + "MOSD_PROGRAMME_RELAY_URL": "https://mosd-programme-relay-authority-cells.solmara.registrystack.org", + "SIPF_PENSIONS_RELAY_URL": "https://sipf-relay-authority-cells.solmara.registrystack.org", + "NAGDI_AGRICULTURE_RELAY_URL": "https://nagdi-relay-authority-cells.solmara.registrystack.org", + "SOLMARA_CRA_EVIDENCE_URL": "${SOLMARA_CRA_EVIDENCE_PUBLIC_BASE_URL:-https://cra-evidence.solmara.registrystack.org}", + "SOLMARA_NIA_EVIDENCE_URL": "${SOLMARA_NIA_EVIDENCE_PUBLIC_BASE_URL:-https://nia-evidence.solmara.registrystack.org}", + "SOLMARA_SRO_EVIDENCE_URL": "${SOLMARA_SRO_EVIDENCE_PUBLIC_BASE_URL:-https://sro-evidence.solmara.registrystack.org}", + "SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL": "${SOLMARA_MOSD_PROGRAMME_EVIDENCE_PUBLIC_BASE_URL:-https://mosd-programme-evidence.solmara.registrystack.org}", + "SOLMARA_SIPF_EVIDENCE_URL": "${SOLMARA_SIPF_EVIDENCE_PUBLIC_BASE_URL:-https://sipf-evidence.solmara.registrystack.org}", + "SOLMARA_NAGDI_EVIDENCE_URL": "${SOLMARA_NAGDI_EVIDENCE_PUBLIC_BASE_URL:-https://nagdi-evidence.solmara.registrystack.org}", + "MINT_URL": "https://mint-authority-cells.solmara.registrystack.org", + "CHILD_BENEFIT_FEDERATOR_URL": "${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org}", + "SCENARIO_RUNNER_URL": "${SOLMARA_SCENARIO_RUNNER_PUBLIC_BASE_URL:-https://scenarios.solmara.registrystack.org}", + "PORTAL_PROBE_URL": "${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}", + } + self.assertEqual( + {key: environment.get(key) for key in expected_origins}, + expected_origins, + ) + + declared_hosts = set() + for path in ROOT.glob("compose.coolify*.yaml"): + hosted_compose = yaml.safe_load(path.read_text(encoding="utf-8")) + for service in hosted_compose.get("services", {}).values(): + host = service.get("labels", {}).get("solmara.lab.host") + if host: + declared_hosts.add(interpolation_default(host)) + self.assertTrue( + { + urlparse(interpolation_default(origin)).hostname + for origin in expected_origins.values() + } + <= declared_hosts + ) + + public_url_map = json.loads(environment["SOLMARA_PUBLIC_URL_MAP"]) + self.assertEqual( + public_url_map, + { + "child-benefit-federator:8080": expected_origins["CHILD_BENEFIT_FEDERATOR_URL"], + "deterministic-publisher:8080": expected_origins["STATIC_METADATA_URL"], + "cra-evidence:8080": expected_origins["SOLMARA_CRA_EVIDENCE_URL"], + "nia-evidence:8080": expected_origins["SOLMARA_NIA_EVIDENCE_URL"], + "sro-evidence:8080": expected_origins["SOLMARA_SRO_EVIDENCE_URL"], + "mosd-programme-evidence:8080": expected_origins["SOLMARA_MOSD_PROGRAMME_EVIDENCE_URL"], + "sipf-evidence:8080": expected_origins["SOLMARA_SIPF_EVIDENCE_URL"], + "nagdi-evidence:8080": expected_origins["SOLMARA_NAGDI_EVIDENCE_URL"], + "mint:8081": expected_origins["MINT_URL"], + "cra-relay:8080": expected_origins["CRA_CIVIL_RELAY_URL"], + "nia-relay:8080": expected_origins["NIA_POPULATION_RELAY_URL"], + "mosd-relay:8080": expected_origins["MOSD_PROGRAMME_RELAY_URL"], + "sipf-relay:8080": expected_origins["SIPF_PENSIONS_RELAY_URL"], + "nagdi-relay:8080": expected_origins["NAGDI_AGRICULTURE_RELAY_URL"], + "static-metadata:8080": expected_origins["STATIC_METADATA_URL"], + "scenario-runner:8080": expected_origins["SCENARIO_RUNNER_URL"], + "portal:4000": expected_origins["PORTAL_PROBE_URL"], + }, + ) + + self.assertNotIn("secrets", home) + self.assertNotIn("volumes", home) + self.assertFalse( + {"SOLMARA_EVIDENCE_CLIENT_KEY", "CHILD_BENEFIT_FEDERATOR_TOKEN"} + & environment.keys() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_image_manifest.py b/scripts/test_hosted_image_manifest.py new file mode 100644 index 0000000..047e58d --- /dev/null +++ b/scripts/test_hosted_image_manifest.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import io +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "hosted-image-manifest.py" +DIGESTS = { + "REGISTRY_RELAY_IMAGE": "1" * 64, + "SOLMARA_EVIDENCE_IMAGE": "2" * 64, + "SOLMARA_MINT_IMAGE": "3" * 64, + "SOLMARA_AUTHORITY_PROVISIONER_IMAGE": "4" * 64, + "SOLMARA_TRANSIT_SIGNER_IMAGE": "5" * 64, + "SOLMARA_STATIC_METADATA_IMAGE": "6" * 64, + "SOLMARA_SCENARIO_RUNNER_IMAGE": "7" * 64, + "SOLMARA_HOME_IMAGE": "8" * 64, + "SOLMARA_PORTAL_IMAGE": "9" * 64, + "SOLMARA_ESIGNET_RELAY_IMAGE": "a" * 64, + "SOLMARA_ESIGNET_POSTGRES_IMAGE": "b" * 64, + "SOLMARA_ESIGNET_UI_IMAGE": "c" * 64, + "SOLMARA_ESIGNET_SEED_IMAGE": "d" * 64, +} + + +def load_module(): + spec = importlib.util.spec_from_file_location("hosted_image_manifest", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class HostedImageManifestTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + self.directory = tempfile.TemporaryDirectory() + self.root = Path(self.directory.name) + self.manifest = self.root / "release" / "solmara-hosted-images.env" + self.environment = { + key: self.module.expected_reference(key, digest) + for key, digest in DIGESTS.items() + } + core = [ + key + for key in self.module.EXPECTED_KEYS + if not key.startswith("SOLMARA_ESIGNET_") + ] + esignet = [ + key + for key in self.module.EXPECTED_KEYS + if key.startswith("SOLMARA_ESIGNET_") + ] + (self.root / "compose.hosted.yaml").write_text( + "services:\n" + + "".join( + f" {index}:\n image: ${{" + key + ":?required}\n" + for index, key in enumerate(core) + ), + encoding="utf-8", + ) + (self.root / "compose.coolify.esignet.yaml").write_text( + "services:\n" + + "".join( + f" {index}:\n image: ${{" + key + ":?required}\n" + for index, key in enumerate(esignet) + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.directory.cleanup() + + def invoke( + self, *arguments: str, environment: dict[str, str] | None = None + ) -> tuple[int, str]: + stderr = io.StringIO() + with ( + mock.patch.dict(os.environ, environment or {}, clear=True), + contextlib.redirect_stderr(stderr), + ): + result = self.module.main(["--compose-root", str(self.root), *arguments]) + return result, stderr.getvalue() + + def test_write_is_canonical_deterministic_and_sanitized(self) -> None: + supplied = {**self.environment, "COOLIFY_API_TOKEN": "must-not-leave-process"} + result, stderr = self.invoke( + "write", "--output", str(self.manifest), environment=supplied + ) + self.assertEqual(result, 0, stderr) + + expected = "".join( + f"{key}={self.environment[key]}\n" for key in self.module.EXPECTED_KEYS + ) + self.assertEqual(self.manifest.read_text(encoding="utf-8"), expected) + self.assertNotIn("must-not-leave-process", expected) + self.assertEqual(self.manifest.stat().st_mode & 0o777, 0o644) + + result, stderr = self.invoke( + "write", "--output", str(self.manifest), environment=supplied + ) + self.assertEqual(result, 0, stderr) + self.assertEqual(self.manifest.read_text(encoding="utf-8"), expected) + + def test_every_image_must_use_its_exact_ghcr_repository_and_digest(self) -> None: + invalid_values = ( + "ghcr.io/registrystack/evidence:candidate", + f"docker.io/registrystack/evidence@sha256:{'b' * 64}", + f"ghcr.io/registrystack/mint@sha256:{'b' * 64}", + f"ghcr.io/registrystack/solmara-lab-evidence@sha256:{'b' * 64}", + f"ghcr.io/registrystack/evidence@sha256:{'B' * 64}", + f"ghcr.io/registrystack/evidence@sha256:{'b' * 63}", + ) + for invalid in invalid_values: + with self.subTest(invalid=invalid): + environment = {**self.environment, "SOLMARA_EVIDENCE_IMAGE": invalid} + result, stderr = self.invoke( + "write", "--output", str(self.manifest), environment=environment + ) + self.assertEqual(result, 1) + self.assertIn("SOLMARA_EVIDENCE_IMAGE must", stderr) + + def test_relay_must_use_the_canonical_official_reference(self) -> None: + invalid = ( + "ghcr.io/registrystack/relay:v0.22.0", + f"ghcr.io/registrystack/solmara-lab-relay@sha256:{'1' * 64}", + f"ghcr.io/registrystack/relay@sha256:{'A' * 64}", + ) + for value in invalid: + with self.subTest(value=value): + environment = {**self.environment, "REGISTRY_RELAY_IMAGE": value} + result, stderr = self.invoke( + "write", "--output", str(self.manifest), environment=environment + ) + self.assertEqual(result, 1) + self.assertIn("REGISTRY_RELAY_IMAGE must", stderr) + + def test_missing_image_fails_without_writing_a_partial_manifest(self) -> None: + environment = dict(self.environment) + del environment["SOLMARA_PORTAL_IMAGE"] + result, stderr = self.invoke( + "write", "--output", str(self.manifest), environment=environment + ) + self.assertEqual(result, 1) + self.assertIn("environment is missing SOLMARA_PORTAL_IMAGE", stderr) + self.assertFalse(self.manifest.exists()) + + def test_validator_refuses_extra_keys_and_noncanonical_order(self) -> None: + canonical = self.module.render_manifest(self.environment) + cases = ( + canonical + f"SOLMARA_DATABASE_PASSWORD={'b' * 64}\n", + "\n".join(reversed(canonical.rstrip("\n").splitlines())) + "\n", + ) + for index, content in enumerate(cases): + with self.subTest(index=index): + self.manifest.parent.mkdir(parents=True, exist_ok=True) + self.manifest.write_text(content, encoding="utf-8") + result, stderr = self.invoke( + "validate", "--manifest", str(self.manifest) + ) + self.assertEqual(result, 1) + self.assertTrue( + "unexpected key" in stderr or "canonical order" in stderr, + stderr, + ) + + def test_validator_refuses_crlf_and_missing_final_newline(self) -> None: + canonical = self.module.render_manifest(self.environment) + cases = ( + canonical.replace("\n", "\r\n").encode("utf-8"), + canonical.rstrip("\n").encode("utf-8"), + ) + for index, content in enumerate(cases): + with self.subTest(index=index): + self.manifest.parent.mkdir(parents=True, exist_ok=True) + self.manifest.write_bytes(content) + result, stderr = self.invoke( + "validate", "--manifest", str(self.manifest) + ) + self.assertEqual(result, 1) + self.assertIn("manifest must", stderr) + + def test_compose_inventory_is_closed_and_requires_variables(self) -> None: + hosted = self.root / "compose.hosted.yaml" + hosted.write_text( + hosted.read_text(encoding="utf-8") + + " unexpected:\n image: ${SOLMARA_OTHER_IMAGE:?required}\n", + encoding="utf-8", + ) + result, stderr = self.invoke("inventory") + self.assertEqual(result, 1) + self.assertIn("unexpected SOLMARA_OTHER_IMAGE", stderr) + + hosted.write_text( + hosted.read_text(encoding="utf-8").replace( + "${SOLMARA_OTHER_IMAGE:?required}", "${SOLMARA_OTHER_IMAGE:-latest}" + ), + encoding="utf-8", + ) + result, stderr = self.invoke("inventory") + self.assertEqual(result, 1) + self.assertIn("must be required variables", stderr) + + def test_release_workflow_generates_validates_and_uploads_the_manifest( + self, + ) -> None: + workflow_path = ROOT / ".github" / "workflows" / "release-candidate.yml" + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + steps = workflow["jobs"]["verify-and-publish"]["steps"] + names = [step.get("name") for step in steps] + self.assertNotIn("Build and push verified Evidence release binary", names) + self.assertNotIn("Build and push verified Mint release binary", names) + self.assertNotIn("Verify Solmara Evidence and Mint image source labels", names) + read_pins = next( + step + for step in steps + if step.get("name") == "Read immutable Registry Stack source identity" + )["run"] + self.assertIn("SOLMARA_EVIDENCE_IMAGE SOLMARA_MINT_IMAGE", read_pins) + runtime_verification = next( + step + for step in steps + if step.get("name") + == "Verify official Registry Stack runtime images and Relayctl" + )["run"] + self.assertIn("for component in relay evidence mint", runtime_verification) + self.assertIn( + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256", runtime_verification + ) + self.assertNotIn("REGISTRY_STACK_RELEASE_EVIDENCE_ASSET", runtime_verification) + self.assertNotIn("REGISTRY_STACK_RELEASE_MINT_ASSET", runtime_verification) + provisioner_index = names.index("Build and push authority provisioner") + signer_index = names.index("Build and push Transit signer") + generate_index = names.index("Generate Coolify image manifest") + upload_index = names.index("Upload Coolify image manifest") + self.assertLess(provisioner_index, generate_index) + self.assertLess(signer_index, generate_index) + self.assertGreater( + generate_index, names.index("Build and push eSignet seed image") + ) + self.assertGreater(upload_index, generate_index) + + provisioner = steps[provisioner_index] + self.assertEqual(provisioner["id"], "authority_provisioner") + self.assertEqual( + provisioner["with"]["file"], + "docker/hosted-authority-provisioner/Dockerfile", + ) + self.assertIn( + "/solmara-lab-authority-provisioner:", + provisioner["with"]["tags"], + ) + self.assertIn( + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL=${{ env.REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL }}", + provisioner["with"]["build-args"], + ) + self.assertIn( + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256=${{ env.REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256 }}", + provisioner["with"]["build-args"], + ) + signer = steps[signer_index] + self.assertEqual(signer["id"], "transit_signer") + self.assertEqual( + signer["with"]["file"], "docker/hosted-transit-signer/Dockerfile" + ) + self.assertIn("/solmara-lab-transit-signer:", signer["with"]["tags"]) + for step in (provisioner, signer): + self.assertIn( + "org.opencontainers.image.revision=${{ github.sha }}", + step["with"]["labels"], + ) + self.assertIn( + "org.opencontainers.image.source=https://github.com/registrystack/solmara-lab", + step["with"]["labels"], + ) + + label_verification = next( + step + for step in steps + if step.get("name") == "Verify hosted authority image source labels" + )["run"] + self.assertIn("steps.authority_provisioner.outputs.digest", label_verification) + self.assertIn("steps.transit_signer.outputs.digest", label_verification) + self.assertIn("org.opencontainers.image.revision", label_verification) + + provisioner_smoke = next( + step + for step in steps + if step.get("name") == "Smoke hosted authority provisioner image" + )["run"] + self.assertEqual(provisioner_smoke.count("--platform linux/amd64"), 3) + self.assertEqual(provisioner_smoke.count("--user 0:0"), 3) + expected_capability_uses = {"CHOWN": 2, "DAC_OVERRIDE": 3, "FOWNER": 3} + for capability, uses in expected_capability_uses.items(): + self.assertEqual(provisioner_smoke.count(f"--cap-add {capability}"), uses) + self.assertIn("org.registrystack.release.revision", label_verification) + + provisioner_smoke = next( + step + for step in steps + if step.get("name") == "Smoke hosted authority provisioner image" + )["run"] + self.assertIn("steps.authority_provisioner.outputs.digest", provisioner_smoke) + self.assertIn("--network none --read-only", provisioner_smoke) + self.assertNotIn("--secrets", provisioner_smoke) + self.assertIn('test "$status" -eq 1', provisioner_smoke) + self.assertIn("hosted target provisioning failed", provisioner_smoke) + self.assertNotIn('chmod u+w "$state/runtime/runtime.yaml"', provisioner_smoke) + self.assertIn("--entrypoint python", provisioner_smoke) + self.assertIn("--cap-add DAC_OVERRIDE --cap-add FOWNER", provisioner_smoke) + self.assertEqual( + provisioner_smoke.count( + "--mint-origin https://mint-authority-cells.solmara.registrystack.org" + ), + 2, + ) + + signer_smoke = next( + step + for step in steps + if step.get("name") == "Smoke hosted Transit signer image" + )["run"] + self.assertIn("steps.transit_signer.outputs.digest", signer_smoke) + self.assertIn("--network none --read-only --entrypoint python", signer_smoke) + self.assertIn("import cryptography", signer_smoke) + self.assertIn("--public-jwk, /tmp/solmara-signing-public.jwk", signer_smoke) + self.assertIn("v1/transit/keys/solmara-evidence-cra", signer_smoke) + self.assertIn( + "signing-private: {environment: SIGNING_PRIVATE_JWK}", signer_smoke + ) + self.assertIn("target: /tmp/solmara-signing.jwk", signer_smoke) + self.assertIn( + "transit-init: {condition: service_completed_successfully}", signer_smoke + ) + self.assertIn('docker compose -p "$project"', signer_smoke) + self.assertIn("up -d --wait --wait-timeout 60", signer_smoke) + self.assertIn('not Path("/tmp/solmara-signing.jwk").exists()', signer_smoke) + self.assertIn('not glob.glob("/tmp/solmara-transit-*")', signer_smoke) + self.assertIn('client.connect("/transit/transit-proxy.sock")', signer_smoke) + self.assertNotIn("--unix-socket", signer_smoke) + self.assertNotIn("$state/transit", signer_smoke) + self.assertNotIn("type=bind", signer_smoke) + + packages = next( + step + for step in steps + if step.get("name") == "Require pre-provisioned public Solmara packages" + )["run"] + self.assertIn("solmara-lab-authority-provisioner", packages) + self.assertIn("solmara-lab-transit-signer", packages) + self.assertNotIn("solmara-lab-evidence", packages) + self.assertNotIn("solmara-lab-mint", packages) + + generate = steps[generate_index] + self.assertIn("hosted-image-manifest.py write", generate["run"]) + self.assertIn("hosted-image-manifest.py validate", generate["run"]) + self.assertEqual(set(generate["env"]), set(self.module.EXPECTED_KEYS)) + self.assertEqual( + generate["env"]["REGISTRY_RELAY_IMAGE"], + "${{ env.REGISTRY_RELAY_IMAGE }}", + ) + self.assertEqual( + generate["env"]["SOLMARA_EVIDENCE_IMAGE"], + "${{ env.SOLMARA_EVIDENCE_IMAGE }}", + ) + self.assertEqual( + generate["env"]["SOLMARA_MINT_IMAGE"], + "${{ env.SOLMARA_MINT_IMAGE }}", + ) + self.assertEqual( + generate["env"]["SOLMARA_AUTHORITY_PROVISIONER_IMAGE"], + "${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-authority-provisioner@${{ steps.authority_provisioner.outputs.digest }}", + ) + self.assertEqual( + generate["env"]["SOLMARA_TRANSIT_SIGNER_IMAGE"], + "${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-transit-signer@${{ steps.transit_signer.outputs.digest }}", + ) + + upload = steps[upload_index] + self.assertEqual( + upload["uses"], + "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", + ) + self.assertEqual(upload["with"]["if-no-files-found"], "error") + self.assertIn("solmara-hosted-images.env", upload["with"]["path"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_network_isolation.py b/scripts/test_hosted_network_isolation.py new file mode 100644 index 0000000..9b1f496 --- /dev/null +++ b/scripts/test_hosted_network_isolation.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import ipaddress +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +PROVISION_PATH = ROOT / "compose.coolify.provision.yaml" +RUNTIME_PATHS = { + "core": ROOT / "compose.coolify.yaml", + "interior": ROOT / "compose.coolify.interior.yaml", + "social": ROOT / "compose.coolify.social-development.yaml", + "pensions": ROOT / "compose.coolify.labour-pensions.yaml", + "agriculture": ROOT / "compose.coolify.agriculture.yaml", +} +EXPECTED_SUBNETS = { + "core": "172.29.1.0/24", + "interior": "172.29.2.0/24", + "social": "172.29.3.0/24", + "pensions": "172.29.4.0/24", + "agriculture": "172.29.5.0/24", +} +BINDINGS = { + "mint-provisioner": ("core", "mint", "172.29.1.20"), + "cra-evidence-provisioner": ("interior", "cra-evidence", "172.29.2.21"), + "nia-evidence-provisioner": ("interior", "nia-evidence", "172.29.2.22"), + "sro-evidence-provisioner": ("social", "sro-evidence", "172.29.3.23"), + "mosd-evidence-provisioner": ( + "social", + "mosd-programme-evidence", + "172.29.3.24", + ), + "sipf-evidence-provisioner": ("pensions", "sipf-evidence", "172.29.4.25"), + "nagdi-evidence-provisioner": ( + "agriculture", + "nagdi-evidence", + "172.29.5.26", + ), +} + + +class HostedNetworkIsolationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.provision = yaml.safe_load(PROVISION_PATH.read_text(encoding="utf-8")) + cls.runtime = { + name: yaml.safe_load(path.read_text(encoding="utf-8")) + for name, path in RUNTIME_PATHS.items() + } + + def test_runtime_apps_use_distinct_expected_private_subnets(self) -> None: + networks = {} + for name, compose in self.runtime.items(): + subnet_text = compose["networks"]["runtime"]["ipam"]["config"][0]["subnet"] + self.assertEqual(subnet_text, EXPECTED_SUBNETS[name]) + network = ipaddress.ip_network(subnet_text) + self.assertTrue(network.is_private) + networks[name] = network + + for name, network in networks.items(): + for other_name, other_network in networks.items(): + if name >= other_name: + continue + self.assertFalse( + network.overlaps(other_network), + f"{name} {network} overlaps {other_name} {other_network}", + ) + + def test_all_bind_hosts_match_their_runtime_addresses(self) -> None: + services_with_bind_hosts = { + name + for name, service in self.provision["services"].items() + if "--bind-host" in service.get("command", []) + } + self.assertEqual(services_with_bind_hosts, set(BINDINGS)) + + for provisioner_name, ( + app, + service_name, + expected_address, + ) in BINDINGS.items(): + command = self.provision["services"][provisioner_name]["command"] + bind_host = command[command.index("--bind-host") + 1] + runtime_address = self.runtime[app]["services"][service_name]["networks"][ + "runtime" + ]["ipv4_address"] + self.assertEqual(bind_host, expected_address) + self.assertEqual(runtime_address, expected_address) + self.assertIn( + ipaddress.ip_address(expected_address), + ipaddress.ip_network(EXPECTED_SUBNETS[app]), + ) + + def test_provisioning_services_remain_networkless(self) -> None: + self.assertNotIn("networks", self.provision) + for service_name, service in self.provision["services"].items(): + self.assertEqual(service.get("network_mode"), "none", service_name) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_provisioning_topology.py b/scripts/test_hosted_provisioning_topology.py new file mode 100644 index 0000000..bde5214 --- /dev/null +++ b/scripts/test_hosted_provisioning_topology.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +PROVISION = ROOT / "compose.coolify.provision.yaml" +SIGNERS = ROOT / "compose.coolify.signers.yaml" +RUNTIME_COMPOSES = ( + ROOT / "compose.coolify.yaml", + ROOT / "compose.coolify.interior.yaml", + ROOT / "compose.coolify.social-development.yaml", + ROOT / "compose.coolify.labour-pensions.yaml", + ROOT / "compose.coolify.agriculture.yaml", +) +PROVIDERS = ("mint", "cra", "nia", "sro", "mosd", "sipf", "nagdi") +RELAYS = ("cra", "nia", "mosd", "sipf", "nagdi") +EVIDENCE_CLIENTS = { + "cra": {"cra-pension-evidence", "cra-citizen-evidence"}, + "nia": set(), + "sro": set(), + "mosd": {"mosd-child-benefit-evidence"}, + "sipf": {"sipf-pension-evidence", "sipf-survivor-evidence"}, + "nagdi": {"nagdi-voucher-evidence", "nagdi-livestock-evidence"}, +} +MINT_CLIENTS = { + *(client for clients in EVIDENCE_CLIENTS.values() for client in clients), + "nia-esignet", +} +TARGET_PROVISIONERS = { + *(f"{authority}-relay-provisioner" for authority in RELAYS), + *(f"{provider}-evidence-provisioner" for provider in PROVIDERS[1:]), + "mint-provisioner", +} +INTERPOLATION = re.compile(r"^\$\{([A-Z][A-Z0-9_]*):\?[^}]+\}$") +FIXTURE_IMAGE = "ghcr.io/registrystack/solmara-test@sha256:" + "a" * 64 +FIXTURE_PRIVATE_JWK_MEMBERS = { + "kty": "EC", + "crv": "P-384", + "x": "A" * 64, + "y": "B" * 64, + "d": "C" * 64, + "kid": "solmara-test", + "alg": "ES384", +} +FIXTURE_PRIVATE_JWK = json.dumps(FIXTURE_PRIVATE_JWK_MEMBERS, separators=(",", ":")) +FIXTURE_PUBLIC_JWK = json.dumps( + {key: value for key, value in FIXTURE_PRIVATE_JWK_MEMBERS.items() if key != "d"}, + separators=(",", ":"), +) +MINT_ORIGIN = "https://mint-authority-cells.solmara.registrystack.org" +RELAY_ORIGINS = { + "cra": "https://cra-relay-authority-cells.solmara.registrystack.org", + "mosd": "https://mosd-programme-relay-authority-cells.solmara.registrystack.org", + "sipf": "https://sipf-relay-authority-cells.solmara.registrystack.org", + "nagdi": "https://nagdi-relay-authority-cells.solmara.registrystack.org", +} + + +def fixture_render(value): + if isinstance(value, dict): + return {key: fixture_render(item) for key, item in value.items()} + if isinstance(value, list): + return [fixture_render(item) for item in value] + if not isinstance(value, str): + return value + match = INTERPOLATION.fullmatch(value) + if match is None: + return value + variable = match.group(1) + if variable.endswith("_IMAGE"): + return FIXTURE_IMAGE + if variable.endswith("_PUBLIC_JWK"): + return FIXTURE_PUBLIC_JWK + if variable.endswith("_PRIVATE_JWK") or variable.endswith("_SIGNING_JWK"): + return FIXTURE_PRIVATE_JWK + return "fixture-value" + + +class HostedProvisioningTopologyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.provision = yaml.safe_load(PROVISION.read_text(encoding="utf-8")) + cls.signers = yaml.safe_load(SIGNERS.read_text(encoding="utf-8")) + cls.runtime = { + path.name: yaml.safe_load(path.read_text(encoding="utf-8")) + for path in RUNTIME_COMPOSES + } + + def test_provisioning_application_owns_exactly_34_active_volumes(self) -> None: + volumes = self.provision["volumes"] + self.assertEqual(len(volumes), 34) + self.assertTrue( + all(not value.get("external", False) for value in volumes.values()) + ) + self.assertTrue( + all( + value["name"].startswith("solmara-authority-cells-") + for value in volumes.values() + ) + ) + + def test_provisioning_application_contains_only_target_provisioners(self) -> None: + services = self.provision["services"] + self.assertEqual(set(services), {*TARGET_PROVISIONERS, "provisioning-ready"}) + self.assertNotIn("SOLMARA_TRANSIT_SIGNER_IMAGE", PROVISION.read_text()) + self.assertNotIn("--private-jwk", PROVISION.read_text()) + private_signing_secrets = { + "mint-signing-jwk", + *(f"{provider}-evidence-signing-jwk" for provider in PROVIDERS[1:]), + } + self.assertTrue(private_signing_secrets.isdisjoint(self.provision["secrets"])) + + def test_runtime_applications_attach_active_volumes_read_only_by_fixed_name( + self, + ) -> None: + provisioned = {value["name"] for value in self.provision["volumes"].values()} + attached: set[str] = set() + for compose in self.runtime.values(): + for key, value in compose.get("volumes", {}).items(): + if not value or not value.get("external"): + continue + name = value.get("name", "") + if name in provisioned: + attached.add(name) + for service in compose["services"].values(): + for mount in service.get("volumes", []): + if isinstance(mount, str) and mount.startswith(f"{key}:"): + self.assertTrue(mount.endswith(":ro"), mount) + self.assertEqual(attached, provisioned) + + def test_private_signing_keys_are_mounted_only_into_matching_signers(self) -> None: + services = self.signers["services"] + for provider in PROVIDERS: + secret = ( + "mint-signing-jwk" + if provider == "mint" + else f"{provider}-evidence-signing-jwk" + ) + consumers = { + service_name + for service_name, service in services.items() + if any(item["source"] == secret for item in service.get("secrets", [])) + } + self.assertEqual(consumers, {f"{provider}-signer"}) + for name, service in self.provision["services"].items(): + if name.endswith("provisioner"): + targets = {item["target"] for item in service.get("secrets", [])} + self.assertNotIn("solmara-provisioning/signing.jwk", targets) + + def test_each_signer_requires_its_matching_public_projection(self) -> None: + services = self.signers["services"] + for provider in PROVIDERS: + prefix = "mint" if provider == "mint" else f"{provider}-evidence" + signer = services[f"{provider}-signer"] + secrets = {item["target"]: item["source"] for item in signer["secrets"]} + self.assertEqual(len(secrets), 2) + self.assertEqual( + secrets["/tmp/solmara-signing.jwk"], f"{prefix}-signing-jwk" + ) + self.assertEqual( + secrets["/tmp/solmara-signing-public.jwk"], + f"{prefix}-signing-public-jwk", + ) + self.assertEqual( + signer["command"][0:4], + [ + "--private-jwk", + "/tmp/solmara-signing.jwk", + "--public-jwk", + "/tmp/solmara-signing-public.jwk", + ], + ) + + def test_mint_client_public_keys_use_provisioner_contract_names(self) -> None: + mint = self.provision["services"]["mint-provisioner"] + targets = {secret["target"] for secret in mint["secrets"]} + clients = ( + "cra-pension-evidence", + "cra-citizen-evidence", + "mosd-child-benefit-evidence", + "sipf-pension-evidence", + "sipf-survivor-evidence", + "nagdi-voucher-evidence", + "nagdi-livestock-evidence", + "nia-esignet", + ) + for client in clients: + self.assertIn(f"/tmp/solmara-provisioning/{client}-public.jwk", targets) + self.assertIn( + "/tmp/solmara-provisioning/solmara-demo-client-public.jwk", targets + ) + + def test_each_evidence_provisioner_receives_only_its_public_signing_key( + self, + ) -> None: + services = self.provision["services"] + for provider in PROVIDERS[1:]: + provisioner = services[f"{provider}-evidence-provisioner"] + sources = {item["source"] for item in provisioner["secrets"]} + self.assertIn(f"{provider}-evidence-signing-public-jwk", sources) + self.assertFalse(any(source.endswith("-signing-jwk") for source in sources)) + foreign_prefixes = { + f"{other}-evidence-" for other in PROVIDERS[1:] if other != provider + } + self.assertFalse( + any(source.startswith(tuple(foreign_prefixes)) for source in sources), + (provider, sources), + ) + + def test_provisioner_secret_inventory_is_exact(self) -> None: + consumed_sources: set[str] = set() + for provider in PROVIDERS[1:]: + service = self.provision["services"][f"{provider}-evidence-provisioner"] + targets = { + Path(item["target"]).name: item["source"] for item in service["secrets"] + } + expected = { + "signing-public.jwk": f"{provider}-evidence-signing-public-jwk", + "audit-hmac-key": f"{provider}-evidence-audit-hmac-key", + "subject-binding-hmac-key": ( + f"{provider}-evidence-subject-binding-hmac-key" + ), + **{ + f"{client}-client-key": f"{client}-client-key" + for client in EVIDENCE_CLIENTS[provider] + }, + } + self.assertEqual(targets, expected) + consumed_sources.update(targets.values()) + + mint = self.provision["services"]["mint-provisioner"] + mint_targets = { + Path(item["target"]).name: item["source"] for item in mint["secrets"] + } + expected_mint = { + "signing-public.jwk": "mint-signing-public-jwk", + "audit-hmac-key": "mint-audit-hmac-key", + "solmara-demo-client-public.jwk": "solmara-demo-client-public-jwk", + **{ + f"{client}-public.jwk": f"{client}-client-public-jwk" + for client in MINT_CLIENTS + }, + } + self.assertEqual(mint_targets, expected_mint) + consumed_sources.update(mint_targets.values()) + self.assertEqual(set(self.provision["secrets"]), consumed_sources) + + def test_relay_provisioners_receive_no_secret(self) -> None: + services = self.provision["services"] + for authority in RELAYS: + relay = services[f"{authority}-relay-provisioner"] + self.assertNotIn("secrets", relay) + self.assertNotIn("--secrets", relay["command"]) + + def test_provisioners_only_elevate_for_authority_volume_initialization( + self, + ) -> None: + for name, service in self.provision["services"].items(): + if not name.endswith("-provisioner"): + continue + self.assertEqual(service["user"], "0:0") + self.assertEqual(service["network_mode"], "none") + self.assertEqual(service["cap_drop"], ["ALL"]) + self.assertEqual( + set(service["cap_add"]), {"CHOWN", "DAC_OVERRIDE", "FOWNER"} + ) + self.assertEqual(service["security_opt"], ["no-new-privileges:true"]) + if service.get("secrets"): + self.assertFalse(service["read_only"]) + self.assertNotIn("tmpfs", service) + self.assertEqual( + service["command"][service["command"].index("--secrets") + 1], + "/tmp/solmara-provisioning", + ) + for secret in service["secrets"]: + self.assertTrue( + secret["target"].startswith("/tmp/solmara-provisioning/") + ) + self.assertEqual(secret["uid"], "0") + self.assertEqual(secret["gid"], "0") + self.assertEqual(secret["mode"], 0o400) + else: + self.assertTrue(service["read_only"]) + self.assertEqual(service["tmpfs"], "/tmp") + + def test_provisioning_readiness_requires_all_targets_to_complete(self) -> None: + readiness = self.provision["services"]["provisioning-ready"] + self.assertEqual( + readiness["depends_on"], + { + target: {"condition": "service_completed_successfully"} + for target in TARGET_PROVISIONERS + }, + ) + self.assertEqual(readiness["command"], ["ready"]) + self.assertEqual(readiness["user"], "65532:65532") + self.assertEqual(readiness["network_mode"], "none") + self.assertTrue(readiness["read_only"]) + self.assertEqual(readiness["tmpfs"], "/tmp") + self.assertEqual(readiness["cap_drop"], ["ALL"]) + self.assertNotIn("cap_add", readiness) + self.assertNotIn("secrets", readiness) + self.assertNotIn("volumes", readiness) + + def test_provisioners_receive_closed_permanent_dependency_origins(self) -> None: + services = self.provision["services"] + for name, service in services.items(): + if not name.endswith("provisioner"): + continue + command = service["command"] + self.assertEqual(command[command.index("--mint-origin") + 1], MINT_ORIGIN) + if name.endswith("-evidence-provisioner"): + provider = name.removesuffix("-evidence-provisioner") + if provider in RELAY_ORIGINS: + self.assertEqual( + command[command.index("--relay-origin") + 1], + RELAY_ORIGINS[provider], + ) + else: + self.assertNotIn("--relay-origin", command) + else: + self.assertNotIn("--relay-origin", command) + + def test_evidence_bind_hosts_match_their_isolated_runtime_networks(self) -> None: + runtime_services = { + "cra": self.runtime["compose.coolify.interior.yaml"]["services"][ + "cra-evidence" + ], + "nia": self.runtime["compose.coolify.interior.yaml"]["services"][ + "nia-evidence" + ], + "sro": self.runtime["compose.coolify.social-development.yaml"]["services"][ + "sro-evidence" + ], + "mosd": self.runtime["compose.coolify.social-development.yaml"]["services"][ + "mosd-programme-evidence" + ], + "sipf": self.runtime["compose.coolify.labour-pensions.yaml"]["services"][ + "sipf-evidence" + ], + "nagdi": self.runtime["compose.coolify.agriculture.yaml"]["services"][ + "nagdi-evidence" + ], + } + for authority, runtime in runtime_services.items(): + provisioner = self.provision["services"][ + f"{authority}-evidence-provisioner" + ] + command = provisioner["command"] + self.assertEqual( + command[command.index("--bind-host") + 1], + runtime["networks"]["runtime"]["ipv4_address"], + ) + + def test_each_signer_and_transit_initializer_mount_only_its_matching_volume( + self, + ) -> None: + services = self.signers["services"] + for provider in PROVIDERS: + volume = ( + "mint-transit" if provider == "mint" else f"{provider}-evidence-transit" + ) + self.assertEqual( + services[f"{provider}-transit-init"]["volumes"], [f"{volume}:/transit"] + ) + signer = services[f"{provider}-signer"] + self.assertEqual(signer["volumes"], [f"{volume}:/transit"]) + self.assertEqual( + signer["depends_on"], + { + f"{provider}-transit-init": { + "condition": "service_completed_successfully" + } + }, + ) + + def test_signer_application_has_exact_services_secrets_and_external_volumes( + self, + ) -> None: + expected_services = { + *(f"{provider}-transit-init" for provider in PROVIDERS), + *(f"{provider}-signer" for provider in PROVIDERS), + "signers-ready", + } + self.assertEqual(set(self.signers["services"]), expected_services) + + expected_secrets: set[str] = set() + expected_volumes: set[str] = set() + for provider in PROVIDERS: + prefix = "mint" if provider == "mint" else f"{provider}-evidence" + expected_secrets.update( + {f"{prefix}-signing-jwk", f"{prefix}-signing-public-jwk"} + ) + expected_volumes.add(f"{prefix}-transit") + self.assertEqual(set(self.signers["secrets"]), expected_secrets) + self.assertEqual(set(self.signers["volumes"]), expected_volumes) + self.assertTrue( + all( + value + == { + "external": True, + "name": f"solmara-authority-cells-{key}", + } + for key, value in self.signers["volumes"].items() + ) + ) + + readiness = self.signers["services"]["signers-ready"] + self.assertEqual( + readiness["depends_on"], + { + f"{provider}-signer": {"condition": "service_healthy"} + for provider in PROVIDERS + }, + ) + self.assertEqual(readiness["network_mode"], "none") + self.assertTrue(readiness["read_only"]) + self.assertEqual(readiness["cap_drop"], ["ALL"]) + + self.assertEqual( + {service["image"] for service in self.signers["services"].values()}, + { + "${SOLMARA_TRANSIT_SIGNER_IMAGE:?set the digest-pinned Solmara Transit signer image}" + }, + ) + + def test_rendered_operator_applications_fit_coolify_payload_limit(self) -> None: + for path, compose in ( + (PROVISION, self.provision), + (SIGNERS, self.signers), + ): + with self.subTest(compose=path.name): + rendered = yaml.safe_dump( + fixture_render(compose), sort_keys=False + ).encode("utf-8") + self.assertLess(len(rendered), 65_536) + + def test_signer_application_preserves_process_confinement(self) -> None: + services = self.signers["services"] + for provider in PROVIDERS: + initializer = services[f"{provider}-transit-init"] + self.assertEqual(initializer["user"], "0:0") + self.assertEqual(initializer["network_mode"], "none") + self.assertTrue(initializer["read_only"]) + self.assertEqual(initializer["cap_drop"], ["ALL"]) + self.assertEqual(set(initializer["cap_add"]), {"CHOWN", "FOWNER"}) + self.assertEqual(initializer["security_opt"], ["no-new-privileges:true"]) + + signer = services[f"{provider}-signer"] + self.assertEqual(signer["user"], "65532:65532") + self.assertEqual(signer["network_mode"], "none") + self.assertFalse(signer["read_only"]) + self.assertNotIn("tmpfs", signer) + self.assertEqual(signer["cap_drop"], ["ALL"]) + self.assertNotIn("cap_add", signer) + self.assertEqual(signer["security_opt"], ["no-new-privileges:true"]) + self.assertEqual(signer["healthcheck"]["retries"], 30) + + def test_relay_runtime_secrets_are_authority_scoped(self) -> None: + services = {} + for compose in self.runtime.values(): + services.update(compose["services"]) + for authority in RELAYS: + environment = services[f"{authority}-relay"]["environment"] + expected = { + "SOLMARA_RELAY_AUDIT_KEY": f"${{{authority.upper()}_RELAY_AUDIT_KEY:?required}}" + } + if authority in {"sipf", "nagdi"}: + expected["SOLMARA_RELAY_CURSOR_KEY"] = ( + f"${{{authority.upper()}_RELAY_CURSOR_KEY:?required}}" + ) + self.assertEqual(environment, expected) + + def test_runtime_consumers_depend_on_app_local_audit_init(self) -> None: + for compose in self.runtime.values(): + services = compose["services"] + audit_init = services["audit-permissions"] + self.assertEqual(audit_init["network_mode"], "none") + self.assertEqual(audit_init["cap_add"], ["CHOWN", "FOWNER"]) + for name, service in services.items(): + if name in { + "audit-permissions", + "mint-readiness", + "static-metadata", + "scenario-runner", + "child-benefit-federator", + "home", + "portal", + }: + continue + self.assertEqual( + service["depends_on"]["audit-permissions"], + {"condition": "service_completed_successfully"}, + name, + ) + + def test_core_deployment_waits_for_private_mint_health(self) -> None: + core = self.runtime["compose.coolify.yaml"]["services"] + readiness = core["mint-readiness"] + self.assertEqual( + readiness["depends_on"], {"mint": {"condition": "service_started"}} + ) + self.assertEqual(readiness["networks"], ["runtime"]) + self.assertEqual(readiness["cap_drop"], ["ALL"]) + self.assertTrue(readiness["read_only"]) + self.assertNotIn("secrets", readiness) + self.assertIn("172.29.1.20:8081/health", readiness["command"][0]) + self.assertEqual( + core["scenario-runner"]["depends_on"], + {"mint-readiness": {"condition": "service_completed_successfully"}}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_relay_bundles.py b/scripts/test_hosted_relay_bundles.py deleted file mode 100644 index df3a34b..0000000 --- a/scripts/test_hosted_relay_bundles.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import importlib.util -import sys -import tempfile -import unittest -from pathlib import Path - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] - - -def load_checker(): - spec = importlib.util.spec_from_file_location( - "check_hosted_relay_bundles", - ROOT / "scripts" / "check-hosted-relay-bundles.py", - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load check-hosted-relay-bundles.py") - module = importlib.util.module_from_spec(spec) - sys.modules["check_hosted_relay_bundles"] = module - spec.loader.exec_module(module) - return module - - -def load_generator(): - spec = importlib.util.spec_from_file_location( - "generate_hosted_relay_bundles", - ROOT / "scripts" / "generate-hosted-relay-bundles.py", - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load generate-hosted-relay-bundles.py") - module = importlib.util.module_from_spec(spec) - sys.modules["generate_hosted_relay_bundles"] = module - spec.loader.exec_module(module) - return module - - -class HostedRelayBundleTests(unittest.TestCase): - def test_expected_bundle_sequence_comes_from_versions_file(self) -> None: - checker = load_checker() - with tempfile.TemporaryDirectory() as temporary_directory: - checker.ROOT = Path(temporary_directory) - (checker.ROOT / "versions.env").write_text( - "SOLMARA_RELAY_BUNDLE_SEQUENCE=7\n", - encoding="utf-8", - ) - - self.assertEqual(checker.expected_bundle_sequence(), 7) - - def test_expected_bundle_sequence_must_be_positive(self) -> None: - checker = load_checker() - with tempfile.TemporaryDirectory() as temporary_directory: - checker.ROOT = Path(temporary_directory) - (checker.ROOT / "versions.env").write_text( - "SOLMARA_RELAY_BUNDLE_SEQUENCE=0\n", - encoding="utf-8", - ) - - with self.assertRaisesRegex(SystemExit, "must be a positive integer"): - checker.expected_bundle_sequence() - - def test_generator_uses_the_same_configured_bundle_sequence(self) -> None: - generator = load_generator() - with tempfile.TemporaryDirectory() as temporary_directory: - generator.ROOT = Path(temporary_directory) - (generator.ROOT / "versions.env").write_text( - "SOLMARA_RELAY_BUNDLE_SEQUENCE=7\n", - encoding="utf-8", - ) - - self.assertEqual(generator.configured_bundle_sequence(), 7) - - def test_generator_accepts_a_1password_private_jwk_reference(self) -> None: - generator = load_generator() - reference = "op://vault-id/item-id/private_jwk" - self.assertEqual( - generator.validate_private_jwk_reference(reference), - reference, - ) - - def test_generator_rejects_an_empty_1password_private_jwk_reference(self) -> None: - generator = load_generator() - with self.assertRaisesRegex( - SystemExit, - "invalid 1Password private JWK reference", - ): - generator.validate_private_jwk_reference("op://") - - def test_generator_rejects_private_material_in_the_public_jwk(self) -> None: - generator = load_generator() - with tempfile.TemporaryDirectory() as temporary_directory: - public_jwk = Path(temporary_directory) / "public.jwk" - public_jwk.write_text('{"kty":"OKP","d":"private"}\n', encoding="utf-8") - with self.assertRaisesRegex(SystemExit, "expected a public-only JWK"): - generator.validate_public_jwk(public_jwk) - - def test_public_and_consultation_bundle_variants_are_explicit(self) -> None: - generator = load_generator() - checker = load_checker() - expected = ( - ("public", "relay.yaml", "", False), - ("consultation", "relay-consultation.yaml", "consultation", True), - ) - self.assertEqual(generator.BUNDLE_VARIANTS, expected) - self.assertEqual(checker.BUNDLE_VARIANTS, expected) - - def test_governed_config_uses_variant_specific_trust_paths(self) -> None: - generator = load_generator() - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - source = root / "relay-consultation.yaml" - destination = root / "relay.yaml" - source.write_text( - "instance:\n id: example-relay-consultation\nconsultation: {}\n", - encoding="utf-8", - ) - container_dir = ( - generator.CONTAINER_ROOT / "example" / "consultation" - ) - generator.write_governed_config( - source, - destination, - container_dir, - ) - governed = yaml.safe_load(destination.read_text(encoding="utf-8")) - self.assertEqual( - governed["config_trust"], - { - "trust_anchor_path": str(container_dir / "trust-anchor.json"), - "bundle_path": str(container_dir / "bundle"), - "antirollback_state_path": generator.ANTIROLLBACK_PATH, - }, - ) - - def test_artifact_closure_accepts_identical_complete_trees(self) -> None: - checker = load_checker() - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - bundled = root / "bundled" - source = root / "source" - for directory in (bundled, source): - (directory / "contracts").mkdir(parents=True) - (directory / "contracts" / "contract.json").write_text( - '{"version":1}\n', encoding="utf-8" - ) - - checker.verify_artifact_closure("example", bundled, source) - - def test_artifact_closure_rejects_changed_or_missing_files(self) -> None: - checker = load_checker() - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - bundled = root / "bundled" - source = root / "source" - bundled.mkdir() - source.mkdir() - (bundled / "contract.json").write_text("signed", encoding="utf-8") - (source / "contract.json").write_text("changed", encoding="utf-8") - - with self.assertRaisesRegex( - SystemExit, "signed artifact contract.json differs" - ): - checker.verify_artifact_closure("example", bundled, source) - - (source / "extra.json").write_text("extra", encoding="utf-8") - with self.assertRaisesRegex(SystemExit, "artifact paths differ"): - checker.verify_artifact_closure("example", bundled, source) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_hosted_runtime_assets.py b/scripts/test_hosted_runtime_assets.py new file mode 100644 index 0000000..644050f --- /dev/null +++ b/scripts/test_hosted_runtime_assets.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "hosted-runtime-assets.py" +SPEC = importlib.util.spec_from_file_location("hosted_runtime_assets", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +PROVISIONER_SCRIPT = ROOT / "scripts" / "provision-hosted-runtime.py" +PROVISIONER_SPEC = importlib.util.spec_from_file_location( + "provision_hosted_runtime", PROVISIONER_SCRIPT +) +assert PROVISIONER_SPEC and PROVISIONER_SPEC.loader +PROVISIONER = importlib.util.module_from_spec(PROVISIONER_SPEC) +PROVISIONER_SPEC.loader.exec_module(PROVISIONER) + + +class HostedRuntimeAssetTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) / "source" + self.root.mkdir() + self._copy_inventory( + ROOT / "generator" / "solmara_lab", + self.root / "generator" / "solmara_lab", + MODULE.GENERATOR_FILES, + ) + for authority in MODULE.AUTHORITIES: + self._copy_inventory( + ROOT / "relays" / authority, + self.root / "relays" / authority, + MODULE.RELAY_FILES[authority], + ) + for cell in MODULE.EVIDENCE_CELLS: + self._copy_inventory( + ROOT / "evidence" / "cells" / cell, + self.root / "evidence" / "cells" / cell, + MODULE.EVIDENCE_FILES[cell], + ) + mint = self.root / "evidence" / "mint.yaml" + mint.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / "evidence" / "mint.yaml", mint) + self.relayctl = Path(self.temporary.name) / "relayctl" + self.relayctl.write_text( + """#!/usr/bin/env python3 +import hashlib +import json +import os +import pathlib +import sys + +if os.environ.get("PYTHONDONTWRITEBYTECODE") != "1": + raise SystemExit(3) +project = pathlib.Path(sys.argv[sys.argv.index("package") + 1]) +output = pathlib.Path(sys.argv[sys.argv.index("--output") + 1]) +runtime = (project / "runtime.yaml").read_text() +if "path: source.sqlite" not in runtime or not (project / "source.sqlite").is_file(): + raise SystemExit(4) +digest = hashlib.sha256() +for path in sorted(project.rglob("*")): + if path.is_file(): + digest.update(path.relative_to(project).as_posix().encode()) + digest.update(path.read_bytes()) +output.mkdir(parents=True) +(output / "relay-package.json").write_text( + json.dumps({"projectDigest": digest.hexdigest()}, sort_keys=True) + "\\n" +) +""", + encoding="utf-8", + ) + self.relayctl.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + @staticmethod + def _copy_inventory(source: Path, destination: Path, files: frozenset[str]) -> None: + for relative in files: + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / relative, target) + + @staticmethod + def _digests(root: Path) -> dict[str, str]: + result = {} + for path in sorted(root.rglob("*")): + if path.is_file(): + result[path.relative_to(root).as_posix()] = MODULE._digest(path) + return result + + def _assert_no_path_leakage(self, root: Path) -> None: + forbidden = ( + str(self.root).encode(), + str(root).encode(), + str(Path(self.temporary.name)).encode(), + b"solmara-hosted-assets-", + ) + for path in root.rglob("*"): + if path.is_file(): + content = path.read_bytes() + for value in forbidden: + self.assertNotIn(value, content, path) + + def test_build_contains_only_closed_runtime_assets(self) -> None: + output = Path(self.temporary.name) / "assets" + MODULE.build(self.root, output, self.relayctl) + MODULE.verify_manifest(output) + + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + files = set(manifest["files"]) + expected_files = set() + for authority in MODULE.AUTHORITIES: + prefix = f"relays/{authority}" + expected_files.update( + { + f"{prefix}/runtime.yaml", + f"{prefix}/package/relay-package.json", + f"{prefix}/source/{authority}.sqlite", + } + ) + for cell in MODULE.EVIDENCE_CELLS: + expected_files.update( + f"evidence/cells/{cell}/{relative}" + for relative in MODULE.EVIDENCE_FILES[cell] + ) + expected_files.add("mint/mint.yaml") + expected_files.update( + f"generator/solmara_lab/{relative}" for relative in MODULE.GENERATOR_FILES + ) + self.assertEqual(files, expected_files) + self.assertFalse(any(path.endswith(".pyc") for path in files)) + self.assertFalse(any("/secrets/" in f"/{path}/" for path in files)) + self._assert_no_path_leakage(output) + + def test_unexpected_secret_symlink_and_bytecode_are_refused(self) -> None: + injections = ( + ("relays/cra/unexpected.yaml", b"UNEXPECTED-CANARY"), + ("evidence/cells/cra/bundle/signing.jwk", b"PRIVATE-JWK-CANARY"), + ("evidence/cells/nia/bundle/audit/events.jsonl", b"AUDIT-CANARY"), + ("evidence/cells/sro/bundle/extracts/source.sqlite", b"EXTRACT-CANARY"), + ("generator/solmara_lab/__pycache__/publisher.pyc", b"PYC-CANARY"), + ) + for relative, content in injections: + with self.subTest(relative=relative): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + with self.assertRaisesRegex(MODULE.AssetBuildError, "inventory"): + MODULE._validate_sources(self.root) + path.unlink() + for parent in path.parents: + if parent == self.root or any(parent.iterdir()): + break + parent.rmdir() + + link = self.root / "evidence" / "cells" / "sro" / "bundle" / "linked.yaml" + link.symlink_to(self.root / "evidence" / "mint.yaml") + with self.assertRaisesRegex(MODULE.AssetBuildError, "symbolic link"): + MODULE._validate_sources(self.root) + + def test_manifest_refuses_tampering_extra_files_and_noncanonical_bytes( + self, + ) -> None: + output = Path(self.temporary.name) / "assets" + MODULE.build(self.root, output, self.relayctl) + PROVISIONER.verify_assets(output) + target = output / "mint" / "mint.yaml" + original = target.read_bytes() + target.write_bytes(original + b"# tampered\n") + with self.assertRaisesRegex(MODULE.AssetBuildError, "verification"): + MODULE.verify_manifest(output) + with self.assertRaisesRegex(PROVISIONER.ProvisionError, "invalid assets"): + PROVISIONER.verify_assets(output) + target.write_bytes(original) + + extra = output / "unexpected.txt" + extra.write_text("unexpected", encoding="utf-8") + with self.assertRaisesRegex(MODULE.AssetBuildError, "verification"): + MODULE.verify_manifest(output) + with self.assertRaisesRegex(PROVISIONER.ProvisionError, "invalid assets"): + PROVISIONER.verify_assets(output) + extra.unlink() + + manifest = output / "manifest.json" + canonical = manifest.read_text(encoding="utf-8") + manifest.write_text(json.dumps(json.loads(canonical)) + "\n", encoding="utf-8") + with self.assertRaisesRegex(MODULE.AssetBuildError, "verification"): + MODULE.verify_manifest(output) + with self.assertRaisesRegex(PROVISIONER.ProvisionError, "invalid assets"): + PROVISIONER.verify_assets(output) + + def test_two_builds_are_byte_for_byte_deterministic(self) -> None: + first = Path(self.temporary.name) / "first" + second = Path(self.temporary.name) / "second" + MODULE.build(self.root, first, self.relayctl) + MODULE.build(self.root, second, self.relayctl) + self.assertEqual(self._digests(first), self._digests(second)) + + def test_relayctl_failure_is_redacted_and_leaves_no_output(self) -> None: + canary = "RELAYCTL-PRIVATE-CANARY" + failing = Path(self.temporary.name) / "failing-relayctl" + failing.write_text( + f"#!/bin/sh\nprintf '%s\\n' '{canary}'\nprintf '%s\\n' '{canary}' >&2\nexit 19\n", + encoding="utf-8", + ) + failing.chmod(0o755) + output = Path(self.temporary.name) / "failed-assets" + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "build", + "--root", + str(self.root), + "--output", + str(output), + "--relayctl", + str(failing), + ], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + self.assertEqual(completed.returncode, 1) + self.assertEqual(completed.stdout, "") + self.assertEqual(completed.stderr.strip(), "hosted runtime asset build failed") + combined = completed.stdout + completed.stderr + for forbidden in (canary, str(self.root), str(output), str(failing)): + self.assertNotIn(forbidden, combined) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_hosted_transit_signer.py b/scripts/test_hosted_transit_signer.py new file mode 100644 index 0000000..3fdd187 --- /dev/null +++ b/scripts/test_hosted_transit_signer.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import importlib.util +import base64 +import hashlib +import json +import os +import socket +import stat +import tempfile +import unittest +from contextlib import redirect_stderr +from io import StringIO +from pathlib import Path +from unittest import mock + +from cryptography.hazmat.primitives.asymmetric import ec + +SCRIPT = Path(__file__).with_name("hosted-transit-signer.py") +SPEC = importlib.util.spec_from_file_location("hosted_transit_signer", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class HostedTransitSignerTests(unittest.TestCase): + def setUp(self) -> None: + safe_parent = Path(__file__).resolve().parent + self.temporary = tempfile.TemporaryDirectory(dir=safe_parent) + self.root = Path(self.temporary.name) + self.socket_temporary = tempfile.TemporaryDirectory(dir="/tmp", prefix="hst-") + self.socket_root = Path(self.socket_temporary.name) + self.secret_directory = self.root / "secrets" + self.secret_directory.mkdir(mode=0o700) + self.secret = self.secret_directory / "signing.jwk" + self.canary = b'{"d":"PRIVATE-SIGNER-CANARY"}' + self.secret.write_bytes(self.canary) + self.secret.chmod(0o400) + self.public = self.secret_directory / "signing-public.jwk" + self.public.write_bytes(b"{}") + self.public.chmod(0o400) + self.staging = self.root / "staging" + self.staging.mkdir(mode=0o700) + + def tearDown(self) -> None: + self.socket_temporary.cleanup() + self.temporary.cleanup() + + def test_compose_secret_is_copied_to_owner_only_staging(self) -> None: + staged = MODULE._stage_secret(self.secret, self.staging) + + self.assertEqual(staged.read_bytes(), self.canary) + self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(staged.parent.stat().st_mode), 0o700) + self.assertEqual(staged.stat().st_uid, os.geteuid()) + + def test_consumed_secret_is_unlinked_only_when_its_digest_still_matches( + self, + ) -> None: + digest = hashlib.sha256(self.canary).digest() + MODULE._consume_secret(self.secret, digest) + self.assertFalse(self.secret.exists()) + + replacement = self.secret_directory / "replacement.jwk" + replacement.write_bytes(self.canary) + replacement.chmod(0o400) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._consume_secret(replacement, hashlib.sha256(b"other").digest()) + self.assertTrue(replacement.exists()) + + def test_only_root_owned_sticky_writable_parent_is_confined(self) -> None: + def directory(mode: int, uid: int = 0) -> os.stat_result: + return os.stat_result([stat.S_IFDIR | mode, 1, 0, 1, uid, 0, 0, 0, 0, 0]) + + self.assertTrue(MODULE._directory_is_confined(directory(0o755))) + self.assertTrue(MODULE._directory_is_confined(directory(0o1777))) + self.assertFalse(MODULE._directory_is_confined(directory(0o0777))) + self.assertFalse( + MODULE._directory_is_confined(directory(0o1777, os.geteuid() + 1)) + ) + + def test_private_key_must_match_exact_public_projection(self) -> None: + private = ec.generate_private_key(ec.SECP256R1()).private_numbers() + numbers = private.public_numbers + + def encode(value: int) -> str: + return ( + base64.urlsafe_b64encode(value.to_bytes(32, "big")) + .rstrip(b"=") + .decode() + ) + + public = { + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "x": encode(numbers.x), + "y": encode(numbers.y), + } + thumbprint = {key: public[key] for key in ("crv", "kty", "x", "y")} + public["kid"] = ( + base64.urlsafe_b64encode( + hashlib.sha256( + json.dumps( + thumbprint, separators=(",", ":"), sort_keys=True + ).encode() + ).digest() + ) + .rstrip(b"=") + .decode() + ) + private_jwk = { + **public, + "d": encode(private.private_value), + } + self.secret.chmod(0o600) + self.secret.write_text(json.dumps(private_jwk), encoding="utf-8") + self.secret.chmod(0o400) + self.public.chmod(0o600) + self.public.write_text(json.dumps(public), encoding="utf-8") + self.public.chmod(0o400) + MODULE._verify_public_match(self.secret, self.public) + + public["x"] = encode(numbers.x - 1) + self.public.chmod(0o600) + self.public.write_text(json.dumps(public), encoding="utf-8") + self.public.chmod(0o400) + with self.assertRaisesRegex(MODULE.SignerError, "invalid key pair"): + MODULE._verify_public_match(self.secret, self.public) + + def test_symlinked_secret_or_parent_is_refused(self) -> None: + link = self.secret_directory / "linked.jwk" + link.symlink_to(self.secret) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._read_secret(link) + + linked_parent = self.root / "linked-parent" + linked_parent.symlink_to(self.secret_directory, target_is_directory=True) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._read_secret(linked_parent / self.secret.name) + + def test_writable_hardlinked_empty_and_oversized_secrets_are_refused(self) -> None: + self.secret.chmod(0o620) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._read_secret(self.secret) + + self.secret.chmod(0o400) + hardlink = self.secret_directory / "hardlink.jwk" + os.link(self.secret, hardlink) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._read_secret(self.secret) + hardlink.unlink() + + self.secret.chmod(0o600) + self.secret.write_bytes(b"") + self.secret.chmod(0o400) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._read_secret(self.secret) + self.secret.chmod(0o600) + self.secret.write_bytes(b"x" * (MODULE.MAX_SECRET_BYTES + 1)) + self.secret.chmod(0o400) + with self.assertRaisesRegex(MODULE.SignerError, "invalid secret"): + MODULE._read_secret(self.secret) + + def test_socket_requires_the_exact_absent_path_and_private_owned_directory( + self, + ) -> None: + socket_directory = self.socket_root / "transit" + socket_directory.mkdir(mode=0o700) + exact_socket = socket_directory / "transit-proxy.sock" + with mock.patch.object(MODULE, "SOCKET_PATH", exact_socket): + MODULE._validate_socket(exact_socket) + + exact_socket.write_text("replacement", encoding="ascii") + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(exact_socket) + exact_socket.unlink() + + socket_directory.chmod(0o770) + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(exact_socket) + + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(socket_directory / "other.sock") + + def test_stale_owned_socket_is_removed_but_live_socket_is_refused(self) -> None: + socket_directory = self.socket_root / "transit" + socket_directory.mkdir(mode=0o700) + exact_socket = socket_directory / "transit-proxy.sock" + with mock.patch.object(MODULE, "SOCKET_PATH", exact_socket): + stale = socket.socket(socket.AF_UNIX) + stale.bind(str(exact_socket)) + stale.close() + exact_socket.chmod(0o600) + MODULE._validate_socket(exact_socket) + self.assertFalse(exact_socket.exists()) + + live = socket.socket(socket.AF_UNIX) + live.bind(str(exact_socket)) + live.listen(1) + exact_socket.chmod(0o600) + try: + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(exact_socket) + self.assertTrue(exact_socket.exists()) + finally: + live.close() + exact_socket.unlink(missing_ok=True) + + def test_stale_socket_refuses_symlink_regular_file_and_open_mode(self) -> None: + socket_directory = self.socket_root / "transit" + socket_directory.mkdir(mode=0o700) + exact_socket = socket_directory / "transit-proxy.sock" + with mock.patch.object(MODULE, "SOCKET_PATH", exact_socket): + regular = self.root / "regular" + regular.write_text("replacement", encoding="ascii") + exact_socket.symlink_to(regular) + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(exact_socket) + exact_socket.unlink() + + exact_socket.write_text("replacement", encoding="ascii") + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(exact_socket) + exact_socket.unlink() + + stale = socket.socket(socket.AF_UNIX) + stale.bind(str(exact_socket)) + stale.close() + exact_socket.chmod(0o660) + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._validate_socket(exact_socket) + exact_socket.unlink() + + def test_stale_socket_owned_by_another_uid_is_refused(self) -> None: + class ForeignSocket: + def lstat(self) -> os.stat_result: + values = [ + stat.S_IFSOCK | 0o600, + 1, + MODULE.os.geteuid() + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + return os.stat_result(values) + + with self.assertRaisesRegex(MODULE.SignerError, "invalid socket"): + MODULE._socket_identity(ForeignSocket()) + + def test_stale_socket_inode_replacement_is_refused_without_unlinking_replacement( + self, + ) -> None: + socket_directory = self.socket_root / "transit" + socket_directory.mkdir(mode=0o700) + exact_socket = socket_directory / "transit-proxy.sock" + first = socket.socket(socket.AF_UNIX) + first.bind(str(exact_socket)) + first.close() + exact_socket.chmod(0o600) + + replacement = socket.socket(socket.AF_UNIX) + replacement_path = socket_directory / "replacement.sock" + replacement.bind(str(replacement_path)) + replacement_path.chmod(0o600) + + original = MODULE._socket_identity + + def replace_before_recheck(path: Path) -> tuple[int, int, int, int]: + identity = original(path) + if replace_before_recheck.calls == 0: + replace_before_recheck.calls += 1 + return identity + os.replace(replacement_path, path) + return original(path) + + replace_before_recheck.calls = 0 + with ( + mock.patch.object(MODULE, "SOCKET_PATH", exact_socket), + mock.patch.object( + MODULE, "_socket_identity", side_effect=replace_before_recheck + ), + self.assertRaisesRegex(MODULE.SignerError, "invalid socket"), + ): + MODULE._validate_socket(exact_socket) + self.assertTrue(exact_socket.exists()) + replacement.close() + exact_socket.unlink(missing_ok=True) + + def test_exec_is_one_proxy_one_staged_key_and_one_exact_socket(self) -> None: + socket_directory = self.socket_root / "transit" + socket_directory.mkdir(mode=0o700) + socket_path = socket_directory / "transit-proxy.sock" + proxy = self.root / "local-transit-proxy.py" + proxy.write_text("# fixed proxy\n", encoding="ascii") + proxy.chmod(0o500) + key_name = "solmara-evidence-cra" + stage_secret = MODULE._stage_secret + private_digest = hashlib.sha256(self.canary).digest() + public_digest = hashlib.sha256(b"{}").digest() + + with ( + mock.patch.object(MODULE, "SECRET_PATH", self.secret), + mock.patch.object(MODULE, "PUBLIC_PATH", self.public), + mock.patch.object(MODULE, "SOCKET_PATH", socket_path), + mock.patch.object(MODULE, "STAGING_ROOT", self.staging), + mock.patch.object( + MODULE, + "_stage_secret", + side_effect=lambda source, **kwargs: stage_secret( + source, self.staging, **kwargs + ), + ), + mock.patch.object( + MODULE, + "_verify_public_match", + return_value=(private_digest, public_digest), + ), + mock.patch.object( + MODULE.os, "execve", side_effect=RuntimeError("exec captured") + ) as execute, + self.assertRaisesRegex(RuntimeError, "exec captured"), + ): + MODULE.exec_signer(self.secret, self.public, socket_path, key_name, proxy) + + executable, arguments, environment = execute.call_args.args + self.assertEqual(executable, os.sys.executable) + self.assertEqual(arguments[0:2], [os.sys.executable, str(proxy)]) + self.assertEqual( + arguments[-4:], ["--socket", str(socket_path), "--key-name", key_name] + ) + staged = Path(arguments[3]) + self.assertNotEqual(staged, self.secret) + self.assertEqual(staged.read_bytes(), self.canary) + self.assertIn("--consume-private-jwk", arguments) + self.assertFalse(self.secret.exists()) + self.assertFalse(self.public.exists()) + self.assertNotIn(self.canary.decode("ascii"), repr(execute.call_args)) + self.assertEqual( + set(environment), {"LANG", "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED"} + ) + + def test_failed_exec_removes_the_private_staging_file_and_directory(self) -> None: + socket_directory = self.socket_root / "transit" + socket_directory.mkdir(mode=0o700) + socket_path = socket_directory / "transit-proxy.sock" + proxy = self.root / "local-transit-proxy.py" + proxy.write_text("# fixed proxy\n", encoding="ascii") + proxy.chmod(0o500) + private_digest = hashlib.sha256(self.canary).digest() + public_digest = hashlib.sha256(b"{}").digest() + stage_secret = MODULE._stage_secret + + with ( + mock.patch.object(MODULE, "SECRET_PATH", self.secret), + mock.patch.object(MODULE, "PUBLIC_PATH", self.public), + mock.patch.object(MODULE, "SOCKET_PATH", socket_path), + mock.patch.object(MODULE, "STAGING_ROOT", self.staging), + mock.patch.object( + MODULE, + "_stage_secret", + side_effect=lambda source, **kwargs: stage_secret( + source, self.staging, **kwargs + ), + ), + mock.patch.object( + MODULE, + "_verify_public_match", + return_value=(private_digest, public_digest), + ), + mock.patch.object(MODULE.os, "execve", side_effect=OSError("refused")), + self.assertRaises(OSError), + ): + MODULE.exec_signer( + self.secret, + self.public, + socket_path, + "solmara-evidence-cra", + proxy, + ) + + self.assertEqual(list(self.staging.iterdir()), []) + self.assertFalse(self.secret.exists()) + self.assertFalse(self.public.exists()) + + def test_unlisted_key_and_noncanonical_secret_fail_before_staging(self) -> None: + with mock.patch.object(MODULE, "_stage_secret") as stage: + with self.assertRaisesRegex( + MODULE.SignerError, "invalid signer configuration" + ): + MODULE.exec_signer( + Path("/another/secret"), + MODULE.PUBLIC_PATH, + MODULE.SOCKET_PATH, + "private-canary", + Path("/proxy"), + ) + stage.assert_not_called() + + def test_cli_failure_is_generic_and_redacts_rejected_values(self) -> None: + error = StringIO() + canaries = ["PRIVATE-PATH-CANARY", "PRIVATE-KEY-CANARY"] + with ( + mock.patch.object( + os.sys, + "argv", + [ + SCRIPT.name, + "--private-jwk", + canaries[0], + "--socket", + "/wrong", + "--key-name", + canaries[1], + ], + ), + redirect_stderr(error), + ): + self.assertEqual(MODULE.main(), 1) + + self.assertEqual(error.getvalue().strip(), MODULE.GENERIC_ERROR) + for canary in canaries: + self.assertNotIn(canary, error.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_image_pins.py b/scripts/test_image_pins.py index 4601752..9a0c1fb 100644 --- a/scripts/test_image_pins.py +++ b/scripts/test_image_pins.py @@ -10,9 +10,11 @@ ROOT = Path(__file__).resolve().parents[1] -RELAY = "ghcr.io/registrystack/registry-relay@sha256:" + "1" * 64 -NOTARY = "ghcr.io/registrystack/registry-notary@sha256:" + "2" * 64 +RELAY = "ghcr.io/registrystack/relay@sha256:" + "1" * 64 +EVIDENCE = "ghcr.io/registrystack/evidence@sha256:" + "2" * 64 +MINT = "ghcr.io/registrystack/mint@sha256:" + "3" * 64 VOLUME_INIT = "busybox@sha256:" + "4" * 64 +GATEWAY = "caddy@sha256:" + "5" * 64 def load_check_image_pins(): @@ -35,19 +37,30 @@ def setUp(self) -> None: self.module.ROOT = self.root (self.root / "versions.env").write_text( f"REGISTRY_RELAY_IMAGE={RELAY}\n" - f"REGISTRY_NOTARY_IMAGE={NOTARY}\n" - f"VOLUME_INIT_IMAGE={VOLUME_INIT}\n", + f"SOLMARA_EVIDENCE_IMAGE={EVIDENCE}\n" + f"SOLMARA_MINT_IMAGE={MINT}\n" + f"VOLUME_INIT_IMAGE={VOLUME_INIT}\n" + f"EVIDENCE_GATEWAY_IMAGE={GATEWAY}\n", encoding="utf-8", ) + with (self.root / "versions.env").open("a", encoding="utf-8") as versions: + for key in ( + "PYTHON_STATIC_IMAGE", "NODE_BUILD_IMAGE", "UV_BUILD_IMAGE", + "ESIGNET_REDIS_IMAGE", "ESIGNET_BASE_IMAGE", + "ESIGNET_UI_IMAGE", "ESIGNET_POSTGRES_IMAGE", + ): + versions.write(f"{key}=example.invalid/image@sha256:{'6' * 64}\n") def tearDown(self) -> None: self.directory.cleanup() - def run_check(self, relay: str = RELAY, notary: str = NOTARY) -> tuple[int, str]: + def run_check(self, *, required: bool = True) -> tuple[int, str]: + operator = ":?required" if required else ":-fallback" (self.root / "compose.yaml").write_text( "services:\n" - f" relay:\n image: ${{REGISTRY_RELAY_IMAGE:-{relay}}}\n" - f" notary:\n image: ${{REGISTRY_NOTARY_IMAGE:-{notary}}}\n", + f" relay:\n image: ${{REGISTRY_RELAY_IMAGE{operator}}}\n" + f" evidence:\n image: ${{SOLMARA_EVIDENCE_IMAGE{operator}}}\n" + f" mint:\n image: ${{SOLMARA_MINT_IMAGE{operator}}}\n", encoding="utf-8", ) stderr = io.StringIO() @@ -55,22 +68,69 @@ def run_check(self, relay: str = RELAY, notary: str = NOTARY) -> tuple[int, str] result = self.module.main() return result, stderr.getvalue() - def test_matching_compose_fallbacks_pass(self) -> None: + def test_matching_source_image_references_pass(self) -> None: result, stderr = self.run_check() self.assertEqual(result, 0, stderr) - def test_relay_fallback_must_match_versions_env(self) -> None: - result, stderr = self.run_check(relay=RELAY[:-1] + "3") + def test_source_images_must_be_required(self) -> None: + result, stderr = self.run_check(required=False) self.assertEqual(result, 1) - self.assertIn("REGISTRY_RELAY_IMAGE fallback must match versions.env", stderr) + self.assertIn("expected a required REGISTRY_RELAY_IMAGE reference", stderr) - def test_notary_fallback_must_match_versions_env(self) -> None: - result, stderr = self.run_check(notary=NOTARY[:-1] + "3") + def test_gateway_must_be_digest_pinned(self) -> None: + versions = (self.root / "versions.env").read_text().replace(GATEWAY, "caddy:latest") + (self.root / "versions.env").write_text(versions) + result, stderr = self.run_check() self.assertEqual(result, 1) - self.assertIn("REGISTRY_NOTARY_IMAGE fallback must match versions.env", stderr) + self.assertIn("EVIDENCE_GATEWAY_IMAGE must use image@sha256", stderr) + + def test_runtime_images_must_use_their_exact_official_repository(self) -> None: + versions = (self.root / "versions.env").read_text().replace( + EVIDENCE, + "ghcr.io/registrystack/solmara-lab-evidence@sha256:" + "2" * 64, + ) + (self.root / "versions.env").write_text(versions) + result, stderr = self.run_check() + + self.assertEqual(result, 1) + self.assertIn( + "SOLMARA_EVIDENCE_IMAGE must use " + "ghcr.io/registrystack/evidence@sha256:", + stderr, + ) + + def test_runtime_builder_only_builds_checksum_verified_relayctl(self) -> None: + builder = (ROOT / "scripts" / "build-registry-stack-runtime.sh").read_text( + encoding="utf-8" + ) + dockerfile = ( + ROOT / "docker" / "registry-stack-release-binary" / "Dockerfile" + ).read_text(encoding="utf-8") + + self.assertIn('verify_official_runtime evidence "$evidence_image"', builder) + self.assertIn('verify_official_runtime mint "$mint_image"', builder) + self.assertIn('build_relayctl "$relayctl_image"', builder) + self.assertNotIn("REGISTRY_STACK_RELEASE_EVIDENCE_ASSET", builder) + self.assertNotIn("REGISTRY_STACK_RELEASE_MINT_ASSET", builder) + self.assertIn("REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256", builder) + self.assertIn("relayctl_sha256", builder) + self.assertNotIn("wget", dockerfile) + self.assertNotIn("curl", dockerfile) + self.assertNotIn(" AS evidence", dockerfile) + self.assertNotIn(" AS mint", dockerfile) + + def test_hosted_pin_gate_verifies_official_runtime_tag_digests_and_labels(self) -> None: + justfile = (ROOT / "justfile").read_text(encoding="utf-8") + + self.assertIn("hosted-pin-check: build-runtime-images", justfile) + + def test_lint_excludes_the_exact_vendor_checkout_only(self) -> None: + justfile = (ROOT / "justfile").read_text(encoding="utf-8") + + self.assertIn("ruff check --select E4,E7,E9,F --exclude vendor .", justfile) if __name__ == "__main__": diff --git a/scripts/test_lifecycle_proof.py b/scripts/test_lifecycle_proof.py new file mode 100644 index 0000000..96f60e0 --- /dev/null +++ b/scripts/test_lifecycle_proof.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import stat +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +SCRIPT = Path(__file__).with_name("lifecycle_proof.py") +SPEC = importlib.util.spec_from_file_location("lifecycle_proof", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +lifecycle = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(lifecycle) + + +class LifecycleProofTests(unittest.TestCase): + def test_complete_proof_is_isolated_and_passes_every_check(self) -> None: + result = lifecycle.run_proof() + + self.assertEqual(result["status"], "pass") + self.assertEqual(result["fixtureState"], "isolated-temporary-directory") + self.assertTrue(all(result["checks"].values())) + + def test_long_lived_read_only_mosd_binding_observes_in_place_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + lifecycle.publish_all(root) + database = ( + root + / lifecycle.RELAY_DIRECTORY + / lifecycle.RELAY_FILENAMES["mosd"] + ) + inode = database.stat().st_ino + binding = lifecycle.GovernedMosdObservation(database) + try: + connection_id = id(binding.connection) + before = binding.observe_duplicate_flag(lifecycle.MOSD_TEST_UIN) + lifecycle.mutate_mosd_state( + database, + lifecycle.MOSD_TEST_UIN, + True, + "2026-07-05T08:15:00Z", + ) + after = binding.observe_duplicate_flag(lifecycle.MOSD_TEST_UIN) + finally: + binding.close() + + self.assertEqual((before[0], after[0]), (0, 1)) + self.assertNotEqual(before[1:], after[1:]) + self.assertEqual(database.stat().st_ino, inode) + self.assertEqual(id(binding.connection), connection_id) + + def test_sro_binding_requires_explicit_rebind_to_changed_extract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + lifecycle.publish_all(root) + observed_at = datetime(2026, 7, 5, 8, 30, tzinfo=timezone.utc) + original_id = lifecycle.DEFAULT_EXTRACTS["sro"] + original = ( + root / lifecycle.EVIDENCE_DIRECTORY / f"{original_id}.sqlite" + ) + original_digest = hashlib.sha256(original.read_bytes()).hexdigest() + binding = lifecycle.SroExtractBinding( + original, + expected_extract_id=original_id, + observed_at=observed_at, + ) + replacement_id = "sro-poverty-20260705T080000Z" + replacement = lifecycle._publish_changed_sro_extract( + root, + extract_id=replacement_id, + published_at="2026-07-05T08:00:00Z", + poverty_band="not_eligible", + ) + try: + still_old = binding.observe_poverty_band(lifecycle.SRO_TEST_UIN) + finally: + binding.close() + + rebound = lifecycle.SroExtractBinding( + replacement, + expected_extract_id=replacement_id, + observed_at=observed_at, + ) + try: + changed = rebound.observe_poverty_band(lifecycle.SRO_TEST_UIN) + finally: + rebound.close() + + self.assertNotEqual(still_old, changed) + self.assertEqual(hashlib.sha256(original.read_bytes()).hexdigest(), original_digest) + self.assertNotEqual(original, replacement) + + def test_invalid_stale_and_writable_extracts_fail_closed(self) -> None: + observed_at = datetime(2026, 7, 5, 8, 30, tzinfo=timezone.utc) + cases = ( + ("stale", "2026-07-03T08:00:00Z", None, False), + ( + "metadata", + "2026-07-05T08:00:00Z", + "metadata-does-not-match-binding", + False, + ), + ("writable", "2026-07-05T08:00:00Z", None, True), + ) + for suffix, published_at, metadata_id, make_writable in cases: + with self.subTest(case=suffix), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + lifecycle.publish_all(root) + extract_id = f"sro-poverty-20260705T08-{suffix}" + extract = lifecycle._publish_changed_sro_extract( + root, + extract_id=extract_id, + published_at=published_at, + poverty_band="not_eligible", + metadata_extract_id=metadata_id, + ) + if make_writable: + extract.chmod(0o644) + with self.assertRaises(lifecycle.LifecycleProofError): + lifecycle.SroExtractBinding( + extract, + expected_extract_id=extract_id, + observed_at=observed_at, + ) + + def test_immutable_publication_refuses_overwrite_without_changing_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + lifecycle.publish_all(root) + extract_id = lifecycle.DEFAULT_EXTRACTS["sro"] + extract = root / lifecycle.EVIDENCE_DIRECTORY / f"{extract_id}.sqlite" + before = hashlib.sha256(extract.read_bytes()).digest() + with self.assertRaises(FileExistsError): + lifecycle.publish_extract( + root, + "sro", + "2026-07-05T08:00:00Z", + extract_id, + ) + self.assertEqual(hashlib.sha256(extract.read_bytes()).digest(), before) + self.assertEqual(stat.S_IMODE(extract.stat().st_mode), 0o444) + + def test_json_cli_emits_only_sanitized_proof_state(self) -> None: + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--json"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + result = json.loads(completed.stdout) + self.assertEqual(result["status"], "pass") + serialized = completed.stdout.lower() + for forbidden in ( + lifecycle.MOSD_TEST_UIN, + lifecycle.SRO_TEST_UIN, + "poverty_band", + "record_revision", + "sqlite", + ): + self.assertNotIn(forbidden.lower(), serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_live_lifecycle_proof.py b/scripts/test_live_lifecycle_proof.py new file mode 100644 index 0000000..8f47df7 --- /dev/null +++ b/scripts/test_live_lifecycle_proof.py @@ -0,0 +1,509 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import os +import sqlite3 +import stat +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +SCRIPT = Path(__file__).with_name("live-lifecycle-proof.py") +SPEC = importlib.util.spec_from_file_location("live_lifecycle_proof", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +lifecycle = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = lifecycle +SPEC.loader.exec_module(lifecycle) + + +class FakeOperations: + def __init__(self, fail_at: str = "", *, hot_reload_sro: bool = False) -> None: + self.fail_at = fail_at + self.hot_reload_sro = hot_reload_sro + self.events: list[str] = [] + self.mosd_duplicate = False + self.sro_changed = False + self.sro_bound = False + self.original_binding = b"original-binding" + self.original_mosd_fixture = lifecycle.MosdFixture(True) + self.mosd_generation = "mosd-generation" + self.sro_generation = "sro-generation-1" + self.publication = lifecycle.ExtractPublication( + Path("private-publication"), + "/private/extract", + "private-extract-id", + ) + + def _event(self, name: str) -> None: + self.events.append(name) + if self.fail_at == name: + raise RuntimeError( + "selector=2300010248 token=private source row private.sqlite" + ) + + def load_environment(self) -> None: + self._event("load") + + def validate_preconditions(self) -> None: + self._event("preconditions") + + def observe_evidence(self, requirement: str) -> bool: + name = ( + "observe-mosd" + if requirement == lifecycle.MOSD_REQUIREMENT + else "observe-sro" + ) + self._event(name) + return ( + not self.mosd_duplicate if name == "observe-mosd" else not self.sro_changed + ) + + def generation(self, service: str) -> str: + name = ( + "generation-sro" if service == lifecycle.SRO_SERVICE else "generation-mosd" + ) + self._event(name) + return ( + self.sro_generation + if service == lifecycle.SRO_SERVICE + else self.mosd_generation + ) + + def capture_mosd_fixture(self): + self._event("capture-mosd") + return self.original_mosd_fixture + + def mutate_mosd(self, duplicate: bool) -> None: + self._event(f"mutate-mosd-{str(duplicate).lower()}") + self.mosd_duplicate = duplicate + + def restore_mosd_fixture(self, fixture) -> None: + self._event("restore-mosd") + if fixture != self.original_mosd_fixture: + raise AssertionError + self.mosd_duplicate = False + + def capture_sro_binding(self) -> bytes: + self._event("capture-binding") + return self.original_binding + + def publish_changed_sro(self): + self._event("publish-sro") + return self.publication + + def bind_sro(self, publication) -> None: + self._event("bind-sro") + if publication is not self.publication: + raise AssertionError + self.sro_bound = True + if self.hot_reload_sro: + self.sro_changed = True + + def restart_sro(self) -> None: + self._event("restart-sro") + self.sro_changed = self.sro_bound + self.sro_generation = ( + "sro-generation-2" if self.sro_bound else "sro-generation-restored" + ) + + def wait_sro_ready(self) -> None: + self._event("wait-sro") + + def prove_replacement_refusals(self) -> None: + self._event("replacement-refusals") + + def restore_sro_binding(self, original: bytes) -> None: + self._event("restore-binding") + if original != self.original_binding: + raise AssertionError + self.sro_bound = False + + def discard_sro_publication(self, publication) -> None: + self._event("discard-sro") + if publication is not self.publication or self.sro_bound: + raise AssertionError + + +class LiveLifecycleProofTests(unittest.TestCase): + def test_complete_proof_uses_signed_http_observations_and_restores_state( + self, + ) -> None: + operations = FakeOperations() + + result = lifecycle.run_proof(operations) + + self.assertEqual(result["status"], "pass") + self.assertEqual(result["proof"], "live-http") + self.assertTrue(all(result["checks"].values())) + self.assertFalse(operations.mosd_duplicate) + self.assertFalse(operations.sro_bound) + self.assertFalse(operations.sro_changed) + self.assertIn("discard-sro", operations.events) + self.assertIn("replacement-refusals", operations.events) + self.assertGreaterEqual(operations.events.count("observe-mosd"), 3) + self.assertGreaterEqual(operations.events.count("observe-sro"), 4) + + def test_failure_after_live_mutation_restores_mosd_and_sro(self) -> None: + operations = FakeOperations(fail_at="replacement-refusals") + + with self.assertRaisesRegex( + lifecycle.LifecycleProofError, + "lifecycle proof did not complete", + ): + lifecycle.run_proof(operations) + + self.assertFalse(operations.mosd_duplicate) + self.assertFalse(operations.sro_bound) + self.assertFalse(operations.sro_changed) + self.assertIn("restore-binding", operations.events) + self.assertGreaterEqual(operations.events.count("restart-sro"), 2) + + def test_failure_before_binding_capture_restores_captured_mosd(self) -> None: + operations = FakeOperations(fail_at="capture-binding") + + with self.assertRaises(lifecycle.LifecycleProofError): + lifecycle.run_proof(operations) + + self.assertFalse(operations.mosd_duplicate) + self.assertIn("restore-mosd", operations.events) + self.assertNotIn("restore-binding", operations.events) + + def test_cleanup_failure_wins_without_exposing_private_details(self) -> None: + operations = FakeOperations(fail_at="restore-mosd") + + with self.assertRaisesRegex( + lifecycle.LifecycleProofError, + "lifecycle proof did not complete", + ) as caught: + lifecycle.run_proof(operations) + + rendered = str(caught.exception).lower() + for forbidden in ("2300010248", "selector", "token", "sqlite", "source row"): + self.assertNotIn(forbidden, rendered) + self.assertIn("restore-binding", operations.events) + self.assertIn("discard-sro", operations.events) + self.assertFalse(operations.sro_bound) + + def test_failure_while_binding_still_discards_unbound_publication(self) -> None: + operations = FakeOperations(fail_at="bind-sro") + + with self.assertRaises(lifecycle.LifecycleProofError): + lifecycle.run_proof(operations) + + self.assertIn("restore-binding", operations.events) + self.assertIn("discard-sro", operations.events) + self.assertFalse(operations.mosd_duplicate) + + def test_hot_reloaded_sro_binding_is_rejected_before_restart(self) -> None: + operations = FakeOperations(hot_reload_sro=True) + + with self.assertRaises(lifecycle.LifecycleProofError): + lifecycle.run_proof(operations) + + binding_index = operations.events.index("bind-sro") + restart_index = operations.events.index("restart-sro") + self.assertIn("observe-sro", operations.events[binding_index:restart_index]) + self.assertFalse(operations.sro_bound) + self.assertFalse(operations.sro_changed) + + def test_changed_extract_is_staged_then_published_read_only(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + publication = lifecycle._publish_changed_sro(root) + try: + self.assertTrue(publication.path.is_file()) + self.assertEqual( + stat.S_IMODE(publication.path.stat().st_mode) & 0o222, + 0, + ) + lifecycle.publisher.validate_extract( + publication.path, + "sro", + observed_at=lifecycle._timestamp(lifecycle._now()), + expected_extract_id=publication.extract_id, + ) + with sqlite3.connect(publication.path) as connection: + row = connection.execute( + "SELECT poverty_band FROM poverty_evidence WHERE uin = ?", + (lifecycle.SRO_CONTROL_SUBJECT,), + ).fetchone() + self.assertEqual(row, ("standard",)) + self.assertEqual(list((root / "runtime").glob(".lifecycle-sro-*")), []) + finally: + publication.path.unlink(missing_ok=True) + + def test_failed_publication_validation_leaves_no_replacement_file(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + original = lifecycle.publisher.validate_extract + lifecycle.publisher.validate_extract = lambda *args, **kwargs: ( + _ for _ in () + ).throw(RuntimeError("private validation detail")) + try: + with self.assertRaises(RuntimeError): + lifecycle._publish_changed_sro(root) + finally: + lifecycle.publisher.validate_extract = original + + extracts = root / lifecycle.publisher.EVIDENCE_DIRECTORY + self.assertEqual(list(extracts.glob("*.sqlite")), []) + + def test_mosd_lifecycle_uses_only_fixed_publisher_verbs(self) -> None: + commands: list[list[str]] = [] + original = lifecycle._run_command + + def record(command, **kwargs): + commands.append(command) + return lifecycle.subprocess.CompletedProcess(command, 0, "", "") + + lifecycle._run_command = record + try: + operations = lifecycle.LocalOperations(Path("/unused")) + fixture = operations.capture_mosd_fixture() + operations.mutate_mosd(True) + operations.restore_mosd_fixture(fixture) + finally: + lifecycle._run_command = original + + self.assertEqual( + [command[-1] for command in commands], + ["begin-proof", "set-proof-state", "restore-proof"], + ) + expected_prefix = [ + "run", + "--rm", + "-T", + "--no-deps", + lifecycle.MOSD_PUBLISHER_SERVICE, + ] + for command in commands: + self.assertEqual(command[-6:-1], expected_prefix) + rendered = " ".join(command).lower() + self.assertNotIn(lifecycle.MOSD_CONTROL_SUBJECT, rendered) + self.assertNotIn("duplicate", rendered) + + def test_failed_mosd_begin_attempts_idempotent_publisher_restore(self) -> None: + commands: list[list[str]] = [] + original = lifecycle._run_command + + def fail_begin(command, **kwargs): + commands.append(command) + if command[-1] == "begin-proof": + raise RuntimeError("private publisher failure") + return lifecycle.subprocess.CompletedProcess(command, 0, "", "") + + lifecycle._run_command = fail_begin + try: + with self.assertRaisesRegex( + lifecycle.LifecycleProofError, + "lifecycle proof did not complete", + ): + lifecycle.LocalOperations(Path("/unused")).capture_mosd_fixture() + finally: + lifecycle._run_command = original + + self.assertEqual( + [command[-1] for command in commands], + ["begin-proof", "restore-proof"], + ) + + def test_mosd_relay_requires_its_project_named_volume_read_only(self) -> None: + operations = lifecycle.LocalOperations(Path("/unused")) + original_command = lifecycle._run_command + original_project = os.environ.get("COMPOSE_PROJECT_NAME") + os.environ["COMPOSE_PROJECT_NAME"] = "solmara-test" + commands: list[list[str]] = [] + responses = iter( + ( + lifecycle.subprocess.CompletedProcess([], 0, "relay-container\n", ""), + lifecycle.subprocess.CompletedProcess( + [], + 0, + json.dumps( + [ + { + "Type": "volume", + "Name": "solmara-test_mosd-relay-source", + "Destination": "/var/lib/relay/source", + "RW": False, + } + ] + ), + "", + ), + ) + ) + + def respond(command, **kwargs): + commands.append(command) + return next(responses) + + lifecycle._run_command = respond + try: + operations._require_read_only_named_volume( + lifecycle.MOSD_RELAY_SERVICE, + lifecycle.MOSD_SOURCE_VOLUME, + lifecycle.RELAY_SOURCE_DESTINATION, + ) + finally: + lifecycle._run_command = original_command + if original_project is None: + os.environ.pop("COMPOSE_PROJECT_NAME", None) + else: + os.environ["COMPOSE_PROJECT_NAME"] = original_project + + self.assertIn("ps", commands[0]) + self.assertEqual(commands[1][:2], ["docker", "inspect"]) + + def test_writable_or_bind_mosd_source_mount_is_refused(self) -> None: + operations = lifecycle.LocalOperations(Path("/unused")) + original_command = lifecycle._run_command + original_project = os.environ.get("COMPOSE_PROJECT_NAME") + os.environ["COMPOSE_PROJECT_NAME"] = "solmara-test" + cases = ( + {"Type": "volume", "RW": True}, + {"Type": "bind", "RW": False}, + ) + try: + for changed in cases: + with self.subTest(changed=changed): + mount = { + "Type": "volume", + "Name": "solmara-test_mosd-relay-source", + "Destination": "/var/lib/relay/source", + "RW": False, + **changed, + } + responses = iter( + ( + lifecycle.subprocess.CompletedProcess( + [], 0, "relay-container\n", "" + ), + lifecycle.subprocess.CompletedProcess( + [], 0, json.dumps([mount]), "" + ), + ) + ) + lifecycle._run_command = lambda command, **kwargs: next(responses) + with self.assertRaises(lifecycle.LifecycleProofError): + operations._require_read_only_named_volume( + lifecycle.MOSD_RELAY_SERVICE, + lifecycle.MOSD_SOURCE_VOLUME, + lifecycle.RELAY_SOURCE_DESTINATION, + ) + finally: + lifecycle._run_command = original_command + if original_project is None: + os.environ.pop("COMPOSE_PROJECT_NAME", None) + else: + os.environ["COMPOSE_PROJECT_NAME"] = original_project + + def test_runtime_patch_changes_only_the_bound_extract_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + runtime = Path(temporary) / "runtime.yaml" + old_path = ( + "/var/lib/registry-evidence/sro/extracts/" + "sro-poverty-20260812T010203Z.sqlite" + ) + new_path = ( + "/var/lib/registry-evidence/sro/extracts/" + "sro-poverty-20260812T020304Z.sqlite" + ) + original = ( + "version: 1\n" + "# operator-generated runtime\n" + "sourceExtracts:\n" + f" sro-poverty-extract: {{ path: {old_path} }}\n" + ).encode() + runtime.write_bytes(original) + + lifecycle._replace_runtime_binding(runtime, new_path) + + self.assertEqual( + runtime.read_bytes(), + original.replace(old_path.encode(), new_path.encode()), + ) + + def test_ready_sro_with_invalid_source_requires_evidence_failure(self) -> None: + operations = lifecycle.LocalOperations(Path("/unused")) + original_http_json = lifecycle.evidence_common.http_json + original_monotonic = lifecycle.time.monotonic + original_sleep = lifecycle.time.sleep + original_observe = operations.observe_evidence + lifecycle.evidence_common.http_json = lambda *args, **kwargs: ( + lifecycle.evidence_common.StepHttpResult(200, {}, {}) + ) + clock = iter((0.0, 0.0, lifecycle.REFUSAL_OBSERVATION_SECONDS + 1.0)) + lifecycle.time.monotonic = lambda: next(clock) + lifecycle.time.sleep = lambda _seconds: None + + def refused(_requirement: str) -> bool: + raise lifecycle.LifecycleProofError("private runtime detail") + + operations.observe_evidence = refused + try: + operations._wait_sro_refused() + finally: + lifecycle.evidence_common.http_json = original_http_json + lifecycle.time.monotonic = original_monotonic + lifecycle.time.sleep = original_sleep + operations.observe_evidence = original_observe + + def test_ready_sro_with_invalid_source_rejects_successful_evidence(self) -> None: + operations = lifecycle.LocalOperations(Path("/unused")) + original_monotonic = lifecycle.time.monotonic + original_observe = operations.observe_evidence + clock = iter((0.0, lifecycle.REFUSAL_OBSERVATION_SECONDS + 1.0)) + lifecycle.time.monotonic = lambda: next(clock) + operations.observe_evidence = lambda _requirement: True + try: + with self.assertRaisesRegex( + lifecycle.LifecycleProofError, + "lifecycle proof did not complete", + ): + operations._wait_sro_refused() + finally: + lifecycle.time.monotonic = original_monotonic + operations.observe_evidence = original_observe + + def test_cli_failure_emits_only_the_public_failure_class(self) -> None: + original = lifecycle.run_proof + lifecycle.run_proof = lambda: (_ for _ in ()).throw( + RuntimeError("selector=2300010248 token=private signed.jws") + ) + stdout = io.StringIO() + stderr = io.StringIO() + try: + with redirect_stdout(stdout), redirect_stderr(stderr): + status = lifecycle.main(["--json"]) + finally: + lifecycle.run_proof = original + + self.assertEqual(status, 1) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "live-lifecycle-proof: failed\n") + + def test_success_document_contains_only_sanitized_named_checks(self) -> None: + result = lifecycle.run_proof(FakeOperations()) + rendered = json.dumps(result, sort_keys=True) + + lifecycle._assert_sanitized(rendered) + for forbidden in ( + lifecycle.MOSD_CONTROL_SUBJECT, + lifecycle.SRO_CONTROL_SUBJECT, + "selector", + "token", + "jws", + "signature", + "sqlite", + "source row", + ): + self.assertNotIn(forbidden.lower(), rendered.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_local_relay_runtime_stager.py b/scripts/test_local_relay_runtime_stager.py new file mode 100644 index 0000000..c77413a --- /dev/null +++ b/scripts/test_local_relay_runtime_stager.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +SCRIPT = Path(__file__).with_name("local-relay-runtime-stager.py") +SPEC = importlib.util.spec_from_file_location("local_relay_runtime_stager", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) +ROOT = SCRIPT.parents[1] +AUTHORITIES = ("cra", "nia", "mosd", "sipf", "nagdi") + + +def isolated_source(root: Path, authority: str) -> Path: + source = root / "source" + generated = source / "package" / "generated" + generated.mkdir(parents=True) + (source / "runtime.yaml").write_text( + f"packagePath: /etc/relay/{authority}/package\n", + encoding="utf-8", + ) + payload = b'{"fixture":"sealed"}\n' + artifact = generated / "artifact.json" + artifact.write_bytes(payload) + manifest = { + "files": [ + { + "path": "generated/artifact.json", + "size": len(payload), + "sha256": f"sha256:{hashlib.sha256(payload).hexdigest()}", + } + ] + } + (source / "package" / "relay-package.json").write_text( + json.dumps(manifest, sort_keys=True), + encoding="utf-8", + ) + return source + + +class LocalRelayRuntimeStagerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.source = isolated_source(self.root, "cra") + self.destination = self.root / "destination" + self.destination.mkdir() + self.uid = os.getuid() + self.gid = os.getgid() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def stage(self) -> None: + MODULE.stage( + "cra", + self.source, + self.destination, + target_uid=self.uid, + target_gid=self.gid, + ) + + def test_stages_complete_package_and_preserves_matching_existing_volume( + self, + ) -> None: + self.stage() + + self.assertEqual( + {entry.name for entry in self.destination.iterdir()}, + {MODULE.LOCK_NAME, "runtime.yaml", "package"}, + ) + self.assertEqual( + (self.destination / "runtime.yaml").read_bytes(), + (self.source / "runtime.yaml").read_bytes(), + ) + source_files = { + path.relative_to(self.source / "package") + for path in (self.source / "package").rglob("*") + if path.is_file() + } + staged_files = { + path.relative_to(self.destination / "package") + for path in (self.destination / "package").rglob("*") + if path.is_file() + } + self.assertEqual(staged_files, source_files) + for path in (self.destination / "package").rglob("*"): + metadata = path.stat() + self.assertEqual((metadata.st_uid, metadata.st_gid), (self.uid, self.gid)) + self.assertEqual( + stat.S_IMODE(metadata.st_mode), 0o500 if path.is_dir() else 0o400 + ) + + first_inode = (self.destination / "package").stat().st_ino + self.stage() + self.assertEqual((self.destination / "package").stat().st_ino, first_inode) + self.assertFalse((self.destination / MODULE.STAGING_PACKAGE).exists()) + self.assertFalse((self.destination / MODULE.STAGING_RUNTIME).exists()) + + (self.source / "runtime.yaml").write_bytes( + (self.source / "runtime.yaml").read_bytes() + b"\n" + ) + with self.assertRaisesRegex(MODULE.StagingError, MODULE.FAILURE_MESSAGE): + self.stage() + self.assertEqual((self.destination / "package").stat().st_ino, first_inode) + + def test_source_inventory_rejects_links_unsafe_modes_and_extra_entries( + self, + ) -> None: + attacks = [] + + extra_source = isolated_source(self.root / "extra", "cra") + (extra_source / "unexpected").write_text("value", encoding="utf-8") + attacks.append(extra_source) + + symlink_source = isolated_source(self.root / "symlink", "cra") + artifact = next((symlink_source / "package" / "generated").rglob("*.json")) + artifact.unlink() + artifact.symlink_to(symlink_source / "runtime.yaml") + attacks.append(symlink_source) + + hardlink_source = isolated_source(self.root / "hardlink", "cra") + artifact = next((hardlink_source / "package" / "generated").rglob("*.json")) + os.link(artifact, hardlink_source / "package" / "duplicate.json") + attacks.append(hardlink_source) + + writable_source = isolated_source(self.root / "writable", "cra") + (writable_source / "runtime.yaml").chmod(0o666) + attacks.append(writable_source) + + for source in attacks: + with self.subTest(source=source.parent.name): + destination = source.parent / "destination" + destination.mkdir() + with self.assertRaisesRegex( + MODULE.StagingError, MODULE.FAILURE_MESSAGE + ): + MODULE.stage( + "cra", + source, + destination, + target_uid=self.uid, + target_gid=self.gid, + ) + + def test_package_bounds_manifest_digest_and_authority_binding_fail_closed( + self, + ) -> None: + digest_source = isolated_source(self.root / "digest", "cra") + artifact = next((digest_source / "package" / "generated").rglob("*.json")) + artifact.write_bytes(artifact.read_bytes() + b" ") + + oversized_source = isolated_source(self.root / "oversized", "cra") + (oversized_source / "runtime.yaml").write_bytes( + b"x" * (MODULE.MAX_RUNTIME_BYTES + 1) + ) + + wrong_authority_source = isolated_source(self.root / "authority", "nia") + + comment_bypass_source = isolated_source(self.root / "comment-bypass", "cra") + (comment_bypass_source / "runtime.yaml").write_text( + "# packagePath: /etc/relay/cra/package\n" + "packagePath: /tmp/not-the-staged-package\n", + encoding="utf-8", + ) + + hash_bypass_source = isolated_source(self.root / "hash-bypass", "cra") + (hash_bypass_source / "runtime.yaml").write_text( + "packagePath: /etc/relay/cra/package#outside-staged\n", + encoding="utf-8", + ) + + for label, source in ( + ("digest", digest_source), + ("oversized", oversized_source), + ("authority", wrong_authority_source), + ("comment-bypass", comment_bypass_source), + ("hash-bypass", hash_bypass_source), + ): + with self.subTest(label=label): + destination = source.parent / "destination" + destination.mkdir() + with self.assertRaisesRegex( + MODULE.StagingError, MODULE.FAILURE_MESSAGE + ): + MODULE.stage( + "cra", + source, + destination, + target_uid=self.uid, + target_gid=self.gid, + ) + + def test_read_detects_same_path_mutation_after_open(self) -> None: + directory_fd = os.open(self.source, os.O_RDONLY | os.O_DIRECTORY) + path = self.source / "runtime.yaml" + metadata = path.stat() + snapshot = MODULE._snapshot(metadata) + real_fstat = os.fstat + calls = 0 + + def racing_fstat(descriptor: int): + nonlocal calls + result = real_fstat(descriptor) + calls += 1 + if calls == 1: + path.write_bytes(path.read_bytes() + b"\n") + return result + + try: + with ( + mock.patch.object(MODULE.os, "fstat", side_effect=racing_fstat), + self.assertRaisesRegex(MODULE.StagingError, MODULE.FAILURE_MESSAGE), + ): + MODULE._read_exact( + directory_fd, + "runtime.yaml", + snapshot, + max_bytes=MODULE.MAX_RUNTIME_BYTES, + ) + finally: + os.close(directory_fd) + + def test_cli_failure_is_generic(self) -> None: + result = MODULE.main( + [ + "--authority", + "unknown-sensitive-authority", + "--source", + str(self.source), + "--destination", + str(self.destination), + "stage", + ] + ) + self.assertEqual(result, 1) + + +class LocalRelayRuntimeComposeTests(unittest.TestCase): + def test_each_relay_has_one_isolated_stager_and_fixed_runtime_identity( + self, + ) -> None: + compose = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) + services = compose["services"] + + for authority in AUTHORITIES: + stager = services[f"{authority}-relay-runtime-stager"] + relay = services[f"{authority}-relay"] + self.assertEqual(stager["network_mode"], "none") + self.assertTrue(stager["read_only"]) + self.assertEqual(stager["cap_drop"], ["ALL"]) + self.assertEqual( + set(stager["cap_add"]), {"CHOWN", "DAC_OVERRIDE", "FOWNER"} + ) + self.assertIn("no-new-privileges:true", stager["security_opt"]) + self.assertEqual(stager["restart"], "no") + volumes = set(stager["volumes"]) + self.assertIn( + f"./relays/{authority}/runtime.yaml:/source/runtime.yaml:ro", + volumes, + ) + self.assertIn(f"./relays/{authority}/package:/source/package:ro", volumes) + self.assertIn(f"{authority}-relay-runtime:/staged", volumes) + for other in set(AUTHORITIES) - {authority}: + self.assertFalse(any(f"relays/{other}" in volume for volume in volumes)) + + self.assertEqual(relay["user"], "65532:65532") + self.assertIn( + f"{authority}-relay-runtime:/etc/relay/{authority}:ro", + relay["volumes"], + ) + self.assertFalse( + any(f"./relays/{authority}/" in volume for volume in relay["volumes"]) + ) + self.assertEqual( + relay["depends_on"][f"{authority}-relay-runtime-stager"]["condition"], + "service_completed_successfully", + ) + + audit_initializer = services["relay-audit-init"] + self.assertNotIn("environment", audit_initializer) + self.assertIn("target_uid = 65532", audit_initializer["command"][2]) + self.assertIn("target_gid = 65532", audit_initializer["command"][2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_local_relay_source_publisher.py b/scripts/test_local_relay_source_publisher.py new file mode 100644 index 0000000..9f7052e --- /dev/null +++ b/scripts/test_local_relay_source_publisher.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("local-relay-source-publisher.py") +SPEC = importlib.util.spec_from_file_location("local_relay_source_publisher", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +CONTROL_ROW = ( + "MOSD-ENROL-CONTROL", + "rev-original", + "active", + "2026-07-04T09:00:00Z", + MODULE.MOSD_CONTROL_SELECTOR, + 0, +) + + +def create_mosd_database(path: Path, *, include_control: bool = True) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(path) as connection: + connection.executescript( + """ + PRAGMA journal_mode = DELETE; + PRAGMA user_version = 1; + CREATE TABLE beneficiary_enrolment_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + duplicate_flag INTEGER NOT NULL CHECK (duplicate_flag IN (0, 1)) + ) STRICT; + CREATE VIEW relay_beneficiary_enrolment AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, duplicate_flag + FROM beneficiary_enrolment_source; + """ + ) + if include_control: + connection.execute( + "INSERT INTO beneficiary_enrolment_source VALUES (?, ?, ?, ?, ?, ?)", + CONTROL_ROW, + ) + + +def create_cra_database(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(path) as connection: + connection.executescript( + """ + PRAGMA journal_mode = DELETE; + PRAGMA user_version = 1; + CREATE TABLE civil_person_source ( + record_id TEXT PRIMARY KEY, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + uin TEXT NOT NULL UNIQUE, + deceased INTEGER NOT NULL CHECK (deceased IN (0, 1)) + ) STRICT; + CREATE VIEW relay_civil_person AS + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, deceased + FROM civil_person_source; + """ + ) + + +def read_control(path: Path) -> tuple[object, ...]: + with sqlite3.connect(path) as connection: + row = connection.execute( + """ + SELECT record_id, record_revision, lifecycle_state, recorded_at, + uin, duplicate_flag + FROM beneficiary_enrolment_source + WHERE uin = ? + """, + (MODULE.MOSD_CONTROL_SELECTOR,), + ).fetchone() + assert row is not None + return row + + +class LocalRelaySourcePublisherTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.seed = self.root / "seed" / "mosd.sqlite" + self.database = self.root / "volume" / "mosd.sqlite" + create_mosd_database(self.seed) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_seed_is_published_once_and_existing_content_is_preserved(self) -> None: + MODULE.ensure_seeded("mosd", self.database, self.seed) + original_inode = self.database.stat().st_ino + self.assertEqual(stat.S_IMODE(self.database.stat().st_mode), 0o644) + with sqlite3.connect(self.database) as connection: + connection.execute( + """ + UPDATE beneficiary_enrolment_source + SET duplicate_flag = 1, record_revision = 'rev-runtime' + WHERE uin = ? + """, + (MODULE.MOSD_CONTROL_SELECTOR,), + ) + + MODULE.ensure_seeded("mosd", self.database, self.seed) + + self.assertEqual(self.database.stat().st_ino, original_inode) + self.assertEqual(read_control(self.database)[1::4], ("rev-runtime", 1)) + + def test_authority_filename_and_schema_isolation_are_enforced(self) -> None: + cra_seed = self.root / "cra-seed" / "cra.sqlite" + cra_database = self.root / "cra-volume" / "cra.sqlite" + create_cra_database(cra_seed) + MODULE.ensure_seeded("cra", cra_database, cra_seed) + + with self.assertRaisesRegex(MODULE.PublisherError, MODULE.FAILURE_MESSAGE): + MODULE.ensure_seeded("mosd", cra_database, cra_seed) + + wrong_schema = self.root / "other-volume" / "mosd.sqlite" + wrong_schema.parent.mkdir(parents=True) + wrong_schema.write_bytes(cra_seed.read_bytes()) + with self.assertRaisesRegex(MODULE.PublisherError, MODULE.FAILURE_MESSAGE): + MODULE.ensure_seeded("mosd", wrong_schema, self.seed) + + def test_existing_database_must_match_the_seed_structure_exactly(self) -> None: + MODULE.ensure_seeded("mosd", self.database, self.seed) + inode = self.database.stat().st_ino + with sqlite3.connect(self.database) as connection: + connection.execute("DROP VIEW relay_beneficiary_enrolment") + connection.execute( + """ + CREATE VIEW relay_beneficiary_enrolment AS + SELECT record_id, uin FROM beneficiary_enrolment_source + """ + ) + + with self.assertRaisesRegex(MODULE.PublisherError, MODULE.FAILURE_MESSAGE): + MODULE.ensure_seeded("mosd", self.database, self.seed) + self.assertEqual(self.database.stat().st_ino, inode) + + def test_proof_mutates_and_restores_the_exact_row_on_the_same_inode(self) -> None: + MODULE.ensure_seeded("mosd", self.database, self.seed) + before = read_control(self.database) + inode = self.database.stat().st_ino + + MODULE.begin_proof("mosd", self.database, self.seed) + backup = self.database.parent / MODULE.BACKUP_NAME + self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o600) + + MODULE.set_proof_state("mosd", self.database, self.seed) + changed = read_control(self.database) + self.assertEqual(changed[5], 1) + self.assertNotEqual(changed[1], before[1]) + self.assertNotEqual(changed[3], before[3]) + self.assertEqual(self.database.stat().st_ino, inode) + + MODULE.restore_proof("mosd", self.database, self.seed) + self.assertEqual(read_control(self.database), before) + self.assertEqual(self.database.stat().st_ino, inode) + self.assertFalse(backup.exists()) + + MODULE.restore_proof("mosd", self.database, self.seed) + self.assertEqual(read_control(self.database), before) + + def test_existing_backup_supports_crash_recovery_without_recapture(self) -> None: + MODULE.ensure_seeded("mosd", self.database, self.seed) + before = read_control(self.database) + MODULE.begin_proof("mosd", self.database, self.seed) + backup = self.database.parent / MODULE.BACKUP_NAME + backup_bytes = backup.read_bytes() + MODULE.set_proof_state("mosd", self.database, self.seed) + + MODULE.begin_proof("mosd", self.database, self.seed) + + self.assertEqual(backup.read_bytes(), backup_bytes) + MODULE.restore_proof("mosd", self.database, self.seed) + self.assertEqual(read_control(self.database), before) + + def test_proof_refuses_other_authorities_missing_backup_and_invalid_backup(self) -> None: + MODULE.ensure_seeded("mosd", self.database, self.seed) + for operation, authority in ( + (MODULE.begin_proof, "cra"), + (MODULE.set_proof_state, "mosd"), + ): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex( + MODULE.PublisherError, MODULE.FAILURE_MESSAGE + ): + operation(authority, self.database, self.seed) + + MODULE.begin_proof("mosd", self.database, self.seed) + backup = self.database.parent / MODULE.BACKUP_NAME + backup.chmod(0o644) + with self.assertRaisesRegex(MODULE.PublisherError, MODULE.FAILURE_MESSAGE): + MODULE.set_proof_state("mosd", self.database, self.seed) + + backup.chmod(0o600) + envelope = json.loads(backup.read_text(encoding="utf-8")) + envelope["sha256"] = "0" * 64 + backup.write_text(json.dumps(envelope), encoding="utf-8") + backup.chmod(0o600) + with self.assertRaisesRegex(MODULE.PublisherError, MODULE.FAILURE_MESSAGE): + MODULE.restore_proof("mosd", self.database, self.seed) + + def test_missing_control_row_and_cli_failures_are_redacted(self) -> None: + empty_seed = self.root / "redacted-seed" / "mosd.sqlite" + database = self.root / "sensitive-selector-source-value" / "mosd.sqlite" + create_mosd_database(empty_seed, include_control=False) + MODULE.ensure_seeded("mosd", database, empty_seed) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--authority", + "mosd", + "--database", + str(database), + "--seed", + str(empty_seed), + "begin-proof", + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr.strip(), MODULE.FAILURE_MESSAGE) + combined = result.stdout + result.stderr + for forbidden in ( + MODULE.MOSD_CONTROL_SELECTOR, + "sensitive-selector-source-value", + str(database), + str(empty_seed), + ): + self.assertNotIn(forbidden, combined) + + def test_cli_accepts_global_options_before_the_subcommand(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--authority", + "mosd", + "--database", + str(self.database), + "--seed", + str(self.seed), + "ensure-seeded", + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), MODULE.SUCCESS_MESSAGE) + self.assertEqual(result.stderr, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_local_transit_providers.py b/scripts/test_local_transit_providers.py new file mode 100644 index 0000000..5ed1180 --- /dev/null +++ b/scripts/test_local_transit_providers.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import importlib.util +import socket +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("check-local-transit-providers.py") +SPEC = importlib.util.spec_from_file_location("transit_check", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class TransitProviderTests(unittest.TestCase): + def test_missing_providers_fail_explicitly(self) -> None: + with tempfile.TemporaryDirectory(dir="/tmp", prefix="s") as temporary: + self.assertEqual(len(MODULE.failures(Path(temporary))), 7) + + def test_every_unix_socket_passes(self) -> None: + with tempfile.TemporaryDirectory(dir="/tmp", prefix="s") as temporary: + root = Path(temporary) + sockets = [] + try: + for provider in MODULE.PROVIDERS: + path = root / "config/evidence/local/cells" / provider / "transit/transit-proxy.sock" + path.parent.mkdir(parents=True) + instance = socket.socket(socket.AF_UNIX) + instance.bind(str(path)) + sockets.append(instance) + self.assertEqual(MODULE.failures(root), []) + finally: + for instance in sockets: + instance.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_local_transit_proxy.py b/scripts/test_local_transit_proxy.py new file mode 100644 index 0000000..6f3bd29 --- /dev/null +++ b/scripts/test_local_transit_proxy.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import json +import socket +import stat +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, utils + +SCRIPT = Path(__file__).with_name("local-transit-proxy.py") +SPEC = importlib.util.spec_from_file_location("local_transit_proxy", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def private_jwk(private_key: ec.EllipticCurvePrivateKey) -> dict[str, str]: + private = private_key.private_numbers() + public = private.public_numbers + return { + "alg": "ES256", + "crv": "P-256", + "d": b64url(private.private_value.to_bytes(32, "big")), + "kid": "solmara-test-key", + "kty": "EC", + "x": b64url(public.x.to_bytes(32, "big")), + "y": b64url(public.y.to_bytes(32, "big")), + } + + +class LocalTransitProxyTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(dir="/tmp", prefix="stp-") + self.root = Path(self.temporary.name) + self.private_key = ec.generate_private_key(ec.SECP256R1()) + self.jwk = private_jwk(self.private_key) + self.key_path = self.root / "signing.jwk" + self.key_path.write_text( + json.dumps(self.jwk, separators=(",", ":"), sort_keys=True), + encoding="utf-8", + ) + self.key_path.chmod(0o600) + self.socket_path = self.root / "transit.sock" + self.server = None + self.thread = None + + def tearDown(self) -> None: + if self.server is not None: + if self.thread is not None and self.thread.is_alive(): + self.server.shutdown() + self.thread.join(timeout=2) + self.server.server_close() + self.temporary.cleanup() + + def start(self) -> None: + self.server = MODULE.build_server( + self.key_path, self.socket_path, "solmara-test-key" + ) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def request( + self, + method: str, + path: str, + *, + body: bytes = b"", + vault_header: str | None = "true", + content_type: str | None = None, + extra_headers: list[tuple[str, str]] | None = None, + declared_length: int | None = None, + ) -> tuple[int, bytes, bytes]: + headers = [("Host", "localhost"), ("Connection", "close")] + if vault_header is not None: + headers.append(("X-Vault-Request", vault_header)) + if content_type is not None: + headers.append(("Content-Type", content_type)) + if body or declared_length is not None: + headers.append( + ( + "Content-Length", + str(len(body) if declared_length is None else declared_length), + ) + ) + headers.extend(extra_headers or []) + request = ( + f"{method} {path} HTTP/1.1\r\n" + + "".join(f"{name}: {value}\r\n" for name, value in headers) + + "\r\n" + ).encode("ascii") + body + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(2) + client.connect(str(self.socket_path)) + client.sendall(request) + try: + client.shutdown(socket.SHUT_WR) + except OSError: + # A small refusal can be written and closed before shutdown. + pass + chunks = bytearray() + while True: + chunk = client.recv(4096) + if not chunk: + break + chunks.extend(chunk) + head, response_body = bytes(chunks).split(b"\r\n\r\n", 1) + status = int(head.split(b" ", 2)[1]) + return status, head, response_body + + def sign_body(self, payload: bytes, **updates: object) -> bytes: + document: dict[str, object] = { + "input": base64.b64encode(hashlib.sha256(payload).digest()).decode("ascii"), + "key_version": 1, + "marshaling_algorithm": "jws", + "prehashed": True, + } + document.update(updates) + return json.dumps(document, separators=(",", ":")).encode("utf-8") + + def test_metadata_and_prehashed_signature_match_the_transit_contract(self) -> None: + self.start() + self.assertTrue(self.key_path.exists()) + status, _, body = self.request("GET", "/v1/transit/keys/solmara-test-key") + self.assertEqual(status, 200) + data = json.loads(body)["data"] + self.assertEqual(data["type"], "ecdsa-p256") + self.assertIs(data["supports_signing"], True) + for field in ("derived", "exportable", "allow_plaintext_backup"): + self.assertIs(data[field], False) + self.assertEqual(data["latest_version"], 1) + self.assertEqual(data["min_encryption_version"], 1) + public_from_metadata = serialization.load_pem_public_key( + data["keys"]["1"]["public_key"].encode("ascii") + ) + self.assertEqual( + public_from_metadata.public_numbers(), + self.private_key.public_key().public_numbers(), + ) + + payload = b"Solmara authority-owned Evidence" + sign_body = self.sign_body(payload) + status, _, body = self.request( + "POST", + "/v1/transit/sign/solmara-test-key/sha2-256", + body=sign_body, + content_type="application/json", + ) + self.assertEqual(status, 200) + signature_text = json.loads(body)["data"]["signature"] + self.assertTrue(signature_text.startswith("vault:v1:")) + raw = base64.urlsafe_b64decode(signature_text.removeprefix("vault:v1:") + "==") + self.assertEqual(len(raw), 64) + der = utils.encode_dss_signature( + int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big") + ) + self.private_key.public_key().verify( + der, + hashlib.sha256(payload).digest(), + ec.ECDSA(utils.Prehashed(hashes.SHA256())), + ) + + def test_opt_in_consumes_the_hosted_staged_key_and_private_directory(self) -> None: + with tempfile.TemporaryDirectory( + dir="/tmp", prefix="solmara-transit-" + ) as staged_name: + staged_directory = Path(staged_name) + staged_directory.chmod(0o700) + staged_key = staged_directory / "signing.jwk" + staged_key.write_text( + json.dumps(self.jwk, separators=(",", ":"), sort_keys=True), + encoding="utf-8", + ) + staged_key.chmod(0o600) + + application = MODULE.TransitApplication( + staged_key, + "solmara-test-key", + consume_private_jwk=True, + ) + + self.assertFalse(staged_key.exists()) + self.assertFalse(staged_directory.exists()) + self.assertEqual( + application.metadata_path, + "/v1/transit/keys/solmara-test-key", + ) + + def test_opt_in_refuses_nonstaged_or_invalid_keys_without_unlinking(self) -> None: + with self.assertRaisesRegex(MODULE.ProxyError, "invalid key"): + MODULE.TransitApplication( + self.key_path, + "solmara-test-key", + consume_private_jwk=True, + ) + self.assertTrue(self.key_path.exists()) + + with tempfile.TemporaryDirectory( + dir="/tmp", prefix="solmara-transit-" + ) as staged_name: + staged_key = Path(staged_name) / "signing.jwk" + staged_key.write_text("{}", encoding="utf-8") + staged_key.chmod(0o600) + with self.assertRaisesRegex(MODULE.ProxyError, "invalid key"): + MODULE.TransitApplication( + staged_key, + "solmara-test-key", + consume_private_jwk=True, + ) + self.assertTrue(staged_key.exists()) + + def test_wrong_path_header_and_key_are_generically_refused(self) -> None: + self.start() + cases = [ + self.request("GET", "/v1/transit/keys/another-key"), + self.request("GET", "/v1/other/keys/solmara-test-key"), + self.request("GET", "/v1/transit/keys/solmara-test-key", vault_header=None), + self.request( + "GET", "/v1/transit/keys/solmara-test-key", vault_header="false" + ), + ] + self.assertEqual([status for status, _, _ in cases], [404, 404, 403, 403]) + self.assertTrue(all(body == MODULE.ERROR_DOCUMENT for _, _, body in cases)) + + def test_wrong_version_and_every_nonexact_body_are_refused(self) -> None: + self.start() + path = "/v1/transit/sign/solmara-test-key/sha2-256" + bodies = [ + self.sign_body(b"payload", key_version=2), + self.sign_body(b"payload", prehashed=False), + self.sign_body(b"payload", marshaling_algorithm="asn1"), + self.sign_body(b"payload", input=base64.b64encode(b"short").decode()), + self.sign_body(b"payload", unexpected=True), + b'{"input":"one","input":"two","key_version":1,"marshaling_algorithm":"jws","prehashed":true}', + b"not-json", + ] + for body in bodies: + with self.subTest(body=body[:40]): + status, _, response = self.request( + "POST", path, body=body, content_type="application/json" + ) + self.assertEqual(status, 400) + self.assertEqual(response, MODULE.ERROR_DOCUMENT) + status, _, response = self.request( + "POST", path, body=self.sign_body(b"payload") + ) + self.assertEqual((status, response), (400, MODULE.ERROR_DOCUMENT)) + + def test_request_and_header_bounds_fail_closed(self) -> None: + self.start() + path = "/v1/transit/sign/solmara-test-key/sha2-256" + status, _, response = self.request( + "POST", + path, + content_type="application/json", + declared_length=MODULE.MAX_REQUEST_BODY_BYTES + 1, + ) + self.assertEqual((status, response), (400, MODULE.ERROR_DOCUMENT)) + status, _, response = self.request( + "GET", + "/v1/transit/keys/solmara-test-key", + extra_headers=[("X-Fill", "a" * MODULE.MAX_HEADER_LINE_BYTES)], + ) + self.assertEqual((status, response), (400, MODULE.ERROR_DOCUMENT)) + + def test_key_and_socket_permissions_are_enforced(self) -> None: + self.key_path.chmod(0o644) + with self.assertRaisesRegex(MODULE.ProxyError, "invalid key"): + MODULE.build_server(self.key_path, self.socket_path, "solmara-test-key") + self.key_path.chmod(0o600) + self.start() + metadata = self.socket_path.stat() + self.assertTrue(stat.S_ISSOCK(metadata.st_mode)) + self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600) + + def test_symlinked_key_and_writable_socket_directory_are_refused(self) -> None: + link = self.root / "link.jwk" + link.symlink_to(self.key_path) + with self.assertRaisesRegex(MODULE.ProxyError, "invalid key"): + MODULE.build_server(link, self.socket_path, "solmara-test-key") + self.root.chmod(0o777) + try: + with self.assertRaisesRegex(MODULE.ProxyError, "invalid socket"): + MODULE.build_server(self.key_path, self.socket_path, "solmara-test-key") + finally: + self.root.chmod(0o700) + + def test_responses_and_errors_never_expose_private_material(self) -> None: + self.start() + private_canary = self.jwk["d"].encode("ascii") + responses = [ + self.request("GET", "/v1/transit/keys/solmara-test-key")[2], + self.request("GET", "/v1/transit/keys/private-canary")[2], + self.request( + "POST", + "/v1/transit/sign/solmara-test-key/sha2-256", + body=b'{"input":"private-canary"}', + content_type="application/json", + )[2], + ] + self.assertTrue(all(private_canary not in response for response in responses)) + metadata = json.loads(responses[0]) + self.assertNotIn("d", metadata["data"]["keys"]["1"]) + application = self.server.application + + class FailingKey: + def sign(self, _digest: bytes, _algorithm: object) -> bytes: + raise ValueError("private-canary") + + application._private_key = FailingKey() + failure = self.request( + "POST", + "/v1/transit/sign/solmara-test-key/sha2-256", + body=self.sign_body(b"payload"), + content_type="application/json", + ) + self.assertEqual((failure[0], failure[2]), (500, MODULE.ERROR_DOCUMENT)) + self.assertNotIn(b"private-canary", failure[2]) + malformed = dict(self.jwk) + malformed["d"] = "private-canary" + self.key_path.write_text(json.dumps(malformed), encoding="utf-8") + self.key_path.chmod(0o600) + with self.assertRaises(MODULE.ProxyError) as context: + MODULE.TransitApplication(self.key_path, "solmara-test-key") + self.assertNotIn("private-canary", str(context.exception)) + + def test_cli_sigterm_stops_cleanly_and_removes_its_socket(self) -> None: + process = subprocess.Popen( + [ + sys.executable, + str(SCRIPT), + "--private-jwk", + str(self.key_path), + "--socket", + str(self.socket_path), + "--key-name", + "solmara-test-key", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + for _ in range(100): + if self.socket_path.exists(): + break + self.assertIsNone(process.poll()) + time.sleep(0.01) + else: + self.fail("proxy socket did not become ready") + process.terminate() + stdout, stderr = process.communicate(timeout=2) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=2) + self.assertEqual(process.returncode, 0) + self.assertEqual((stdout, stderr), (b"", b"")) + self.assertFalse(self.socket_path.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_local_transit_signers.py b/scripts/test_local_transit_signers.py new file mode 100644 index 0000000..e2603f2 --- /dev/null +++ b/scripts/test_local_transit_signers.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("local-transit-signers.py") +SPEC = importlib.util.spec_from_file_location("local_transit_signers", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class LocalTransitSignerOrchestrationTests(unittest.TestCase): + def test_exact_provider_and_key_inventory(self) -> None: + self.assertEqual( + MODULE.PROVIDERS, + ("mint", "cra", "nia", "sro", "mosd-programme", "sipf", "nagdi"), + ) + self.assertEqual(MODULE.key_name("mint"), "solmara-mint") + for provider in MODULE.PROVIDERS[1:]: + self.assertEqual(MODULE.key_name(provider), f"solmara-evidence-{provider}") + private_jwk, socket_path, _ = MODULE.paths(provider) + self.assertEqual(private_jwk.name, "signing.jwk") + self.assertEqual(socket_path.name, "transit-proxy.sock") + + def test_stop_never_claims_an_unrelated_pid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + pid_file = Path(temporary) / "unrelated.pid" + pid_file.write_text(f"{os.getpid()}\n", encoding="ascii") + self.assertIsNone(MODULE.owned_process(pid_file)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_metadata_authority_contracts.py b/scripts/test_metadata_authority_contracts.py index 43d69d3..1322808 100644 --- a/scripts/test_metadata_authority_contracts.py +++ b/scripts/test_metadata_authority_contracts.py @@ -10,24 +10,41 @@ class AuthorityMetadataContractTests(unittest.TestCase): - def test_notary_data_services_publish_real_runtime_routes(self) -> None: + def test_six_authority_evidence_services_publish_real_runtime_routes(self) -> None: catalog = json.loads((METADATA / "catalog.json").read_text(encoding="utf-8")) authority_services = [ service for service in catalog["data_services"] - if service["id"].endswith("-notary-api") + if service["id"].endswith("-evidence-api") ] self.assertEqual(len(authority_services), 6) + self.assertEqual( + {service["id"] for service in authority_services}, + { + "cra-evidence-api", + "nia-evidence-api", + "sro-evidence-api", + "mosd-programme-evidence-api", + "sipf-evidence-api", + "nagdi-evidence-api", + }, + ) for service in authority_services: with self.subTest(service=service["id"]): - self.assertTrue(service["iri"].endswith("/v1/evaluations")) + self.assertTrue(service["iri"].endswith("/v1/evidence")) self.assertEqual(service["endpoint_url"], service["iri"]) self.assertTrue( - service["endpoint_description"].endswith("/openapi.json") + service["endpoint_description"].endswith( + "/v1/evidence-definitions" + ) + ) + self.assertEqual( + service["conforms_to"], + "https://id.registrystack.org/spec/registry-evidence/v1", ) - def test_authority_offerings_publish_evaluation_and_discovery_routes(self) -> None: + def test_all_eleven_operational_requirements_have_authority_offerings(self) -> None: document = json.loads( (METADATA / "evidence-offerings.json").read_text(encoding="utf-8") ) @@ -37,41 +54,49 @@ def test_authority_offerings_publish_evaluation_and_discovery_routes(self) -> No if offering["access"]["kind"] == "evidence-verification-api" ] - self.assertEqual(len(authority_offerings), 8) + self.assertEqual(len(authority_offerings), 11) + self.assertEqual( + {offering["access"]["source_type"] for offering in authority_offerings}, + {"immutable extract", "Relay lookup"}, + ) for offering in authority_offerings: with self.subTest(offering=offering["id"]): access = offering["access"] - self.assertTrue(access["endpoint_url"].endswith("/v1/evaluations")) + self.assertTrue(access["endpoint_url"].endswith("/v1/evidence")) self.assertTrue( - access["discovery_url"].endswith("/.well-known/evidence-service") + access["discovery_url"].endswith("/v1/evidence-definitions") ) - def test_offering_purposes_match_notary_services(self) -> None: + def test_offering_purposes_match_authority_requirements(self) -> None: document = json.loads( (METADATA / "evidence-offerings.json").read_text(encoding="utf-8") ) offerings = {offering["id"]: offering for offering in document["offerings"]} self.assertEqual( - offerings["sipf-pensions-pension-case-offering"]["purposes"], + offerings["sipf-pension-payment-v1-offering"]["purposes"], + ["https://id.registrystack.org/solmara/purpose/pension-payment-review"], + ) + self.assertEqual( + offerings["sipf-survivor-benefit-v1-offering"]["purposes"], [ - "https://id.registrystack.org/solmara/purpose/pension-payment-review", - "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination", + "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination" ], ) self.assertEqual( - offerings["nagdi-agriculture-farmer-voucher-offering"]["purposes"], + offerings["nagdi-voucher-v1-offering"]["purposes"], ["https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"], ) self.assertEqual( - offerings["nagdi-agriculture-livestock-movement-offering"]["purposes"], + offerings["nagdi-livestock-v1-offering"]["purposes"], ["https://id.registrystack.org/solmara/purpose/livestock-movement-control"], ) def test_cra_offerings_publish_only_the_supported_uin_lookup(self) -> None: for offering_id in ( - "cra-birth-registration-offering", - "cra-death-registration-offering", + "cra-child-benefit-v1-offering", + "cra-pension-death-v1-offering", + "cra-citizen-record-v1-offering", ): with self.subTest(offering=offering_id): offering = json.loads( @@ -90,8 +115,8 @@ def test_survivor_example_targets_the_surviving_spouse(self) -> None: / "03 - Read survivor eligibility.bru" ).read_text(encoding="utf-8") - self.assertIn('"value": "2300118698"', request) - self.assertNotIn('"value": "2300109568"', request) + self.assertIn('"values": {"uin": "2300118698"}', request) + self.assertNotIn('"values": {"uin": "2300109568"}', request) if __name__ == "__main__": diff --git a/scripts/test_notary_state_restart.py b/scripts/test_notary_state_restart.py deleted file mode 100644 index c32e30e..0000000 --- a/scripts/test_notary_state_restart.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Deterministic tests for the Notary PostgreSQL restart-persistence gate.""" - -from __future__ import annotations - -import importlib.util -import io -import json -import sys -import tempfile -import unittest -from contextlib import redirect_stdout -from pathlib import Path - - -SCRIPT = Path(__file__).with_name("notary_state_restart.py") -SPEC = importlib.util.spec_from_file_location("notary_state_restart", SCRIPT) -if SPEC is None or SPEC.loader is None: - raise RuntimeError(f"could not load {SCRIPT}") -restart = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = restart -SPEC.loader.exec_module(restart) - - -def valid_compose_config() -> dict[str, object]: - return { - "services": { - "postgres": { - "volumes": [ - { - "type": "volume", - "source": "postgres-data", - "target": "/var/lib/postgresql/data", - "read_only": False, - } - ] - } - }, - "volumes": {"postgres-data": {"name": "demo_postgres-data"}}, - } - - -def valid_counts(*, increment: int = 0) -> tuple[tuple[str, str, int], ...]: - rows = [] - for authority_index, (authority, _service) in enumerate(restart.AUTHORITIES): - for table_index, table in enumerate(restart.CORRECTNESS_TABLES): - rows.append( - (authority, table, authority_index + table_index + 1 + increment) - ) - return tuple(sorted(rows)) - - -def snapshot( - *, - container_id: str = "postgres-before", - system_identifier: str = "7541234567890123456", - version: int = 160010, - data_directory: str = "/var/lib/postgresql/data", - volume_name: str = "demo_postgres-data", - counts: tuple[tuple[str, str, int], ...] | None = None, -) -> restart.StateSnapshot: - return restart.StateSnapshot( - container_id=container_id, - system_identifier=system_identifier, - server_version_num=version, - data_directory=data_directory, - volume_name=volume_name, - counts=valid_counts() if counts is None else counts, - ) - - -class RecordingRunner: - def __init__(self, *, outputs: list[str] | None = None) -> None: - self.calls: list[tuple[list[str], Path, dict[str, str]]] = [] - self.outputs = list(outputs or []) - self.now = 0.0 - - def run( - self, - command: list[str] | tuple[str, ...], - *, - cwd: Path, - env: dict[str, str], - ) -> str: - self.calls.append((list(command), cwd, dict(env))) - return self.outputs.pop(0) if self.outputs else "" - - def monotonic(self) -> float: - return self.now - - def sleep(self, seconds: float) -> None: - self.now += seconds - - -class ComposeLayoutTests(unittest.TestCase): - def test_accepts_exact_postgresql_16_named_volume(self) -> None: - self.assertEqual( - restart.validate_compose_pgdata_layout(valid_compose_config()), - "demo_postgres-data", - ) - - def test_rejects_nonexact_storage_mounts(self) -> None: - invalid_mounts = ( - { - "type": "volume", - "source": "other", - "target": "/var/lib/postgresql/data", - }, - { - "type": "bind", - "source": "./postgres-data", - "target": "/var/lib/postgresql/data", - }, - { - "type": "volume", - "source": "postgres-data", - "target": "/var/lib/postgresql/data", - "read_only": True, - }, - { - "type": "volume", - "source": "postgres-data", - "target": "/var/lib/postgresql", - }, - { - "type": "volume", - "source": "postgres-data", - "target": "/var/lib/postgresql/data/nested", - }, - ) - for mount in invalid_mounts: - with self.subTest(mount=mount): - config = valid_compose_config() - config["services"]["postgres"]["volumes"] = [mount] - with self.assertRaises(restart.GateError): - restart.validate_compose_pgdata_layout(config) - - def test_rejects_second_mount_above_pgdata(self) -> None: - config = valid_compose_config() - config["services"]["postgres"]["volumes"].append( - { - "type": "bind", - "source": "/tmp", - "target": "/var/lib", - } - ) - with self.assertRaises(restart.GateError): - restart.validate_compose_pgdata_layout(config) - - -class RuntimeMountTests(unittest.TestCase): - def setUp(self) -> None: - self.mount = { - "Type": "volume", - "Name": "demo_postgres-data", - "Destination": "/var/lib/postgresql/data", - "RW": True, - } - self.labels = { - "com.docker.compose.project": "demo", - "com.docker.compose.volume": "postgres-data", - } - - def validate(self, mounts: object, labels: object | None = None) -> None: - restart.validate_runtime_pgdata_mounts( - mounts, - expected_volume="demo_postgres-data", - volume_labels=self.labels if labels is None else labels, - compose_project_name="demo", - ) - - def test_accepts_exact_runtime_mount_and_labels(self) -> None: - self.validate([self.mount]) - - def test_rejects_anonymous_bind_readonly_parent_and_nested_mounts(self) -> None: - changes = ( - {"Name": ""}, - {"Type": "bind"}, - {"RW": False}, - {"Destination": "/var/lib/postgresql"}, - {"Destination": "/var/lib/postgresql/data/nested"}, - ) - for change in changes: - with self.subTest(change=change): - mount = {**self.mount, **change} - with self.assertRaises(restart.GateError): - self.validate([mount]) - - def test_rejects_any_overlapping_second_mount(self) -> None: - for destination in ( - "/", - "/var/lib", - "/var/lib/postgresql", - "/var/lib/postgresql/data/nested", - ): - with self.subTest(destination=destination): - overlapping = { - "Type": "bind", - "Source": "/tmp", - "Destination": destination, - "RW": True, - } - with self.assertRaises(restart.GateError): - self.validate([self.mount, overlapping]) - - def test_rejects_wrong_compose_ownership_labels(self) -> None: - for key in self.labels: - with self.subTest(key=key): - labels = {**self.labels, key: "other"} - with self.assertRaises(restart.GateError): - self.validate([self.mount], labels) - - -class SnapshotParsingTests(unittest.TestCase): - def test_parses_postgresql_16_control_snapshot(self) -> None: - self.assertEqual( - restart.parse_control_snapshot( - "7541234567890123456\n160010\n/var/lib/postgresql/data\n" - ), - ("7541234567890123456", 160010, "/var/lib/postgresql/data"), - ) - - def test_rejects_wrong_major_or_data_directory(self) -> None: - for output in ( - "7541234567890123456\n170001\n/var/lib/postgresql/data\n", - "7541234567890123456\n160010\n/var/lib/postgresql/16/docker\n", - "not-a-number\n160010\n/var/lib/postgresql/data\n", - ): - with self.subTest(output=output): - with self.assertRaises(restart.GateError): - restart.parse_control_snapshot(output) - - def test_parses_exact_nine_authority_counts(self) -> None: - output = "\n".join( - f"{table}|{index + 1}" - for index, table in enumerate(restart.CORRECTNESS_TABLES) - ) - parsed = restart.parse_authority_counts("cra", output) - self.assertEqual(set(parsed), set(restart.CORRECTNESS_TABLES)) - self.assertEqual(sum(parsed.values()), 45) - - def test_rejects_zero_missing_duplicate_or_unknown_counts(self) -> None: - valid_lines = [ - f"{table}|1" for table in restart.CORRECTNESS_TABLES - ] - invalid = ( - [f"{table}|0" for table in restart.CORRECTNESS_TABLES], - valid_lines[:-1], - [*valid_lines, valid_lines[0]], - [*valid_lines[:-1], "schema_metadata|1"], - ) - for lines in invalid: - with self.subTest(lines=lines): - with self.assertRaises(restart.GateError): - restart.parse_authority_counts("cra", "\n".join(lines)) - - def test_count_query_has_only_the_nine_correctness_tables(self) -> None: - query = restart.RestartPersistenceGate._counts_sql() - for table in restart.CORRECTNESS_TABLES: - self.assertIn(f"registry_notary_private.{table}", query) - self.assertNotIn("schema_metadata", query) - self.assertEqual(query.count("count(*)"), 9) - - -class SnapshotComparisonTests(unittest.TestCase): - def test_accepts_replacement_container_with_identical_state(self) -> None: - restart.compare_snapshots( - snapshot(container_id="postgres-before"), - snapshot(container_id="postgres-after"), - ) - - def test_rejects_reused_container_or_changed_identity_metadata_and_counts(self) -> None: - after_changes = ( - {}, - {"container_id": "postgres-after", "system_identifier": "123"}, - {"container_id": "postgres-after", "version": 160011}, - { - "container_id": "postgres-after", - "data_directory": "/var/lib/postgresql/other", - }, - {"container_id": "postgres-after", "volume_name": "other"}, - { - "container_id": "postgres-after", - "counts": valid_counts(increment=1), - }, - ) - before = snapshot() - for change in after_changes: - with self.subTest(change=change): - with self.assertRaises(restart.GateError): - restart.compare_snapshots(before, snapshot(**change)) - - -class ResolveComposeTests(unittest.TestCase): - def test_preserves_explicit_project_and_inherited_image_overrides(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for name in (".env", "versions.env", "compose.yaml"): - (root / name).touch() - runner = RecordingRunner(outputs=[json.dumps(valid_compose_config())]) - environment = { - "COMPOSE_PROJECT_NAME": "explicit-project", - "POSTGRES_IMAGE": "postgres:16.99", - "REGISTRY_NOTARY_IMAGE": "notary:test", - } - gate = restart.RestartPersistenceGate( - root=root, - runner=runner, - environ=environment, - ) - - gate.resolve_compose() - - self.assertEqual(gate.compose_project_name, "explicit-project") - self.assertEqual(gate.expected_volume, "demo_postgres-data") - self.assertEqual(len(runner.calls), 1) - command, cwd, used_environment = runner.calls[0] - self.assertEqual(command[-3:], ["config", "--format", "json"]) - self.assertEqual(cwd, root.resolve()) - self.assertEqual(used_environment, environment) - - -class WaitForStackTests(unittest.TestCase): - class HealthyRunner(RecordingRunner): - def __init__(self, *, installer_exit_code: int = 0) -> None: - super().__init__() - self.installer_exit_code = installer_exit_code - - def run( - self, - command: list[str] | tuple[str, ...], - *, - cwd: Path, - env: dict[str, str], - ) -> str: - command = list(command) - self.calls.append((command, cwd, dict(env))) - if "ps" in command and "-q" in command: - return "container-" + command[-1] - if command[:3] == ["docker", "inspect", "--format"]: - template = command[3] - if "ExitCode" in template: - return f"exited|{self.installer_exit_code}" - return "healthy" - raise AssertionError(f"unexpected command: {command}") - - def gate(self, runner: RecordingRunner) -> restart.RestartPersistenceGate: - gate = restart.RestartPersistenceGate( - root=Path.cwd(), - runner=runner, - environ={"COMPOSE_PROJECT_NAME": "demo"}, - wait_seconds=5, - ) - gate.compose = ["docker", "compose"] - return gate - - def test_waits_for_all_healthchecks_and_installers(self) -> None: - runner = self.HealthyRunner() - self.gate(runner).wait_for_stack() - compose_ps = [call[0] for call in runner.calls if "ps" in call[0]] - self.assertEqual(len(compose_ps), 13) - installer_services = { - command[-1] for command in compose_ps if "--all" in command - } - self.assertEqual( - installer_services, - { - f"{service}-state-install" - for _authority, service in restart.AUTHORITIES - }, - ) - self.assertEqual(runner.now, 0.0) - - def test_fails_immediately_when_an_installer_exits_nonzero(self) -> None: - runner = self.HealthyRunner(installer_exit_code=7) - with self.assertRaisesRegex(restart.GateError, "installer failed"): - self.gate(runner).wait_for_stack() - - def test_times_out_with_the_pending_service(self) -> None: - runner = self.HealthyRunner() - - def unhealthy_run( - command: list[str] | tuple[str, ...], - *, - cwd: Path, - env: dict[str, str], - ) -> str: - output = WaitForStackTests.HealthyRunner.run( - runner, command, cwd=cwd, env=env - ) - if command[:3] == ["docker", "inspect", "--format"]: - if "ExitCode" not in command[3]: - return "starting" - return output - - runner.run = unhealthy_run - with self.assertRaisesRegex(restart.GateError, "postgres=starting"): - self.gate(runner).wait_for_stack() - self.assertGreaterEqual(runner.now, 5.0) - - -class OrchestrationTests(unittest.TestCase): - class HarnessGate(restart.RestartPersistenceGate): - def __init__(self, runner: RecordingRunner, events: list[str]) -> None: - super().__init__( - root=Path.cwd(), - runner=runner, - environ={ - "COMPOSE_PROJECT_NAME": "explicit-project", - "POSTGRES_IMAGE": "postgres:16.99", - "REGISTRY_NOTARY_IMAGE": "notary:test", - }, - ) - self.events = events - self.snapshots = [ - snapshot(container_id="postgres-before"), - snapshot(container_id="postgres-after"), - ] - - def resolve_compose(self) -> None: - self.events.append("resolve") - self.compose_project_name = self.env["COMPOSE_PROJECT_NAME"] - self.compose = ["docker", "compose"] - self.expected_volume = "demo_postgres-data" - - def wait_for_stack(self) -> None: - self.events.append("wait") - - def doctor_all(self) -> None: - self.events.append("doctor") - - def capture_snapshot(self) -> restart.StateSnapshot: - name = "capture-before" if len(self.snapshots) == 2 else "capture-after" - self.events.append(name) - return self.snapshots.pop(0) - - def service_container_id( - self, service: str, *, include_stopped: bool = False - ) -> str: - self.events.append(f"assert-removed:{service}:{include_stopped}") - return "" - - def test_exact_down_up_order_and_inherited_environment(self) -> None: - events: list[str] = [] - runner = RecordingRunner() - gate = self.HarnessGate(runner, events) - - with redirect_stdout(io.StringIO()): - gate.run_gate() - - self.assertEqual( - events, - [ - "resolve", - "wait", - "doctor", - "capture-before", - "assert-removed:postgres:True", - "wait", - "capture-after", - "doctor", - ], - ) - self.assertEqual([call[0] for call in runner.calls], [["just", "down"], ["just", "up"]]) - for _command, cwd, environment in runner.calls: - self.assertEqual(cwd, Path.cwd().resolve()) - self.assertEqual(environment["COMPOSE_PROJECT_NAME"], "explicit-project") - self.assertEqual(environment["POSTGRES_IMAGE"], "postgres:16.99") - self.assertEqual(environment["REGISTRY_NOTARY_IMAGE"], "notary:test") - - def test_command_failure_stops_before_up(self) -> None: - class FailingRunner(RecordingRunner): - def run(self, command, *, cwd, env): - super().run(command, cwd=cwd, env=env) - raise restart.CommandError("down failed") - - events: list[str] = [] - runner = FailingRunner() - gate = self.HarnessGate(runner, events) - with self.assertRaisesRegex(restart.CommandError, "down failed"): - gate.run_gate() - self.assertEqual([call[0] for call in runner.calls], [["just", "down"]]) - self.assertEqual(events[-1], "capture-before") - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_project_runtime_secrets.py b/scripts/test_project_runtime_secrets.py new file mode 100644 index 0000000..b64d515 --- /dev/null +++ b/scripts/test_project_runtime_secrets.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import importlib.util +import stat +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("project-runtime-secrets.py") +SPEC = importlib.util.spec_from_file_location("secret_projection", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class RuntimeSecretProjectionTests(unittest.TestCase): + def test_signing_keys_never_enter_runtime_projection(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + private, output = root / "private", root / "runtime" + for cell, clients in MODULE.CELL_CLIENTS.items(): + secrets = private / cell / "secrets" + secrets.mkdir(parents=True) + client_files = tuple(f"{client}-{suffix}" for client in clients for suffix in ("client-id", "client-key")) + for name in ("signing.jwk", "audit-hmac-key", "subject-binding-hmac-key", *client_files): + (secrets / name).write_text(name) + mint = private / "mint/secrets" + mint.mkdir(parents=True) + (mint / "signing.jwk").write_text("private") + (mint / "audit-hmac-key").write_text("audit") + MODULE.project(private, output) + self.assertFalse(list(output.rglob("signing.jwk"))) + self.assertTrue((output / "cra/cra-pension-evidence-client-key").exists()) + self.assertEqual( + (output / "cra/cra-pension-evidence-client-id").read_text(), + "cra-pension-evidence-client-id", + ) + self.assertEqual((output / "mint/audit-hmac-key").read_text(), "audit") + for path in output.glob("*/*"): + metadata = path.stat(follow_symlinks=False) + self.assertTrue(stat.S_ISREG(metadata.st_mode), path) + self.assertEqual(metadata.st_nlink, 1, path) + self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600, path) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_provision_hosted_runtime.py b/scripts/test_provision_hosted_runtime.py new file mode 100644 index 0000000..6f2d87c --- /dev/null +++ b/scripts/test_provision_hosted_runtime.py @@ -0,0 +1,1096 @@ +from __future__ import annotations + +import base64 +import copy +import contextlib +import hashlib +import importlib.util +import json +import os +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import unittest +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import yaml +from cryptography.hazmat.primitives.asymmetric import ec + + +SCRIPT = Path(__file__).with_name("provision-hosted-runtime.py") +SPEC = importlib.util.spec_from_file_location("provision_hosted_runtime", SCRIPT) +assert SPEC and SPEC.loader +provisioner = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(provisioner) +REPOSITORY = SCRIPT.parent.parent + + +def changed_paths(before, after, prefix: tuple[str, ...] = ()) -> set[tuple[str, ...]]: + if isinstance(before, dict) and isinstance(after, dict): + result: set[tuple[str, ...]] = set() + for key in before.keys() | after.keys(): + result.update( + changed_paths(before.get(key), after.get(key), (*prefix, str(key))) + ) + return result + if before != after: + return {prefix} + return set() + + +def write_manifest(root: Path) -> None: + files = { + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() and path.name != "manifest.json" + } + (root / "manifest.json").write_text( + json.dumps({"format": 1, "files": files}, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def key_pair() -> tuple[dict[str, str], dict[str, str]]: + private_key = ec.generate_private_key(ec.SECP256R1()) + private_numbers = private_key.private_numbers() + public_numbers = private_numbers.public_numbers + public = { + "alg": "ES256", + "crv": "P-256", + "kty": "EC", + "x": b64url(public_numbers.x.to_bytes(32, "big")), + "y": b64url(public_numbers.y.to_bytes(32, "big")), + } + thumbprint = {key: public[key] for key in ("crv", "kty", "x", "y")} + public["kid"] = b64url( + hashlib.sha256( + json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode() + ).digest() + ) + private = {**public, "d": b64url(private_numbers.private_value.to_bytes(32, "big"))} + return public, private + + +def write_secret(root: Path, name: str, value: bytes | dict[str, str]) -> None: + root.mkdir(parents=True, exist_ok=True) + data = json.dumps(value).encode() if isinstance(value, dict) else value + path = root / name + if path.exists(): + path.chmod(0o600) + path.write_bytes(data) + path.chmod(0o400) + + +class HostedProvisionerTests(unittest.TestCase): + def test_rollout_origins_are_exact_canonical_https_origins(self) -> None: + origins = [provisioner.MINT_ORIGIN, *provisioner.RELAY_ORIGINS.values()] + for origin in origins: + with self.subTest(origin=origin): + self.assertEqual(provisioner._validated_origin(origin, origin), origin) + + expected = provisioner.MINT_ORIGIN + hostname = expected.removeprefix("https://") + invalid = [ + None, + f"http://{hostname}", + f"https://user@{hostname}", + f"https://{hostname}:443", + f"https://{hostname}/", + f"https://{hostname}/path", + f"https://{hostname}?query=yes", + f"https://{hostname}#fragment", + "https://mint.solmara.registrystack.org", + provisioner.RELAY_ORIGINS["cra"], + ] + for origin in invalid: + with self.subTest(origin=origin): + with self.assertRaises(provisioner.ProvisionError): + provisioner._validated_origin(origin, expected) + + def test_mint_origin_is_required_for_every_provision_target(self) -> None: + for target in [ + "mint", + *(f"{authority}-relay" for authority in provisioner.RELAYS), + *(f"{cell}-evidence" for cell in provisioner.CELLS), + ]: + with self.subTest(target=target): + with self.assertRaises(provisioner.ProvisionError): + provisioner.parser().parse_args( + [ + "provision", + "--target", + target, + "--assets", + "/assets", + "--secrets", + "/secrets", + "--runtime-output", + "/runtime", + ] + ) + + def test_only_relay_backed_evidence_accepts_its_exact_relay_origin(self) -> None: + for cell in provisioner.CELLS: + config = yaml.safe_load( + ( + REPOSITORY + / "evidence" + / "cells" + / cell + / "bundle" + / "evidence.yaml" + ).read_text(encoding="utf-8") + ) + expected = provisioner.RELAY_ORIGINS.get(cell) + if expected is None: + provisioner._patch_evidence_origins( + copy.deepcopy(config), cell, provisioner.MINT_ORIGIN, None + ) + with self.assertRaises(provisioner.ProvisionError): + provisioner._patch_evidence_origins( + copy.deepcopy(config), + cell, + provisioner.MINT_ORIGIN, + provisioner.RELAY_ORIGINS["cra"], + ) + else: + provisioner._patch_evidence_origins( + copy.deepcopy(config), cell, provisioner.MINT_ORIGIN, expected + ) + with self.assertRaises(provisioner.ProvisionError): + provisioner._patch_evidence_origins( + copy.deepcopy(config), cell, provisioner.MINT_ORIGIN, None + ) + foreign = next( + origin + for authority, origin in provisioner.RELAY_ORIGINS.items() + if authority != cell + ) + with self.assertRaises(provisioner.ProvisionError): + provisioner._patch_evidence_origins( + copy.deepcopy(config), cell, provisioner.MINT_ORIGIN, foreign + ) + + def test_origin_patching_changes_only_closed_authentication_fields(self) -> None: + mint_before = yaml.safe_load( + (REPOSITORY / "evidence" / "mint.yaml").read_text(encoding="utf-8") + ) + mint_after = copy.deepcopy(mint_before) + provisioner._patch_mint_origin(mint_after, provisioner.MINT_ORIGIN) + self.assertEqual( + changed_paths(mint_before, mint_after), + {("issuer",), ("clientAssertion", "audience")}, + ) + self.assertEqual(mint_after["accessTokens"], mint_before["accessTokens"]) + self.assertEqual(mint_after["signing"], mint_before["signing"]) + + for authority in provisioner.RELAYS: + before = yaml.safe_load( + (REPOSITORY / "relays" / authority / "runtime.yaml").read_text( + encoding="utf-8" + ) + ) + after = copy.deepcopy(before) + provisioner._patch_relay_origin(after, provisioner.MINT_ORIGIN) + self.assertEqual( + changed_paths(before, after), + {("authentication", "issuer", "discoveryUrl")}, + ) + self.assertEqual(after["sources"], before["sources"]) + self.assertEqual( + after["authentication"]["issuer"]["audience"], + before["authentication"]["issuer"]["audience"], + ) + + for cell in provisioner.CELLS: + before = yaml.safe_load( + ( + REPOSITORY + / "evidence" + / "cells" + / cell + / "bundle" + / "evidence.yaml" + ).read_text(encoding="utf-8") + ) + after = copy.deepcopy(before) + provisioner._patch_evidence_origins( + after, + cell, + provisioner.MINT_ORIGIN, + provisioner.RELAY_ORIGINS.get(cell), + ) + expected_paths = { + ("authentication", "issuer"), + ("authentication", "jwksUri"), + } + for source_name, source in before["sources"].items(): + if source["transport"] == "http-json": + expected_paths.update( + { + ("sources", source_name, "baseUrl"), + ( + "sources", + source_name, + "authentication", + "tokenEndpoint", + ), + ( + "sources", + source_name, + "authentication", + "clientAssertionAudience", + ), + } + ) + for field in ("clientIdRef", "scope", "audience"): + self.assertEqual( + after["sources"][source_name]["authentication"][field], + source["authentication"][field], + ) + self.assertEqual( + after["sources"][source_name]["request"], source["request"] + ) + self.assertEqual(changed_paths(before, after), expected_paths) + self.assertEqual(after["issuer"], before["issuer"]) + self.assertEqual(after["signing"], before["signing"]) + self.assertEqual(after["requirements"], before["requirements"]) + self.assertEqual(after["authorityProfiles"], before["authorityProfiles"]) + + def test_no_argument_cli_failure_is_one_generic_line(self) -> None: + completed = subprocess.run( + [sys.executable, str(SCRIPT)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 1) + self.assertEqual(completed.stdout, "") + self.assertEqual(completed.stderr, f"{provisioner.GENERIC_ERROR}\n") + + def test_unexpected_failures_are_redacted_at_the_command_boundary(self) -> None: + stderr = StringIO() + with ( + mock.patch.object( + provisioner, "provision", side_effect=RuntimeError("canary") + ), + contextlib.redirect_stderr(stderr), + ): + result = provisioner.main( + [ + "provision", + "--target", + "cra-relay", + "--assets", + "/canary/assets", + "--secrets", + "/canary/secrets", + "--runtime-output", + "/canary/runtime", + "--source-output", + "/canary/source", + "--mint-origin", + provisioner.MINT_ORIGIN, + ] + ) + self.assertEqual(result, 1) + self.assertEqual(stderr.getvalue(), f"{provisioner.GENERIC_ERROR}\n") + self.assertNotIn("canary", stderr.getvalue()) + + def test_binary_write_is_exact(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + target = Path(temporary) / "source.sqlite" + content = b"SQLite format 3\x00\n\xff\x00" + provisioner._write(target, content, 0o444) + self.assertEqual(target.read_bytes(), content) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o444) + + def test_install_is_idempotent_only_for_an_exact_existing_tree(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + staged, destination = root / "staged", root / "destination" + (staged / "nested").mkdir(parents=True) + provisioner._write(staged / "one", b"one", 0o444) + provisioner._write(staged / "nested" / "two", b"two", 0o444) + destination.mkdir() + provisioner._write(destination / "one", b"one", 0o444) + + with self.assertRaises(provisioner.ProvisionError): + provisioner._install_tree(staged, destination, root_mode=0o555) + + provisioner._write(destination / "nested" / "two", b"two", 0o444) + (destination / "nested").chmod(0o755) + provisioner._install_tree(staged, destination, root_mode=0o555) + provisioner._install_tree(staged, destination, root_mode=0o555) + + (destination / "one").chmod(0o644) + with self.assertRaises(provisioner.ProvisionError): + provisioner._install_tree(staged, destination, root_mode=0o555) + + def test_secret_reader_rejects_writable_and_symbolic_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + safe = root / "safe" + safe.write_bytes(b"value") + safe.chmod(0o444) + self.assertEqual(provisioner._read_secret(root, "safe"), b"value") + + safe.chmod(0o666) + with self.assertRaises(provisioner.ProvisionError): + provisioner._read_secret(root, "safe") + safe.chmod(0o444) + os.symlink(safe, root / "link") + with self.assertRaises(provisioner.ProvisionError): + provisioner._read_secret(root, "link") + + def test_hmac_secret_rejects_weak_or_non_text_material(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + secret = root / "hmac" + secret.write_bytes(b"short") + secret.chmod(0o400) + with self.assertRaises(provisioner.ProvisionError): + provisioner._hmac_secret(root, "hmac") + secret.chmod(0o600) + secret.write_bytes(b"a" * 31 + b"\x00") + secret.chmod(0o400) + with self.assertRaises(provisioner.ProvisionError): + provisioner._hmac_secret(root, "hmac") + secret.chmod(0o600) + secret.write_bytes(b"a" * 32) + secret.chmod(0o400) + self.assertEqual(provisioner._hmac_secret(root, "hmac"), b"a" * 32) + + def test_secret_inventory_is_exact_and_authority_scoped(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + write_secret(root, "signing-public.jwk", b"public") + write_secret(root, "audit-hmac-key", b"a" * 32) + root.chmod(0o700) + expected = {"signing-public.jwk", "audit-hmac-key"} + provisioner._validate_secret_inventory(root, expected) + + write_secret(root, "another-authority-client-key", b"private") + with self.assertRaises(provisioner.ProvisionError): + provisioner._validate_secret_inventory(root, expected) + + def test_injected_secret_root_is_exact_and_confined_before_reading(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + parent = Path(temporary) + inputs = parent / "inputs" + inputs.mkdir(mode=0o755) + provisioner._confine_secret_inventory(inputs) + self.assertEqual(stat.S_IMODE(inputs.stat().st_mode), 0o700) + + wrong_mode = parent / "wrong-mode" + wrong_mode.mkdir(mode=0o700) + with self.assertRaises(provisioner.ProvisionError): + provisioner._confine_secret_inventory(wrong_mode) + + symlink = parent / "link" + symlink.symlink_to(inputs, target_is_directory=True) + with self.assertRaises(provisioner.ProvisionError): + provisioner._confine_secret_inventory(symlink) + + def test_injected_secret_inventory_is_consumed_on_success(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + inputs = root / "inputs" + outputs = root / "generated-output-secrets" + outputs.mkdir() + generated = outputs / "audit-hmac-key" + generated.write_bytes(b"generated-canary") + expected = provisioner._provision_secret_inventory("mint") + assert expected is not None + for name in expected: + write_secret(inputs, name, b"input-canary") + arguments = SimpleNamespace(target="mint", secrets=inputs) + + with mock.patch.object(provisioner, "_provision_target") as target: + provisioner.provision(arguments) + + target.assert_called_once_with(arguments) + self.assertFalse(inputs.exists()) + self.assertEqual(generated.read_bytes(), b"generated-canary") + + def test_injected_secret_inventory_is_consumed_on_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + inputs = Path(temporary) / "inputs" + expected = provisioner._provision_secret_inventory("sipf-evidence") + assert expected is not None + for name in expected: + write_secret(inputs, name, b"input-canary") + arguments = SimpleNamespace(target="sipf-evidence", secrets=inputs) + + with ( + mock.patch.object( + provisioner, + "_provision_target", + side_effect=RuntimeError("failure-canary"), + ), + self.assertRaisesRegex(RuntimeError, "failure-canary"), + ): + provisioner.provision(arguments) + + self.assertFalse(inputs.exists()) + + def test_cleanup_never_removes_an_undeclared_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + inputs = Path(temporary) / "inputs" + write_secret(inputs, "declared", b"declared-canary") + write_secret(inputs, "undeclared", b"undeclared-canary") + + with self.assertRaisesRegex( + provisioner.ProvisionError, "secret cleanup failed" + ): + provisioner._consume_secret_inventory(inputs, {"declared"}) + + self.assertFalse((inputs / "declared").exists()) + self.assertEqual((inputs / "undeclared").read_bytes(), b"undeclared-canary") + + def test_ready_command_has_no_inputs_and_succeeds(self) -> None: + stdout = StringIO() + with contextlib.redirect_stdout(stdout): + result = provisioner.main(["ready"]) + self.assertEqual(result, 0) + self.assertEqual(stdout.getvalue(), f"{provisioner.SUCCESS}\n") + + def test_evidence_output_contains_only_its_public_signer_and_own_secrets( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + assets = root / "assets" + cell = assets / "evidence" / "cells" / "sipf" + (cell / "bundle").mkdir(parents=True) + (cell / "bundle" / "evidence.yaml").write_text( + yaml.safe_dump( + { + "authentication": {"issuer": "old", "jwksUri": "old"}, + "signing": {"activePublicJwkFile": "old"}, + "sources": { + "pension": { + "transport": "http-json", + "baseUrl": "old", + "authentication": { + "tokenEndpoint": "old", + "clientAssertionAudience": "old", + }, + } + }, + } + ), + encoding="utf-8", + ) + (cell / "runtime.yaml").write_text( + yaml.safe_dump({"listener": {"bindHost": "old"}}), + encoding="utf-8", + ) + secrets = root / "inputs" + signing_public, signing_private = key_pair() + _, client_private = key_pair() + write_secret(secrets, "signing-public.jwk", signing_public) + write_secret(secrets, "audit-hmac-key", b"a" * 32) + write_secret(secrets, "subject-binding-hmac-key", b"b" * 32) + for client in provisioner.CELL_CLIENTS["sipf"]: + write_secret(secrets, f"{client}-client-key", client_private) + + runtime, output_secrets = root / "runtime", root / "output-secrets" + provisioner._stage_evidence( + assets, + "sipf", + secrets, + runtime, + output_secrets, + None, + provisioner.EXPECTED_BIND_HOST["sipf"], + "2026-08-12T00:00:00Z", + "2026-08-12T00:00:00Z", + provisioner.MINT_ORIGIN, + provisioner.RELAY_ORIGINS["sipf"], + ) + + self.assertEqual( + {path.name for path in output_secrets.iterdir()}, + { + "audit-hmac-key", + "subject-binding-hmac-key", + "sipf-pension-evidence-client-id", + "sipf-pension-evidence-client-key", + "sipf-survivor-evidence-client-id", + "sipf-survivor-evidence-client-key", + }, + ) + self.assertNotIn( + "signing", " ".join(path.name for path in output_secrets.iterdir()) + ) + for secret in output_secrets.iterdir(): + self.assertEqual(stat.S_IMODE(secret.stat().st_mode), 0o600) + public_file = ( + runtime / "bundle" / "public-keys" / f"{signing_public['kid']}.jwk.json" + ) + self.assertEqual(json.loads(public_file.read_text()), signing_public) + self.assertNotIn(signing_private["d"], public_file.read_text()) + authored = yaml.safe_load( + (cell / "bundle" / "evidence.yaml").read_text(encoding="utf-8") + ) + provisioned = yaml.safe_load( + (runtime / "bundle" / "evidence.yaml").read_text(encoding="utf-8") + ) + self.assertEqual(authored["authentication"]["issuer"], "old") + self.assertEqual(authored["sources"]["pension"]["baseUrl"], "old") + self.assertEqual( + provisioned["authentication"]["issuer"], provisioner.MINT_ORIGIN + ) + self.assertEqual( + provisioned["sources"]["pension"]["baseUrl"], + provisioner.RELAY_ORIGINS["sipf"], + ) + + def test_mint_writes_only_audit_secret_and_public_client_registrations( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + assets = root / "assets" + (assets / "mint").mkdir(parents=True) + (assets / "mint" / "mint.yaml").write_text( + yaml.safe_dump( + { + "listener": {"address": "old"}, + "signing": {"activePublicJwkFile": "old"}, + "issuer": "old", + "clientAssertion": {"audience": "old"}, + } + ), + encoding="utf-8", + ) + secrets = root / "inputs" + public, private = key_pair() + write_secret(secrets, "signing-public.jwk", public) + write_secret(secrets, "audit-hmac-key", b"a" * 32) + for client in provisioner.MINT_CLIENTS: + write_secret(secrets, f"{client}-public.jwk", public) + write_secret(secrets, "solmara-demo-client-public.jwk", public) + + runtime, output_secrets = root / "runtime", root / "output-secrets" + provisioner._stage_mint( + assets, + secrets, + runtime, + output_secrets, + provisioner.EXPECTED_BIND_HOST["mint"], + provisioner.MINT_ORIGIN, + ) + self.assertEqual( + {path.name for path in output_secrets.iterdir()}, {"audit-hmac-key"} + ) + self.assertEqual( + stat.S_IMODE((output_secrets / "audit-hmac-key").stat().st_mode), + 0o600, + ) + emitted = "\n".join( + path.read_text() for path in runtime.rglob("*") if path.is_file() + ) + self.assertNotIn(private["d"], emitted) + self.assertEqual(len(list((runtime / "clients").glob("*.yaml"))), 9) + authored = yaml.safe_load( + (assets / "mint" / "mint.yaml").read_text(encoding="utf-8") + ) + provisioned = yaml.safe_load( + (runtime / "mint.yaml").read_text(encoding="utf-8") + ) + self.assertEqual(authored["issuer"], "old") + self.assertEqual(provisioned["issuer"], provisioner.MINT_ORIGIN) + self.assertEqual( + provisioned["clientAssertion"]["audience"], + f"{provisioner.MINT_ORIGIN}/token", + ) + + write_secret(secrets, "signing-public.jwk", private) + with self.assertRaises(provisioner.ProvisionError): + provisioner._stage_mint( + assets, + secrets, + root / "bad-runtime", + root / "bad-secrets", + provisioner.EXPECTED_BIND_HOST["mint"], + provisioner.MINT_ORIGIN, + ) + + def test_relay_provision_preserves_database_and_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + assets = root / "assets" + relay = assets / "relays" / "cra" + (relay / "package").mkdir(parents=True) + (relay / "package" / "sealed.json").write_bytes(b"sealed") + (relay / "source").mkdir() + database = b"SQLite format 3\x00\n\xff\x00" + (relay / "source" / "cra.sqlite").write_bytes(database) + (relay / "runtime.yaml").write_text( + yaml.safe_dump( + { + "version": 1, + "authentication": { + "issuer": {"discoveryUrl": "https://old.invalid"} + }, + } + ), + encoding="utf-8", + ) + write_manifest(assets) + secrets = root / "unused-secrets" + secrets.mkdir() + runtime, source = root / "runtime", root / "source" + arguments = provisioner.parser().parse_args( + [ + "provision", + "--target", + "cra-relay", + "--assets", + str(assets), + "--runtime-output", + str(runtime), + "--source-output", + str(source), + "--mint-origin", + provisioner.MINT_ORIGIN, + ] + ) + + provisioner.provision(arguments) + provisioner.provision(arguments) + self.assertEqual((source / "cra.sqlite").read_bytes(), database) + self.assertEqual( + (runtime / "package" / "sealed.json").read_bytes(), b"sealed" + ) + self.assertEqual( + stat.S_IMODE((source / "cra.sqlite").stat().st_mode), 0o444 + ) + authored = yaml.safe_load( + (relay / "runtime.yaml").read_text(encoding="utf-8") + ) + provisioned = yaml.safe_load( + (runtime / "runtime.yaml").read_text(encoding="utf-8") + ) + self.assertEqual( + authored["authentication"]["issuer"]["discoveryUrl"], + "https://old.invalid", + ) + self.assertEqual( + provisioned["authentication"]["issuer"]["discoveryUrl"], + f"{provisioner.MINT_ORIGIN}/.well-known/openid-configuration", + ) + + def test_all_outputs_are_preflighted_before_any_volume_changes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime_output = root / "runtime-output" + runtime_output.mkdir() + provisioner._write(runtime_output / "runtime.yaml", b"active", 0o444) + secret_output = root / "secret-output" + provisioner._write(secret_output / "audit-hmac-key", b"a" * 32, 0o400) + arguments = provisioner.parser().parse_args( + [ + "provision", + "--target", + "mint", + "--assets", + str(root / "assets"), + "--secrets", + str(root / "inputs"), + "--runtime-output", + str(runtime_output), + "--secret-output", + str(secret_output), + "--bind-host", + provisioner.EXPECTED_BIND_HOST["mint"], + "--mint-origin", + provisioner.MINT_ORIGIN, + ] + ) + + def stage(_assets, _inputs, runtime, secrets, _bind, _mint_origin): + provisioner._write(runtime / "runtime.yaml", b"replacement", 0o444) + provisioner._write(secrets / "audit-hmac-key", b"a" * 32, 0o600) + + with ( + mock.patch.object(provisioner, "verify_assets"), + mock.patch.object(provisioner, "_validate_secret_inventory"), + mock.patch.object(provisioner, "_confine_secret_inventory"), + mock.patch.object(provisioner, "_consume_secret_inventory"), + mock.patch.object(provisioner, "_stage_mint", side_effect=stage), + self.assertRaises(provisioner.ProvisionError), + ): + provisioner.provision(arguments) + self.assertEqual( + stat.S_IMODE((secret_output / "audit-hmac-key").stat().st_mode), + 0o400, + ) + self.assertEqual((runtime_output / "runtime.yaml").read_bytes(), b"active") + + def test_generated_secret_mode_upgrade_is_preflighted_and_exact(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + staged = root / "staged" + destination = root / "destination" + provisioner._write(staged / "audit-hmac-key", b"a" * 32, 0o600) + provisioner._write(destination / "audit-hmac-key", b"a" * 32, 0o400) + + provisioner._check_secret_install_tree(staged, destination) + self.assertEqual( + stat.S_IMODE((destination / "audit-hmac-key").stat().st_mode), + 0o400, + ) + with mock.patch.object(provisioner.os, "chown"): + provisioner._install_secret_tree(staged, destination) + self.assertEqual( + stat.S_IMODE((destination / "audit-hmac-key").stat().st_mode), + 0o600, + ) + + (destination / "audit-hmac-key").write_bytes(b"mismatch") + (destination / "audit-hmac-key").chmod(0o400) + with self.assertRaises(provisioner.ProvisionError): + provisioner._check_secret_install_tree(staged, destination) + + hardlink_destination = root / "hardlink-destination" + provisioner._write( + hardlink_destination / "audit-hmac-key", b"a" * 32, 0o600 + ) + os.link( + hardlink_destination / "audit-hmac-key", root / "external-hard-link" + ) + with self.assertRaises(provisioner.ProvisionError): + provisioner._check_secret_install_tree(staged, hardlink_destination) + + def test_relay_cli_loads_manifest_verifier_directly(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + assets = root / "assets" + relay = assets / "relays" / "cra" + (relay / "package").mkdir(parents=True) + (relay / "package" / "sealed.json").write_bytes(b"sealed") + (relay / "source").mkdir() + (relay / "source" / "cra.sqlite").write_bytes(b"SQLite format 3\x00") + (relay / "runtime.yaml").write_text( + yaml.safe_dump( + { + "version": 1, + "authentication": { + "issuer": {"discoveryUrl": "https://old.invalid"} + }, + } + ), + encoding="utf-8", + ) + write_manifest(assets) + runtime, source = root / "runtime", root / "source" + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "provision", + "--target", + "cra-relay", + "--assets", + str(assets), + "--runtime-output", + str(runtime), + "--source-output", + str(source), + "--mint-origin", + provisioner.MINT_ORIGIN, + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout.strip(), provisioner.SUCCESS) + self.assertEqual(completed.stderr, "") + + def test_manifest_failure_precedes_every_output_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + assets = root / "assets" + assets.mkdir() + (assets / "manifest.json").write_text("{}\n", encoding="utf-8") + runtime, source = root / "runtime", root / "source" + runtime.mkdir() + source.mkdir() + provisioner._write(runtime / "keep", b"runtime", 0o444) + provisioner._write(source / "keep", b"source", 0o444) + arguments = provisioner.parser().parse_args( + [ + "provision", + "--target", + "cra-relay", + "--assets", + str(assets), + "--runtime-output", + str(runtime), + "--source-output", + str(source), + "--mint-origin", + provisioner.MINT_ORIGIN, + ] + ) + with self.assertRaises(provisioner.ProvisionError): + provisioner.provision(arguments) + self.assertEqual((runtime / "keep").read_bytes(), b"runtime") + self.assertEqual((source / "keep").read_bytes(), b"source") + + def test_existing_direct_extract_publication_is_reused(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime, extracts = root / "runtime", root / "extracts" + runtime.mkdir() + extracts.mkdir() + published_at = "2026-08-12T09:00:00Z" + extract_id = "sro-poverty-20260812T090000Z" + extract = extracts / f"{extract_id}.sqlite" + with sqlite3.connect(extract) as connection: + connection.execute( + "CREATE TABLE evidence_extract (published_at TEXT, publisher TEXT, extract_id TEXT)" + ) + connection.execute( + "INSERT INTO evidence_extract VALUES (?, ?, ?)", + (published_at, "did:web:example", extract_id), + ) + (runtime / "runtime.yaml").write_text( + yaml.safe_dump( + { + "sourceExtracts": { + "sro-poverty-extract": { + "path": f"/var/lib/registry-evidence/sro/extracts/{extract.name}" + } + } + } + ), + encoding="utf-8", + ) + publisher = mock.Mock() + with mock.patch.object( + provisioner, "_load_publisher", return_value=publisher + ): + observed = provisioner._publication_time( + root / "assets", + "sro", + runtime, + extracts, + "2026-08-12T09:01:00Z", + ) + self.assertEqual(observed, published_at) + publisher.validate_extract.assert_called_once() + + def test_orphan_direct_extract_recovers_after_pre_runtime_crash(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime, extracts = root / "runtime", root / "extracts" + runtime.mkdir() + extracts.mkdir() + published_at = "2026-08-12T09:00:00Z" + extract_id = "sro-poverty-20260812T090000Z" + extract = extracts / f"{extract_id}.sqlite" + extract.write_bytes(b"immutable") + extract.chmod(0o444) + metadata = SimpleNamespace(published_at=published_at, extract_id=extract_id) + publisher = mock.Mock() + publisher.validate_extract.return_value = metadata + with mock.patch.object( + provisioner, "_load_publisher", return_value=publisher + ): + observed = provisioner._publication_time( + root / "assets", + "sro", + runtime, + extracts, + "2026-08-12T09:01:00Z", + ) + self.assertEqual(observed, published_at) + publisher.validate_extract.assert_called_once_with( + extract, "sro", observed_at="2026-08-12T09:01:00Z" + ) + + def test_stale_or_mismatched_extract_is_a_value_free_refusal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime, extracts = root / "runtime", root / "extracts" + runtime.mkdir() + extracts.mkdir() + extract = extracts / "cra-birth-20260812T090000Z.sqlite" + extract.write_bytes(b"stale-canary") + extract.chmod(0o444) + publisher = mock.Mock() + publisher.validate_extract.side_effect = RuntimeError("stale-canary") + with ( + mock.patch.object( + provisioner, "_load_publisher", return_value=publisher + ), + self.assertRaisesRegex(provisioner.ProvisionError, "invalid existing"), + ): + provisioner._publication_time( + root / "assets", + "cra", + runtime, + extracts, + "2026-08-13T09:01:00Z", + ) + + def test_extract_publication_appends_and_atomically_rebinds(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime, extracts = root / "runtime", root / "extracts" + runtime.mkdir() + extracts.mkdir() + previous_name = "sro-poverty-20260812T090000Z.sqlite" + replacement_name = "sro-poverty-20260812T100000Z.sqlite" + (extracts / previous_name).write_bytes(b"previous") + (extracts / previous_name).chmod(0o444) + extracts.chmod(0o555) + (runtime / "runtime.yaml").write_text( + yaml.safe_dump( + { + "listener": {"bindHost": provisioner.EXPECTED_BIND_HOST["sro"]}, + "sourceExtracts": { + "sro-poverty-extract": { + "path": f"/var/lib/registry-evidence/sro/extracts/{previous_name}" + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (runtime / "runtime.yaml").chmod(0o444) + runtime.chmod(0o555) + publisher = mock.Mock() + publisher.timestamped_extract_id.return_value = previous_name.removesuffix( + ".sqlite" + ) + + def stage(_assets, _cell, destination, _published_at, _observed_at): + (destination / replacement_name).write_bytes(b"replacement") + (destination / replacement_name).chmod(0o444) + return replacement_name + + arguments = SimpleNamespace( + target="sro-evidence", + assets=root / "assets", + runtime_output=runtime, + extract_output=extracts, + ) + with ( + mock.patch.object(provisioner, "verify_assets"), + mock.patch.object( + provisioner, + "_publication_time", + return_value="2026-08-12T09:00:00Z", + ), + mock.patch.object( + provisioner, "_load_publisher", return_value=publisher + ), + mock.patch.object(provisioner, "_stage_extract", side_effect=stage), + ): + provisioner.publish_extract(arguments) + + self.assertEqual((extracts / previous_name).read_bytes(), b"previous") + self.assertEqual((extracts / replacement_name).read_bytes(), b"replacement") + config = yaml.safe_load((runtime / "runtime.yaml").read_text()) + self.assertEqual( + Path(config["sourceExtracts"]["sro-poverty-extract"]["path"]).name, + replacement_name, + ) + self.assertEqual(stat.S_IMODE(runtime.stat().st_mode), 0o555) + self.assertEqual(stat.S_IMODE(extracts.stat().st_mode), 0o555) + rollback = ( + runtime + / f"runtime.rollback-{previous_name.removesuffix('.sqlite')}.yaml" + ) + self.assertTrue(rollback.is_file()) + rollback_config = yaml.safe_load(rollback.read_text()) + self.assertEqual( + Path( + rollback_config["sourceExtracts"]["sro-poverty-extract"]["path"] + ).name, + previous_name, + ) + + def test_extract_append_never_overwrites_a_mismatched_filename(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + staged = root / "staged.sqlite" + destination = root / "extracts" + destination.mkdir() + target = destination / staged.name + staged.write_bytes(b"replacement") + target.write_bytes(b"active") + with self.assertRaises(provisioner.ProvisionError): + provisioner._append_file(staged, destination) + self.assertEqual(target.read_bytes(), b"active") + + def test_evidence_bind_address_is_closed_before_staging(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + arguments = provisioner.parser().parse_args( + [ + "provision", + "--target", + "sipf-evidence", + "--assets", + str(root / "assets"), + "--secrets", + str(root / "secrets"), + "--runtime-output", + str(root / "runtime"), + "--secret-output", + str(root / "output-secrets"), + "--bind-host", + "172.29.1.99", + "--mint-origin", + provisioner.MINT_ORIGIN, + "--relay-origin", + provisioner.RELAY_ORIGINS["sipf"], + ] + ) + with ( + mock.patch.object(provisioner, "verify_assets"), + mock.patch.object(provisioner, "_confine_secret_inventory"), + mock.patch.object(provisioner, "_consume_secret_inventory"), + mock.patch.object(provisioner, "_stage_evidence") as stage, + self.assertRaises(provisioner.ProvisionError), + ): + provisioner.provision(arguments) + stage.assert_not_called() + + def test_init_audit_changes_only_each_root_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "audit" + root.mkdir() + child = root / "existing.jsonl" + child.write_bytes(b"existing-audit-canary") + child.chmod(0o640) + with ( + mock.patch.object(provisioner.os, "chown") as chown, + mock.patch.object(provisioner.os, "chmod") as chmod, + ): + provisioner.init_audit([root], 65532, 65532) + chown.assert_called_once_with(root, 65532, 65532) + chmod.assert_called_once_with(root, 0o700) + self.assertEqual(child.read_bytes(), b"existing-audit-canary") + self.assertEqual(stat.S_IMODE(child.stat().st_mode), 0o640) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_publish_runtime_extracts.py b/scripts/test_publish_runtime_extracts.py new file mode 100644 index 0000000..932eec5 --- /dev/null +++ b/scripts/test_publish_runtime_extracts.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import shutil +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = Path(__file__).with_name("publish-runtime-extracts.py") +SPEC = importlib.util.spec_from_file_location("runtime_extract_publication", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +PUBLISHER = importlib.import_module("solmara_lab.publisher") + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class RuntimeExtractPublicationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + for authority in MODULE.AUTHORITIES: + authored = self.root / "evidence/cells" / authority / "runtime.yaml" + authored.parent.mkdir(parents=True) + shutil.copy2(ROOT / "evidence/cells" / authority / "runtime.yaml", authored) + generated = ( + self.root + / MODULE.RUNTIME_DIRECTORY + / authority + / "runtime.yaml" + ) + generated.parent.mkdir(parents=True) + shutil.copy2(authored, generated) + generated.chmod(0o444) + generated.parent.chmod(0o555) + + def tearDown(self) -> None: + for path in sorted(self.root.rglob("*"), reverse=True): + if path.is_dir(): + path.chmod(0o755) + elif path.exists(): + path.chmod(0o644) + self.temporary.cleanup() + + def _runtime_binding(self, authority: str) -> str: + runtime = yaml.safe_load( + ( + self.root + / MODULE.RUNTIME_DIRECTORY + / authority + / "runtime.yaml" + ).read_text(encoding="utf-8") + ) + profile = MODULE.AUTHORITIES[authority][0] + return runtime["sourceExtracts"][profile]["path"] + + def _extracts(self) -> list[Path]: + return sorted((self.root / PUBLISHER.EVIDENCE_DIRECTORY).glob("*.sqlite")) + + def _authored_digests(self) -> dict[Path, str]: + return { + path: digest(path) + for path in sorted((self.root / "evidence/cells").rglob("*")) + if path.is_file() + } + + def test_fresh_publication_binds_only_generated_runtime_configs(self) -> None: + authored_before = self._authored_digests() + published_at = "2026-08-12T09:30:00.123456Z" + + result = MODULE.prepare_runtime_extracts(self.root, published_at) + + self.assertEqual( + {authority: item["status"] for authority, item in result.items()}, + {authority: "published" for authority in MODULE.AUTHORITIES}, + ) + self.assertEqual(len(self._extracts()), 3) + for authority, item in result.items(): + expected_id = PUBLISHER.timestamped_extract_id(authority, published_at) + self.assertEqual(item["extractId"], expected_id) + self.assertEqual( + self._runtime_binding(authority), + f"/var/lib/registry-evidence/{authority}/extracts/{expected_id}.sqlite", + ) + extract = self.root / item["path"] + self.assertEqual(stat.S_IMODE(extract.stat().st_mode), 0o444) + with sqlite3.connect(extract) as connection: + self.assertEqual( + connection.execute( + "SELECT published_at, publisher, extract_id FROM evidence_extract" + ).fetchall(), + [(published_at, PUBLISHER.PUBLISHERS[authority], expected_id)], + ) + self.assertEqual(self._authored_digests(), authored_before) + + def test_default_publication_uses_one_explicit_current_utc_time(self) -> None: + current = "2026-08-12T09:30:00.654321Z" + with mock.patch.object( + MODULE, "current_publication_time", return_value=current + ) as clock: + result = MODULE.prepare_runtime_extracts(self.root) + + clock.assert_called_once_with() + for authority, item in result.items(): + extract = self.root / item["path"] + with sqlite3.connect(extract) as connection: + self.assertEqual( + connection.execute( + "SELECT published_at FROM evidence_extract" + ).fetchone(), + (current,), + ) + self.assertEqual( + item["extractId"], + PUBLISHER.timestamped_extract_id(authority, current), + ) + + def test_fresh_exact_bound_extracts_are_reused(self) -> None: + first = MODULE.prepare_runtime_extracts( + self.root, "2026-08-12T09:00:00Z" + ) + before = {path: digest(path) for path in self._extracts()} + runtimes_before = { + authority: self._runtime_binding(authority) + for authority in MODULE.AUTHORITIES + } + + second = MODULE.prepare_runtime_extracts( + self.root, "2026-08-12T10:00:00Z" + ) + + self.assertEqual( + {authority: item["status"] for authority, item in second.items()}, + {authority: "reused" for authority in MODULE.AUTHORITIES}, + ) + self.assertEqual( + {authority: item["extractId"] for authority, item in second.items()}, + {authority: item["extractId"] for authority, item in first.items()}, + ) + self.assertEqual({path: digest(path) for path in self._extracts()}, before) + self.assertEqual( + { + authority: self._runtime_binding(authority) + for authority in MODULE.AUTHORITIES + }, + runtimes_before, + ) + + def test_stale_extracts_are_retained_and_replaced_under_new_filenames(self) -> None: + first = MODULE.prepare_runtime_extracts( + self.root, "2026-08-10T09:00:00Z" + ) + old_paths = { + authority: self.root / item["path"] + for authority, item in first.items() + } + old_digests = {authority: digest(path) for authority, path in old_paths.items()} + + second = MODULE.prepare_runtime_extracts( + self.root, "2026-08-12T09:00:00Z" + ) + + self.assertEqual(len(self._extracts()), 6) + for authority, item in second.items(): + self.assertEqual(item["status"], "published") + self.assertNotEqual(item["extractId"], first[authority]["extractId"]) + self.assertTrue(old_paths[authority].exists()) + self.assertEqual(digest(old_paths[authority]), old_digests[authority]) + self.assertTrue(self._runtime_binding(authority).endswith( + f"/{item['extractId']}.sqlite" + )) + + def test_metadata_mismatch_fails_before_any_new_publication_or_binding(self) -> None: + MODULE.prepare_runtime_extracts(self.root, "2026-08-12T09:00:00Z") + cra = Path(self._runtime_binding("cra")).name + cra_extract = self.root / PUBLISHER.EVIDENCE_DIRECTORY / cra + cra_extract.chmod(0o644) + with sqlite3.connect(cra_extract) as connection: + connection.execute( + "UPDATE evidence_extract SET publisher = ?", + (PUBLISHER.PUBLISHERS["nia"],), + ) + connection.commit() + cra_extract.chmod(0o444) + runtime_before = { + authority: self._runtime_binding(authority) + for authority in MODULE.AUTHORITIES + } + extracts_before = list(self._extracts()) + + with self.assertRaisesRegex(MODULE.RuntimeExtractError, "failed validation"): + MODULE.prepare_runtime_extracts(self.root, "2026-08-12T10:00:00Z") + + self.assertEqual(self._extracts(), extracts_before) + self.assertEqual( + { + authority: self._runtime_binding(authority) + for authority in MODULE.AUTHORITIES + }, + runtime_before, + ) + + def test_writable_bound_extract_fails_closed(self) -> None: + result = MODULE.prepare_runtime_extracts( + self.root, "2026-08-12T09:00:00Z" + ) + nia_extract = self.root / result["nia"]["path"] + nia_extract.chmod(0o644) + runtimes_before = { + authority: self._runtime_binding(authority) + for authority in MODULE.AUTHORITIES + } + + with self.assertRaisesRegex(MODULE.RuntimeExtractError, "writable mode"): + MODULE.prepare_runtime_extracts(self.root, "2026-08-12T10:00:00Z") + + self.assertEqual( + { + authority: self._runtime_binding(authority) + for authority in MODULE.AUTHORITIES + }, + runtimes_before, + ) + + def test_unpatchable_generated_binding_fails_before_publication(self) -> None: + runtime = ( + self.root / MODULE.RUNTIME_DIRECTORY / "sro" / "runtime.yaml" + ) + runtime.parent.chmod(0o755) + runtime.chmod(0o644) + original = runtime.read_text(encoding="utf-8") + bound_path = self._runtime_binding("sro") + runtime.write_text( + original.replace(bound_path, f'"{bound_path}"'), encoding="utf-8" + ) + runtime.chmod(0o444) + runtime.parent.chmod(0o555) + + with self.assertRaisesRegex(MODULE.RuntimeExtractError, "uniquely patchable"): + MODULE.prepare_runtime_extracts(self.root, "2026-08-12T09:00:00Z") + + self.assertEqual(self._extracts(), []) + + def test_cli_accepts_an_explicit_deterministic_publication_time(self) -> None: + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--root", + str(self.root), + "--published-at", + "2026-08-12T09:00:00Z", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + result = json.loads(completed.stdout) + self.assertEqual(set(result), set(MODULE.AUTHORITIES)) + self.assertEqual( + result["sro"]["extractId"], "sro-poverty-20260812T090000Z" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_quality_scripts.py b/scripts/test_quality_scripts.py deleted file mode 100644 index b252c0b..0000000 --- a/scripts/test_quality_scripts.py +++ /dev/null @@ -1,2039 +0,0 @@ -from __future__ import annotations - -import contextlib -import csv -import importlib.util -import io -import json -import os -import re -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from types import SimpleNamespace - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] - - -class ComposeLoader(yaml.SafeLoader): - """Safe loader that treats Compose merge tags as their underlying value.""" - - -def _construct_compose_tag( - loader: ComposeLoader, node: yaml.Node -) -> object: - if isinstance(node, yaml.SequenceNode): - return loader.construct_sequence(node) - if isinstance(node, yaml.MappingNode): - return loader.construct_mapping(node) - return loader.construct_scalar(node) - - -for compose_tag in ("!override", "!reset"): - ComposeLoader.add_constructor(compose_tag, _construct_compose_tag) - - -def load_smoke_live(): - spec = importlib.util.spec_from_file_location( - "smoke_live", ROOT / "scripts" / "smoke-live.py" - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load smoke-live.py") - module = importlib.util.module_from_spec(spec) - sys.modules["smoke_live"] = module - spec.loader.exec_module(module) - return module - - -def load_compose_project_name(): - spec = importlib.util.spec_from_file_location( - "compose_project_name", ROOT / "scripts" / "compose_project_name.py" - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load compose_project_name.py") - module = importlib.util.module_from_spec(spec) - sys.modules["compose_project_name"] = module - spec.loader.exec_module(module) - return module - - -def load_config_secret_check(): - spec = importlib.util.spec_from_file_location( - "check_config_secrets", ROOT / "scripts" / "check-config-secrets.py" - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load check-config-secrets.py") - module = importlib.util.module_from_spec(spec) - sys.modules["check_config_secrets"] = module - spec.loader.exec_module(module) - return module - - -def load_secret_generator(): - load_compose_project_name() - spec = importlib.util.spec_from_file_location( - "solmara_gen_secrets", ROOT / "scripts" / "gen-secrets.py" - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load gen-secrets.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class QualityScriptTests(unittest.TestCase): - def test_performance_guide_references_every_k6_entrypoint(self) -> None: - guide = (ROOT / "perf" / "README.md").read_text(encoding="utf-8") - referenced = set(re.findall(r"perf/k6/[A-Za-z0-9_.-]+\.js", guide)) - entrypoints = { - path.relative_to(ROOT).as_posix() - for path in (ROOT / "perf" / "k6").glob("*.js") - } - self.assertEqual(referenced, entrypoints) - - def test_authority_notary_cel_ceiling_is_local_only_and_generated(self) -> None: - projects = ( - "cra-civil", - "nia-population", - "sro-social", - "mosd-programme", - "sipf-pensions", - "nagdi-agriculture", - ) - for project in projects: - with self.subTest(environment="local", project=project): - local_authored = yaml.safe_load( - ( - ROOT - / "projects" - / project - / "environments" - / "local.yaml" - ).read_text(encoding="utf-8") - ) - local_generated = yaml.safe_load( - ( - ROOT - / "runtime" - / "registry-projects" - / "local" - / project - / "notary" - / "notary.yaml" - ).read_text(encoding="utf-8") - ) - self.assertEqual( - local_authored["notary_cel"], - {"worker_memory_bytes": 1_073_741_824}, - ) - self.assertEqual( - local_generated["cel"], - local_authored["notary_cel"], - ) - - with self.subTest(environment="hosted", project=project): - hosted_authored = yaml.safe_load( - ( - ROOT - / "projects" - / project - / "environments" - / "hosted.yaml" - ).read_text(encoding="utf-8") - ) - hosted_generated = yaml.safe_load( - ( - ROOT - / "runtime" - / "registry-projects" - / "hosted" - / project - / "notary" - / "notary.yaml" - ).read_text(encoding="utf-8") - ) - self.assertNotIn("notary_cel", hosted_authored) - self.assertNotIn("cel", hosted_generated) - - def test_registry_project_reference_schemas_use_exact_uin_bounds(self) -> None: - schema_paths = sorted((ROOT / "projects").glob("*/entities/*.yaml")) - schema_paths.extend( - sorted( - (ROOT / "projects").glob( - "*/integrations/*/integration.yaml" - ) - ) - ) - - def assert_exact_uin_bounds(value: object, path: Path) -> None: - if isinstance(value, dict): - if value.get("pattern") == "^[0-9]{10}$": - self.assertEqual( - value.get("minLength"), - 10, - f"{path} must state the exact UIN lower bound", - ) - self.assertEqual( - value.get("maxLength"), - 10, - f"{path} must state the exact UIN upper bound", - ) - for nested in value.values(): - assert_exact_uin_bounds(nested, path) - elif isinstance(value, list): - for nested in value: - assert_exact_uin_bounds(nested, path) - - for path in schema_paths: - with self.subTest(schema=path.relative_to(ROOT)): - assert_exact_uin_bounds( - yaml.safe_load(path.read_text(encoding="utf-8")), - path, - ) - - def test_generated_secret_contract_uses_authority_owners(self) -> None: - module = load_secret_generator() - self.assertEqual( - set(module.RAW_HASH_PAIRS), - { - ( - "CRA_CHILD_BENEFIT_CLIENT_TOKEN", - "CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - ), - ("CRA_PENSION_CLIENT_TOKEN", "CRA_PENSION_CLIENT_TOKEN_HASH"), - ("CRA_CITIZEN_CLIENT_TOKEN", "CRA_CITIZEN_CLIENT_TOKEN_HASH"), - ( - "NIA_CHILD_BENEFIT_CLIENT_TOKEN", - "NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - ), - ("NIA_CITIZEN_CLIENT_TOKEN", "NIA_CITIZEN_CLIENT_TOKEN_HASH"), - ( - "SRO_CHILD_BENEFIT_CLIENT_TOKEN", - "SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - ), - ( - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - ), - ("SIPF_PENSION_CLIENT_TOKEN", "SIPF_PENSION_CLIENT_TOKEN_HASH"), - ("NAGDI_NOTARY_TOKEN", "NAGDI_CLIENT_TOKEN_HASH"), - }, - ) - self.assertEqual( - set(module.JWK_KIDS), - { - "CRA_RELAY_WORKLOAD_JWK", - "NIA_RELAY_WORKLOAD_JWK", - "NIA_ESIGNET_RELAY_WORKLOAD_JWK", - "SRO_RELAY_WORKLOAD_JWK", - "PROGRAMME_RELAY_WORKLOAD_JWK", - "SIPF_RELAY_WORKLOAD_JWK", - "NAGDI_RELAY_WORKLOAD_JWK", - "NIA_NOTARY_ISSUER_JWK", - "SIPF_NOTARY_ISSUER_JWK", - "NAGDI_NOTARY_ISSUER_JWK", - }, - ) - - with tempfile.TemporaryDirectory() as directory: - temporary_root = Path(directory) - module.ROOT = temporary_root - module.POSTGRES_SSL_DIR = temporary_root / "config" / "postgres" / "ssl" - module.compose_project_name = lambda _root: "solmara-lab-test" - federation_output = temporary_root / "federation.env" - with contextlib.redirect_stderr(io.StringIO()): - with self.assertRaises(SystemExit) as rejected: - module.main(["--federation-output", str(federation_output)]) - self.assertEqual(rejected.exception.code, 2) - self.assertFalse(federation_output.exists()) - - self.assertEqual(module.main([]), 0) - output = temporary_root / ".env" - self.assertEqual(output.stat().st_mode & 0o777, 0o600) - - generated_keys = { - line.split("=", 1)[0] - for line in output.read_text(encoding="utf-8").splitlines() - if line and not line.startswith("#") - } - declared_keys = { - line.split("=", 1)[0] - for line in (ROOT / ".env.example") - .read_text(encoding="utf-8") - .splitlines() - if line and not line.startswith("#") and "=" in line - } - self.assertEqual(generated_keys - declared_keys, set()) - - retired_names = { - "CIVIL_CHILD_BENEFIT_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "NIA_CHILD_BENEFIT_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "SRO_CHILD_BENEFIT_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "PROGRAMME_CHILD_BENEFIT_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "PENSION_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "CITIZEN_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "CITIZEN_ISSUER_NOTARY_POSTGRES_RUNTIME_PASSWORD", - "PENSION_NOTARY_TOKEN", - "PORTAL_CITIZEN_NOTARY_TOKEN", - "PORTAL_RELAY_TOKEN", - "SOLMARA_ESIGNET_IDENTITY_RELEASE_RAW", - "SOLMARA_ESIGNET_IDENTITY_RELEASE_HASH", - "CHILD_BENEFIT_PUBLIC_DOMAIN", - "CHILD_BENEFIT_FEDERATOR_REQUEST_JWK", - "CIVIL_CHILD_BENEFIT_PAIRWISE_SECRET", - "PENSION_NOTARY_ISSUER_JWK", - "CITIZEN_NOTARY_ISSUER_JWK", - "CITIZEN_ISSUER_ESIGNET_RP_JWK", - "CIVIL_CHILD_BENEFIT_NOTARY_URL", - "PENSION_NOTARY_URL", - "PORTAL_CIVIL_RELAY_URL", - } - self.assertEqual(retired_names & generated_keys, set()) - self.assertEqual(retired_names & declared_keys, set()) - self.assertEqual( - { - name - for name in generated_keys | declared_keys - if name.endswith(("_SOURCE_RAW", "_SOURCE_HASH")) - }, - set(), - ) - - def test_workload_issuer_contract_is_bounded_and_esignet_isolated(self) -> None: - compose = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) - services = compose["services"] - expected = { - "cra-workload-agent": ( - "cra-notary", - { - "registry:consult:cra-child-benefit", - "registry:consult:cra-citizen-record", - "registry:consult:cra-pension-death", - }, - ), - "nia-workload-agent": ( - "nia-notary", - { - "registry:consult:nia-child-benefit", - "registry:consult:nia-citizen-status", - }, - ), - "sro-workload-agent": ( - "sro-notary", - {"registry:consult:child-benefit"}, - ), - "programme-workload-agent": ( - "programme-notary", - {"registry:consult:child-benefit"}, - ), - "sipf-workload-agent": ( - "sipf-notary", - { - "registry:consult:sipf-pension-payment-review", - "registry:consult:sipf-survivor-benefit", - }, - ), - "nagdi-workload-agent": ( - "nagdi-notary", - {"registry:consult:livestock", "registry:consult:voucher"}, - ), - } - relay_services = { - "cra-workload-agent": "cra-civil-relay", - "nia-workload-agent": "nia-population-relay", - "sro-workload-agent": "sro-social-relay", - "programme-workload-agent": "programme-mis-relay", - "sipf-workload-agent": "sipf-pensions-relay", - "nagdi-workload-agent": "nagdi-agriculture-relay", - } - for service_name, (client_id, scopes) in expected.items(): - with self.subTest(service=service_name): - environment = services[service_name]["environment"] - identities = json.loads(environment["WORKLOAD_IDENTITIES_JSON"]) - notary = next( - identity for identity in identities if identity["azp"] == client_id - ) - self.assertEqual(notary["subject"], client_id) - self.assertEqual(notary["audience"], "registry-relay") - self.assertEqual(set(notary["scopes"]), scopes) - self.assertEqual( - notary["token_file"], f"/run/secrets/{client_id}-relay-token" - ) - self.assertEqual( - len({identity["azp"] for identity in identities}), len(identities) - ) - self.assertEqual( - len({identity["subject"] for identity in identities}), - len(identities), - ) - self.assertEqual( - len({identity["token_file"] for identity in identities}), - len(identities), - ) - for retired in ( - "WORKLOAD_AUDIENCE", - "WORKLOAD_AZP", - "WORKLOAD_SUB", - "WORKLOAD_SCOPE", - "WORKLOAD_TOKEN_FILE", - "WORKLOAD_PRIVATE_JWK_ENV", - ): - self.assertNotIn(retired, environment) - relay_service = relay_services[service_name] - self.assertEqual( - services[service_name]["network_mode"], - f"service:{relay_service}", - ) - self.assertNotIn("WORKLOAD_BIND_HOST", environment) - self.assertEqual( - services[service_name]["depends_on"][relay_service]["condition"], - "service_started", - ) - - consultation_service = service_name.replace( - "-workload-agent", "-consultation-workload-agent" - ) - consultation_relay = f"{relay_service}-consultation" - consultation_agent = services[consultation_service] - consultation_environment = consultation_agent["environment"] - consultation_identities = json.loads( - consultation_environment["WORKLOAD_IDENTITIES_JSON"] - ) - self.assertEqual(consultation_identities, [notary]) - self.assertEqual( - consultation_agent["network_mode"], - f"service:{consultation_relay}", - ) - self.assertNotIn("WORKLOAD_BIND_HOST", consultation_environment) - self.assertEqual( - consultation_agent["depends_on"][consultation_relay]["condition"], - "service_started", - ) - - nia_identities = json.loads( - services["nia-workload-agent"]["environment"]["WORKLOAD_IDENTITIES_JSON"] - ) - self.assertEqual(len(nia_identities), 2) - esignet_identity = next( - identity - for identity in nia_identities - if identity["azp"] == "solmara-esignet" - ) - self.assertEqual(esignet_identity["subject"], "solmara-esignet") - self.assertEqual(esignet_identity["scopes"], ["population:identity_release"]) - self.assertEqual(esignet_identity["token_uid"], 1001) - self.assertEqual(esignet_identity["token_gid"], 1001) - self.assertEqual( - esignet_identity["token_file"], - "/run/esignet-secrets/solmara-esignet-relay-token", - ) - self.assertEqual( - esignet_identity["private_jwk_env"], - "NIA_ESIGNET_RELAY_WORKLOAD_JWK", - ) - self.assertIn( - "nia-esignet-workload-token:/run/esignet-secrets", - services["nia-workload-agent"]["volumes"], - ) - self.assertNotIn( - "nia-esignet-workload-token:/run/esignet-secrets", - services["nia-notary"]["volumes"], - ) - - hosted_overlay = yaml.load( - (ROOT / "compose.hosted.yaml").read_text(encoding="utf-8"), - Loader=ComposeLoader, - ) - hosted_services = hosted_overlay["services"] - hosted_volumes = hosted_overlay["volumes"] - external_volume_names = { - "cra": "${CRA_WORKLOAD_TOKEN_VOLUME:-solmara-cra-workload-token}", - "nia": "${NIA_WORKLOAD_TOKEN_VOLUME:-solmara-nia-workload-token}", - "sro": "${SRO_WORKLOAD_TOKEN_VOLUME:-solmara-sro-workload-token}", - "programme": ( - "${PROGRAMME_WORKLOAD_TOKEN_VOLUME:" - "-solmara-programme-workload-token}" - ), - "sipf": "${SIPF_WORKLOAD_TOKEN_VOLUME:-solmara-sipf-workload-token}", - "nagdi": "${NAGDI_WORKLOAD_TOKEN_VOLUME:-solmara-nagdi-workload-token}", - } - for service_name in expected: - authority = service_name.removesuffix("-workload-agent") - consultation_service = ( - f"{authority}-consultation-workload-agent" - ) - with self.subTest(hosted_authority=authority): - self.assertEqual( - hosted_services[service_name]["profiles"], - ["local-workload-issuer"], - ) - self.assertEqual( - hosted_services[consultation_service]["profiles"], - ["local-workload-issuer"], - ) - volume = hosted_volumes[ - f"{authority}-consultation-workload-token" - ] - self.assertTrue(volume["external"]) - self.assertEqual( - volume["name"], external_volume_names[authority] - ) - - local_esignet = yaml.safe_load( - (ROOT / "compose.esignet.yaml").read_text(encoding="utf-8") - )["services"]["esignet"] - hosted_compose = yaml.safe_load( - (ROOT / "compose.coolify.esignet.yaml").read_text(encoding="utf-8") - ) - hosted_esignet = hosted_compose["services"]["esignet"] - hosted_agent = hosted_compose["services"]["nia-esignet-workload-agent"] - for esignet in (local_esignet, hosted_esignet): - self.assertEqual( - esignet["environment"]["REGISTRY_RELAY_AUTH_BEARER_TOKEN_FILE"], - "/run/secrets/solmara-esignet-relay-token", - ) - self.assertNotIn("REGISTRY_RELAY_AUTH_BEARER_TOKEN", esignet["environment"]) - self.assertNotIn( - "REGISTRY_RELAY_AUTH_CREDENTIAL_KIND", esignet["environment"] - ) - self.assertIn( - "nia-esignet-workload-token:/run/secrets:ro", esignet["volumes"] - ) - self.assertEqual( - local_esignet["depends_on"]["nia-workload-agent"]["condition"], - "service_healthy", - ) - self.assertEqual( - hosted_esignet["depends_on"]["nia-esignet-workload-agent"]["condition"], - "service_healthy", - ) - hosted_identity = json.loads( - hosted_agent["environment"]["WORKLOAD_IDENTITIES_JSON"] - ) - self.assertEqual( - hosted_agent["environment"]["WORKLOAD_ISSUER"], - "https://workload-issuer.solmara.registrystack.org", - ) - self.assertEqual( - hosted_agent["environment"]["NIA_ESIGNET_RELAY_WORKLOAD_JWK"], - "${NIA_ESIGNET_RELAY_WORKLOAD_JWK}", - ) - self.assertEqual( - hosted_identity, - [ - { - "audience": "registry-relay", - "azp": "solmara-esignet", - "subject": "solmara-esignet", - "scopes": ["population:identity_release"], - "token_file": "/run/secrets/solmara-esignet-relay-token", - "private_jwk_env": "NIA_ESIGNET_RELAY_WORKLOAD_JWK", - "token_uid": 1001, - "token_gid": 1001, - } - ], - ) - self.assertNotIn("ports", local_esignet) - local_esignet_edge = yaml.safe_load( - (ROOT / "compose.esignet.yaml").read_text(encoding="utf-8") - )["services"]["esignet-edge"] - self.assertEqual( - local_esignet_edge["ports"], ["${SOLMARA_ESIGNET_PORT:-4308}:3000"] - ) - self.assertEqual( - local_esignet_edge["depends_on"]["esignet"]["condition"], - "service_healthy", - ) - self.assertIn( - "config/esignet/nginx.conf", - local_esignet_edge["build"]["args"]["ESIGNET_NGINX_CONF"], - ) - self.assertIsNone(hosted_compose["volumes"]["nia-esignet-workload-token"]) - - hosted_interior = yaml.safe_load( - (ROOT / "compose.coolify.interior.yaml").read_text(encoding="utf-8") - ) - for service_name in ("nia-notary", "nia-notary-state-install"): - self.assertTrue( - all( - "nia-esignet-workload-token" not in volume - for volume in hosted_interior["services"][service_name]["volumes"] - ) - ) - - jwks = json.loads( - ( - ROOT / "metadata" / "public" / ".well-known" / "jwks.json" - ).read_text(encoding="utf-8") - ) - expected_kids = { - value - for value in load_secret_generator().JWK_KIDS.values() - if "relay-workload-key" in value - } - self.assertEqual({key["kid"] for key in jwks["keys"]}, expected_kids) - self.assertEqual(len(jwks["keys"]), len(expected_kids)) - for key in jwks["keys"]: - self.assertEqual(set(key), {"alg", "crv", "kid", "kty", "x"}) - self.assertEqual(key["alg"], "EdDSA") - self.assertEqual(key["crv"], "Ed25519") - self.assertEqual(key["kty"], "OKP") - - hosted_core = yaml.safe_load( - (ROOT / "compose.coolify.yaml").read_text(encoding="utf-8") - ) - issuer = hosted_core["services"]["workload-issuer"] - self.assertEqual( - issuer["labels"]["solmara.lab.host"], - "workload-issuer.solmara.registrystack.org", - ) - self.assertTrue( - any( - "/.well-known/jwks.json" in part - for part in issuer["healthcheck"]["test"] - ) - ) - - retired_static_names = { - "SOLMARA_ESIGNET_IDENTITY_RELEASE_RAW", - "SOLMARA_ESIGNET_IDENTITY_RELEASE_HASH", - } - self.assertTrue( - retired_static_names.isdisjoint( - name for pair in load_secret_generator().RAW_HASH_PAIRS for name in pair - ) - ) - - def test_esignet_identity_input_resolves_the_nia_uin(self) -> None: - project = yaml.safe_load( - (ROOT / "projects" / "nia-population" / "registry-stack.yaml").read_text( - encoding="utf-8" - ) - ) - authored_profile = project["services"]["nia-population-records"]["api"][ - "attribute_release_profiles" - ]["solmara-nia-userinfo"] - self.assertNotIn("input", authored_profile["subject"]) - self.assertEqual(authored_profile["subject"]["source_field"], "uin") - self.assertEqual(authored_profile["subject"]["id_type"], "national_id") - self.assertNotIn("response", authored_profile) - self.assertEqual( - authored_profile["claims"]["individual_id"]["source_field"], "uin" - ) - - for environment in ("local", "hosted"): - relay = yaml.safe_load( - ( - ROOT - / "runtime" - / "registry-projects" - / environment - / "nia-population" - / "relay" - / "relay.yaml" - ).read_text(encoding="utf-8") - ) - population = next( - entity - for dataset in relay["datasets"] - for entity in dataset["entities"] - if entity["name"] == "population" - ) - generated_profile = next( - profile - for profile in population["attribute_release_profiles"] - if profile["id"] == "solmara-nia-userinfo" - ) - self.assertEqual(generated_profile["subject"]["source_field"], "uin") - - population_fixture = ( - ROOT - / "ministries" - / "interior-population" - / "fixtures" - / "population_person.csv" - ) - with population_fixture.open(encoding="utf-8", newline="") as fixture: - elena = next( - row for row in csv.DictReader(fixture) if row["uin"] == "2300018263" - ) - self.assertEqual( - (elena["given_name"], elena["family_name"]), ("Elena", "Dela Cruz") - ) - - def test_fiction_lint_passes_current_tree(self) -> None: - result = subprocess.run( - [str(ROOT / "scripts" / "check-fiction.sh")], - cwd=ROOT, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_secret_lint_passes_current_tree(self) -> None: - result = subprocess.run( - [str(ROOT / "scripts" / "check-config-secrets.py")], - cwd=ROOT, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_secret_lint_scans_runtime_and_limits_workload_volume_exemption( - self, - ) -> None: - module = load_config_secret_check() - scanned = {path.relative_to(ROOT).as_posix() for path in module.iter_files()} - self.assertIn( - "runtime/registry-projects/local/cra-civil/notary/notary.yaml", - scanned, - ) - self.assertIn("projects/cra-civil/environments/local.yaml", scanned) - self.assertIn("compose.hosted.yaml", scanned) - self.assertFalse(any(path.startswith("notaries/") for path in scanned)) - self.assertFalse(any(path.startswith("hosted/notaries/") for path in scanned)) - self.assertTrue( - module.line_is_allowed(" - cra-workload-token:/run/secrets:ro") - ) - self.assertTrue( - module.line_is_allowed( - " - nia-esignet-workload-token:/run/esignet-secrets" - ) - ) - self.assertTrue( - module.line_is_allowed( - " api_key_fingerprint: { secret: CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH }" - ) - ) - self.assertFalse( - module.line_is_allowed("token: leaked-cra-notary-workload-token:value") - ) - self.assertFalse(module.line_is_allowed("signing_key: a-raw-private-key")) - - def test_registry_projects_are_explicit_and_use_the_pinned_registryctl( - self, - ) -> None: - required_version = next( - line.split("=", 1)[1] - for line in (ROOT / "versions.env").read_text(encoding="utf-8").splitlines() - if line.startswith("REGISTRYCTL_VERSION=") - ) - projects = [ - "cra-civil", - "nia-population", - "sro-social", - "mosd-programme", - "sipf-pensions", - "nagdi-agriculture", - ] - with tempfile.TemporaryDirectory() as directory: - temporary = Path(directory) - registryctl = temporary / "registryctl" - log = temporary / "commands.log" - registryctl.write_text( - f"""#!/bin/sh -if [ "${1:-}" = "--version" ]; then - echo "registryctl {required_version}" - exit 0 -fi -if [ "${2:-}" = "--help" ]; then - case "${1:-}" in - check | test | build | capabilities) exit 0 ;; - esac -fi -if [ "${1:-}" = "authoring" ] && [ "${2:-}" = "editor" ] && [ "${3:-}" = "--help" ]; then - exit 0 -fi -printf '%s\\n' "$*" >> "$REGISTRYCTL_LOG" -""", - encoding="utf-8", - ) - registryctl.chmod(0o755) - result = subprocess.run( - [str(ROOT / "scripts" / "registry-projects.sh"), "check"], - cwd=ROOT, - env={ - **os.environ, - "REGISTRYCTL_BIN": str(registryctl), - "REGISTRYCTL_LOG": str(log), - }, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - commands = log.read_text(encoding="utf-8").splitlines() - expected = [ - f"check --project-dir {ROOT / 'projects' / project} --environment {environment}" - for project in projects - for environment in ("local", "hosted") - ] - self.assertEqual(commands, expected) - - log.write_text("", encoding="utf-8") - review = subprocess.run( - [str(ROOT / "scripts" / "registry-projects.sh"), "review"], - cwd=ROOT, - env={ - **os.environ, - "REGISTRYCTL_BIN": str(registryctl), - "REGISTRYCTL_LOG": str(log), - }, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(review.returncode, 0, review.stderr) - review_commands = log.read_text(encoding="utf-8").splitlines() - self.assertEqual( - review_commands, - [f"{command} --explain" for command in expected], - ) - - log.write_text("", encoding="utf-8") - capabilities = subprocess.run( - [str(ROOT / "scripts" / "registry-projects.sh"), "capabilities"], - cwd=ROOT, - env={ - **os.environ, - "REGISTRYCTL_BIN": str(registryctl), - "REGISTRYCTL_LOG": str(log), - }, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(capabilities.returncode, 0, capabilities.stderr) - capability_commands = log.read_text(encoding="utf-8").splitlines() - self.assertEqual( - capability_commands, - [ - f"capabilities --project-dir {ROOT / 'projects' / project} " - f"--environment {environment}" - for project in projects - for environment in ("local", "hosted") - ], - ) - - log.write_text("", encoding="utf-8") - editor = subprocess.run( - [str(ROOT / "scripts" / "registry-projects.sh"), "editor"], - cwd=ROOT, - env={ - **os.environ, - "REGISTRYCTL_BIN": str(registryctl), - "REGISTRYCTL_LOG": str(log), - }, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(editor.returncode, 0, editor.stderr) - editor_commands = log.read_text(encoding="utf-8").splitlines() - self.assertEqual( - editor_commands, - [ - f"authoring editor --project-dir {ROOT / 'projects' / project}" - for project in projects - ], - ) - - registryctl.write_text( - "#!/bin/sh\necho 'registryctl 0.8.3'\n", - encoding="utf-8", - ) - rejected = subprocess.run( - [str(ROOT / "scripts" / "registry-projects.sh"), "check"], - cwd=ROOT, - env={**os.environ, "REGISTRYCTL_BIN": str(registryctl)}, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(rejected.returncode, 1) - self.assertIn( - f"registryctl {required_version} is required", rejected.stderr - ) - - registryctl.write_text( - f'#!/bin/sh\nif [ "${{1:-}}" = "--version" ]; then echo \'registryctl {required_version}\'; exit 0; fi\nexit 1\n', - encoding="utf-8", - ) - incompatible = subprocess.run( - [str(ROOT / "scripts" / "registry-projects.sh"), "check"], - cwd=ROOT, - env={**os.environ, "REGISTRYCTL_BIN": str(registryctl)}, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(incompatible.returncode, 1) - self.assertIn( - "with project-authoring check/test/build/capabilities is required", - incompatible.stderr, - ) - - registry_projects = ( - ROOT / "scripts" / "registry-projects.sh" - ).read_text(encoding="utf-8") - self.assertIn("--format json", registry_projects) - self.assertIn("registryctl-build-output.py", registry_projects) - self.assertIn("registryctl-test-output.py", registry_projects) - self.assertNotIn( - ".registry-stack/build/$environment/private", - registry_projects, - ) - - def test_registry_project_secret_references_have_local_producers(self) -> None: - module = load_secret_generator() - produced = ( - {hashed for _, hashed in module.RAW_HASH_PAIRS} - | set(module.JWK_KIDS) - | module.DIRECT_PROJECT_SECRET_NAMES - ) - declared = { - line.split("=", 1)[0] - for line in (ROOT / ".env.example").read_text(encoding="utf-8").splitlines() - if line and not line.startswith("#") and "=" in line - } - consumed = set() - - def collect(value) -> None: - if isinstance(value, dict): - if set(value) == {"secret"} and isinstance(value["secret"], str): - consumed.add(value["secret"]) - for child in value.values(): - collect(child) - elif isinstance(value, list): - for child in value: - collect(child) - - for environment in sorted((ROOT / "projects").glob("*/environments/*.yaml")): - collect(yaml.safe_load(environment.read_text(encoding="utf-8"))) - - self.assertEqual(consumed - produced, set()) - self.assertEqual(consumed - declared, set()) - - def test_relay_consultation_state_uses_the_v015_epoch(self) -> None: - versions = dict( - line.split("=", 1) - for line in (ROOT / "versions.env").read_text(encoding="utf-8").splitlines() - if line and not line.startswith("#") and "=" in line - ) - self.assertEqual(versions["REGISTRY_RELAY_STATE_EPOCH"], "v015") - - compose_names = ( - "compose.yaml", - "compose.coolify.interior.yaml", - "compose.coolify.social-development.yaml", - "compose.coolify.labour-pensions.yaml", - "compose.coolify.agriculture.yaml", - ) - epoch_reference = "${REGISTRY_RELAY_STATE_EPOCH:-v015}" - for compose_name in compose_names: - with self.subTest(compose=compose_name): - compose_path = ROOT / compose_name - raw = compose_path.read_text(encoding="utf-8") - compose = yaml.safe_load(raw) - services = compose["services"] - self.assertEqual( - services["postgres"]["environment"][ - "REGISTRY_RELAY_STATE_EPOCH" - ], - epoch_reference, - ) - self.assertEqual( - services["postgres"]["healthcheck"]["test"], - [ - "CMD-SHELL", - "pg_isready -h 127.0.0.1 -U " - "$${POSTGRES_USER} -d $${POSTGRES_DB}", - ], - ) - for authority in ("cra", "nia", "sro", "programme", "sipf", "nagdi"): - legacy = f"solmara_relay_{authority}_consultation" - self.assertNotIn(f"{legacy}_runtime", raw) - self.assertNotIn(f"{legacy}_owner", raw) - self.assertNotIn(f"/{legacy}?sslmode=require", raw) - - state_urls = [ - environment["REGISTRY_RELAY_CONSULTATION_DATABASE_URL"] - for service in services.values() - if ( - environment := service.get("environment") - ) - and "REGISTRY_RELAY_CONSULTATION_DATABASE_URL" in environment - ] - self.assertTrue(state_urls) - self.assertTrue( - all(f"_consultation_{epoch_reference}" in url for url in state_urls) - ) - - bootstrap_commands = [ - service["command"] - for name, service in services.items() - if name.endswith("-relay-state-bootstrap") - ] - self.assertTrue(bootstrap_commands) - for command in bootstrap_commands: - owner = command[command.index("--owner-role") + 1] - self.assertIn(f"_consultation_{epoch_reference}_owner", owner) - - provisioner = (ROOT / "scripts" / "init-notary-postgresql.sh").read_text( - encoding="utf-8" - ) - self.assertIn( - 'database="solmara_relay_${key}_consultation_${relay_state_epoch}"', - provisioner, - ) - self.assertIn( - "REGISTRY_RELAY_STATE_EPOCH must contain only lowercase letters", - provisioner, - ) - - def test_generated_public_and_consultation_relays_are_separate(self) -> None: - projects = ( - "cra-civil", - "nia-population", - "sro-social", - "mosd-programme", - "sipf-pensions", - "nagdi-agriculture", - ) - for project in projects: - for environment in ("local", "hosted"): - with self.subTest(project=project, environment=environment): - authored = yaml.safe_load( - ( - ROOT - / "projects" - / project - / "environments" - / f"{environment}.yaml" - ).read_text(encoding="utf-8") - ) - relay_root = ( - ROOT - / "runtime" - / "registry-projects" - / environment - / project - / "relay" - ) - public = yaml.safe_load( - (relay_root / "relay.yaml").read_text(encoding="utf-8") - ) - consultation = yaml.safe_load( - (relay_root / "relay-consultation.yaml").read_text( - encoding="utf-8" - ) - ) - service_id = authored["deployment"]["relay"]["service"] - workload_id = authored["notary_relay"]["workload_client_id"] - self.assertNotIn("consultation", public) - self.assertIn("consultation", consultation) - self.assertEqual(public["instance"]["id"], service_id) - self.assertEqual( - consultation["instance"]["id"], f"{service_id}-consultation" - ) - self.assertEqual(public["datasets"], consultation["datasets"]) - self.assertEqual( - public["auth"]["oidc"]["allowed_clients"], - sorted(set(authored["relay"].get("allowed_clients", []))), - ) - self.assertEqual( - consultation["auth"]["oidc"]["allowed_clients"], - [workload_id], - ) - self.assertEqual( - consultation["consultation"]["authorized_workload"][ - "client_value" - ], - workload_id, - ) - self.assertEqual( - consultation["server"]["bind"], "127.0.0.1:8080" - ) - - def test_relay_uses_canonical_release_with_explicit_source_dev_path(self) -> None: - versions = dict( - line.split("=", 1) - for line in (ROOT / "versions.env").read_text(encoding="utf-8").splitlines() - if line and not line.startswith("#") and "=" in line - ) - self.assertRegex( - versions["REGISTRY_STACK_SOURCE_REF"], - r"^v[0-9]+\.[0-9]+\.[0-9]+$", - ) - self.assertRegex(versions["REGISTRY_STACK_SOURCE_COMMIT"], r"^[0-9a-f]{40}$") - self.assertNotIn("REGISTRY_RELAY_FEATURES", versions) - self.assertNotIn("SOLMARA_RELAY_RUNTIME_IMAGE", versions) - self.assertRegex( - versions["VOLUME_INIT_IMAGE"], - r"^busybox@sha256:[0-9a-f]{64}$", - ) - self.assertEqual( - versions["SOLMARA_RELAY_DEV_IMAGE"], - "solmara-lab-registry-relay:source", - ) - self.assertEqual(int(versions["SOLMARA_RELAY_BUNDLE_SEQUENCE"]), 2) - - dockerfile = ( - ROOT / "docker" / "relay-runtime" / "Dockerfile" - ).read_text(encoding="utf-8") - self.assertIn("FROM ${REGISTRY_RELAY_IMAGE}", dockerfile) - self.assertIn( - 'LABEL org.opencontainers.image.base.name="${REGISTRY_RELAY_IMAGE}"', - dockerfile, - ) - self.assertIn( - 'LABEL org.opencontainers.image.source="https://github.com/registrystack/solmara-lab"', - dockerfile, - ) - self.assertIn("--package registry-relay", dockerfile) - self.assertNotIn("--features", dockerfile) - self.assertNotIn("--all-features", dockerfile) - - justfile = (ROOT / "justfile").read_text(encoding="utf-8") - self.assertEqual(justfile.count("scripts/build-relay-runtime.sh"), 2) - self.assertNotIn("SOLMARA_RELAY_RUNTIME_IMAGE", justfile) - self.assertEqual( - justfile.count( - 'REGISTRY_RELAY_IMAGE="$SOLMARA_RELAY_DEV_IMAGE"' - ), - 2, - ) - normal_up = justfile.split("\nup:\n", 1)[1].split("\n\n", 1)[0] - normal_esignet = justfile.split("\nup-esignet:\n", 1)[1].split( - "\n\n", - 1, - )[0] - self.assertNotIn("build-relay-runtime.sh", normal_up) - self.assertNotIn("build-relay-runtime.sh", normal_esignet) - self.assertNotIn("REGISTRY_RELAY_IMAGE=", normal_up) - self.assertNotIn("REGISTRY_RELAY_IMAGE=", normal_esignet) - - review_script = (ROOT / "scripts" / "review.sh").read_text( - encoding="utf-8" - ) - self.assertIn('git -C "$root" grep -I -n', review_script) - self.assertNotIn("grep -RIn", review_script) - - release_workflow = ( - ROOT / ".github" / "workflows" / "release-candidate.yml" - ).read_text(encoding="utf-8") - self.assertNotIn( - "- name: Build and push feature-enabled Relay runtime", - release_workflow, - ) - self.assertNotIn("SOLMARA_RELAY_RUNTIME_IMAGE", release_workflow) - self.assertIn( - "REGISTRY_RELAY_IMAGE=${{ env.REGISTRY_RELAY_IMAGE }}", - release_workflow, - ) - self.assertIn( - "VOLUME_INIT_IMAGE=${{ env.VOLUME_INIT_IMAGE }}", - release_workflow, - ) - self.assertIn("SOLMARA_RELAY_BUNDLE_SEQUENCE", release_workflow) - self.assertLess( - release_workflow.index("- name: Build and push hosted Relay image"), - release_workflow.index("- name: Verify hosted Relay signed bundles"), - ) - self.assertIn( - "config verify-bundle", - release_workflow, - ) - self.assertIn('"$project/consultation"', release_workflow) - - def test_coolify_authority_state_is_postgresql_isolated(self) -> None: - authority_groups = { - "compose.coolify.interior.yaml": ( - "nia", - ( - ("cra", "cra-civil", "cra-civil-relay", "cra-notary"), - ("nia", "nia-population", "nia-population-relay", "nia-notary"), - ), - ), - "compose.coolify.social-development.yaml": ( - "", - ( - ("sro", "sro-social", "sro-social-relay", "sro-notary"), - ( - "programme", - "mosd-programme", - "programme-mis-relay", - "programme-notary", - ), - ), - ), - "compose.coolify.labour-pensions.yaml": ( - "sipf", - (("sipf", "sipf-pensions", "sipf-pensions-relay", "sipf-notary"),), - ), - "compose.coolify.agriculture.yaml": ( - "", - ( - ( - "nagdi", - "nagdi-agriculture", - "nagdi-agriculture-relay", - "nagdi-notary", - ), - ), - ), - } - - for compose_name, (source_readers, authorities) in authority_groups.items(): - with self.subTest(compose=compose_name): - compose = yaml.safe_load( - (ROOT / compose_name).read_text(encoding="utf-8") - ) - declared_volumes = set((compose.get("volumes") or {}).keys()) - services = compose["services"] - self.assertNotIn("redis", services) - self.assertIn("postgres-data", declared_volumes) - self.assertIn( - "postgres-data:/var/lib/postgresql/data", - services["postgres"]["volumes"], - ) - postgres_env = services["postgres"]["environment"] - authority_keys = [authority[0] for authority in authorities] - self.assertEqual( - postgres_env["SOLMARA_RELAY_DATABASES"].split(), authority_keys - ) - self.assertEqual( - postgres_env["SOLMARA_NOTARY_DATABASES"].split(), authority_keys - ) - self.assertEqual( - postgres_env["SOLMARA_SOURCE_READER_DATABASES"], source_readers - ) - self.assertEqual( - services["registry-postgresql-bootstrap"]["restart"], "no" - ) - for service_name, service in services.items(): - for mount in service.get("volumes") or []: - with self.subTest(service=service_name, mount=mount): - self.assertIn( - mount.split(":", 1)[0], - declared_volumes, - "Coolify authority services must use named volumes; " - "repository bind mounts are not deployable closures", - ) - - for key, project, relay_name, notary_name in authorities: - with self.subTest(authority=key): - relay = services[relay_name] - consultation_relay_name = f"{relay_name}-consultation" - consultation_relay = services[consultation_relay_name] - notary = services[notary_name] - installer = services[f"{notary_name}-state-install"] - workload_agent_name = f"{key}-workload-agent" - workload_agent = services[workload_agent_name] - bootstrap = services[f"{key}-relay-state-bootstrap"] - config_state_init = services[ - f"{key}-relay-config-state-init" - ] - consultation_config_state_init = services[ - f"{key}-relay-consultation-config-state-init" - ] - relay_mounts = set(relay.get("volumes") or []) - consultation_relay_mounts = set( - consultation_relay.get("volumes") or [] - ) - bootstrap_mounts = set(bootstrap.get("volumes") or []) - config_state_mounts = set( - config_state_init.get("volumes") or [] - ) - consultation_config_state_mounts = set( - consultation_config_state_init.get("volumes") or [] - ) - notary_mounts = set(notary.get("volumes") or []) - public_relay_config = ( - f"/etc/solmara/hosted-relay-bundles/{project}/bootstrap.yaml" - ) - consultation_relay_config = ( - f"/etc/solmara/hosted-relay-bundles/{project}/" - "consultation/bootstrap.yaml" - ) - notary_config = ( - f"/etc/solmara/registry-projects/hosted/{project}/notary/notary.yaml" - ) - self.assertEqual( - relay["command"], ["--config", public_relay_config] - ) - self.assertEqual( - consultation_relay["command"], - [ - "--config", - consultation_relay_config, - "--bind", - "127.0.0.1:8080", - ], - ) - self.assertEqual( - bootstrap["command"][ - bootstrap["command"].index("--config") + 1 - ], - consultation_relay_config, - ) - self.assertIn(f"{key}-relay-cache", declared_volumes) - self.assertIn( - f"{key}-relay-consultation-cache", declared_volumes - ) - self.assertIn( - f"{key}-relay-cache:/var/lib/registry-relay/cache", - relay_mounts, - ) - self.assertIn( - f"{key}-relay-consultation-cache:" - "/var/lib/registry-relay/cache", - consultation_relay_mounts, - ) - self.assertIn( - f"{key}-relay-consultation-cache:" - "/var/lib/registry-relay/cache", - bootstrap_mounts, - ) - self.assertNotIn( - "REGISTRY_RELAY_CONSULTATION_DATABASE_URL", - relay["environment"], - ) - self.assertIn( - "REGISTRY_RELAY_CONSULTATION_DATABASE_URL", - consultation_relay["environment"], - ) - self.assertEqual(relay["expose"], ["8080"]) - self.assertEqual(consultation_relay["expose"], ["8081"]) - self.assertNotIn("labels", consultation_relay) - self.assertEqual(config_state_init["user"], "0:0") - self.assertEqual( - config_state_init["entrypoint"], - ["/bin/busybox", "sh", "-eu", "-c"], - ) - self.assertEqual( - consultation_config_state_init["entrypoint"], - ["/bin/busybox", "sh", "-eu", "-c"], - ) - self.assertIn( - f"{key}-relay-cache:/var/lib/registry-relay/cache", - config_state_mounts, - ) - self.assertEqual( - relay["depends_on"][f"{key}-relay-config-state-init"][ - "condition" - ], - "service_completed_successfully", - ) - self.assertIn( - f"{key}-relay-consultation-cache:" - "/var/lib/registry-relay/cache", - consultation_config_state_mounts, - ) - self.assertEqual( - bootstrap["depends_on"][ - f"{key}-relay-consultation-config-state-init" - ]["condition"], - "service_completed_successfully", - ) - self.assertEqual( - consultation_relay["depends_on"][ - f"{key}-relay-state-bootstrap" - ]["condition"], - "service_completed_successfully", - ) - self.assertEqual( - notary["command"], ["--config", notary_config] - ) - self.assertEqual( - installer["command"][ - installer["command"].index("--config") + 1 - ], - notary_config, - ) - self.assertEqual( - notary["network_mode"], - f"service:{consultation_relay_name}", - ) - self.assertEqual(notary["user"], "65534:65534") - self.assertNotIn( - "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", - notary["environment"], - ) - self.assertIn( - "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", - installer["environment"], - ) - self.assertEqual(installer["restart"], "no") - self.assertEqual( - notary["depends_on"][f"{notary_name}-state-install"][ - "condition" - ], - "service_completed_successfully", - ) - self.assertEqual( - notary["depends_on"][workload_agent_name]["condition"], - "service_healthy", - ) - self.assertEqual( - installer["depends_on"][workload_agent_name]["condition"], - "service_healthy", - ) - workload_environment = workload_agent["environment"] - workload_identity = json.loads( - workload_environment["WORKLOAD_IDENTITIES_JSON"] - ) - self.assertEqual( - workload_environment["WORKLOAD_ISSUER"], - "https://workload-issuer.solmara.registrystack.org", - ) - private_jwk_env = f"{key.upper()}_RELAY_WORKLOAD_JWK" - self.assertEqual( - workload_environment[private_jwk_env], - f"${{{private_jwk_env}}}", - ) - self.assertEqual(len(workload_identity), 1) - self.assertEqual( - workload_identity[0]["private_jwk_env"], - private_jwk_env, - ) - self.assertEqual( - workload_identity[0]["token_file"], - f"/run/secrets/{key}-notary-relay-token", - ) - self.assertIn( - f"{key}-workload-token:/run/secrets", - workload_agent["volumes"], - ) - self.assertIsNone( - compose["volumes"][f"{key}-workload-token"] - ) - self.assertEqual( - notary["labels"]["solmara.lab.host"], - f"{notary_name}.solmara.registrystack.org", - ) - self.assertFalse( - any( - "/var/lib/registry-notary" in mount - for mount in notary_mounts - ) - ) - - def test_hosted_authority_images_contain_runtime_closures_and_tls(self) -> None: - relay_dockerfile = (ROOT / "docker" / "relay" / "Dockerfile").read_text( - encoding="utf-8" - ) - notary_dockerfile = ( - ROOT / "docker" / "notary" / "Dockerfile" - ).read_text(encoding="utf-8") - projects = ( - "cra-civil", - "nia-population", - "sro-social", - "mosd-programme", - "sipf-pensions", - "nagdi-agriculture", - ) - for project in projects: - with self.subTest(project=project): - self.assertIn( - f"COPY runtime/registry-projects/hosted/{project}/notary/notary.yaml " - f"/etc/solmara/registry-projects/hosted/{project}/notary/notary.yaml", - notary_dockerfile, - ) - self.assertIn( - "COPY config/hosted-relay-bundles " - "/etc/solmara/hosted-relay-bundles", - relay_dockerfile, - ) - self.assertIn( - "COPY --from=volume-init /bin/busybox /bin/busybox", - relay_dockerfile, - ) - self.assertNotIn( - "COPY runtime/registry-projects/hosted/", - relay_dockerfile, - ) - tls_copy = ( - "COPY config/postgres/ssl/server.crt /etc/solmara/postgres/root.crt" - ) - self.assertIn(tls_copy, relay_dockerfile) - self.assertIn(tls_copy, notary_dockerfile) - - def test_hosted_postgresql_image_contains_both_live_source_fixtures(self) -> None: - dockerfile = (ROOT / "docker" / "postgres" / "Dockerfile").read_text( - encoding="utf-8" - ) - self.assertIn( - "COPY ministries/interior-population/fixtures /docker-entrypoint-initdb.d", - dockerfile, - ) - self.assertIn( - "COPY ministries/labour-pensions/fixtures /docker-entrypoint-initdb.d", - dockerfile, - ) - - def test_notary_postgresql_state_is_isolated_and_redis_free(self) -> None: - notaries = { - "cra-notary": ("cra-civil", "cra", "cra-civil-relay"), - "nia-notary": ("nia-population", "nia", "nia-population-relay"), - "sro-notary": ("sro-social", "sro", "sro-social-relay"), - "programme-notary": ( - "mosd-programme", - "programme", - "programme-mis-relay", - ), - "sipf-notary": ("sipf-pensions", "sipf", "sipf-pensions-relay"), - "nagdi-notary": ( - "nagdi-agriculture", - "nagdi", - "nagdi-agriculture-relay", - ), - } - local = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) - services = local["services"] - declared_volumes = set((local.get("volumes") or {}).keys()) - self.assertNotIn("redis", services) - self.assertIn("postgres-data", declared_volumes) - self.assertIn( - "postgres-data:/var/lib/postgresql/data", - services["postgres"]["volumes"], - ) - self.assertNotIn( - "postgres-data:/var/lib/postgresql", - services["postgres"]["volumes"], - ) - state_proof = (ROOT / "scripts" / "notary_state_restart.py").read_text( - encoding="utf-8" - ) - for required in ( - "SELECT system_identifier FROM pg_control_system()", - "validate_runtime_pgdata_mounts", - 'self.run_just("down")', - 'self.run_just("up")', - "compare_snapshots(before, after)", - 'volume_labels.get("com.docker.compose.volume") != "postgres-data"', - ): - self.assertIn(required, state_proof) - self.assertNotIn('run_just("reset")', state_proof) - self.assertNotIn('run_just("restart")', state_proof) - self.assertEqual( - {name for name in services if name.endswith("-notary")}, - set(notaries), - ) - self.assertEqual( - services["postgres"]["environment"]["SOLMARA_NOTARY_DATABASES"].split(), - ["cra", "nia", "sro", "programme", "sipf", "nagdi"], - ) - self.assertEqual( - services["postgres"]["environment"]["SOLMARA_RELAY_DATABASES"].split(), - ["cra", "nia", "sro", "programme", "sipf", "nagdi"], - ) - source_urls = { - services["nia-population-relay"]["environment"]["SOLMARA_NIA_DATABASE_URL"], - services["sipf-pensions-relay"]["environment"]["SOLMARA_SIPF_DATABASE_URL"], - } - self.assertEqual( - source_urls, - {"${SOLMARA_NIA_DATABASE_URL}", "${SOLMARA_SIPF_DATABASE_URL}"}, - ) - env_example = (ROOT / ".env.example").read_text(encoding="utf-8") - self.assertIn( - "SOLMARA_NIA_DATABASE_URL=postgres://solmara_source_nia_reader:", - env_example, - ) - self.assertIn( - "SOLMARA_SIPF_DATABASE_URL=postgres://solmara_source_sipf_reader:", - env_example, - ) - self.assertNotIn( - "SOLMARA_NIA_DATABASE_URL=postgres://solmara_registry:", env_example - ) - self.assertNotIn( - "SOLMARA_SIPF_DATABASE_URL=postgres://solmara_registry:", env_example - ) - for token_name in ( - "CRA_CHILD_BENEFIT_CLIENT_TOKEN", - "NIA_CHILD_BENEFIT_CLIENT_TOKEN", - "SRO_CHILD_BENEFIT_CLIENT_TOKEN", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN", - ): - self.assertIn(f"{token_name}=", env_example) - self.assertNotIn("CIVIL_CHILD_BENEFIT_NOTARY_TOKEN=", env_example) - self.assertNotIn("NIA_CHILD_BENEFIT_NOTARY_TOKEN=", env_example) - self.assertNotIn("SRO_CHILD_BENEFIT_NOTARY_TOKEN=", env_example) - self.assertNotIn("PROGRAMME_CHILD_BENEFIT_NOTARY_TOKEN=", env_example) - bootstrap = services["registry-postgresql-bootstrap"] - self.assertEqual(bootstrap["restart"], "no") - self.assertEqual( - bootstrap["depends_on"]["postgres"]["condition"], - "service_healthy", - ) - - runtime_urls = set() - migrator_urls = set() - relay_urls = set() - for service_name, (project, database_key, relay_name) in notaries.items(): - with self.subTest(notary=service_name): - config = yaml.safe_load( - ( - ROOT - / "runtime" - / "registry-projects" - / "local" - / project - / "notary" - / "notary.yaml" - ).read_text(encoding="utf-8") - ) - state = config["state"] - self.assertEqual(state["storage"], "postgresql") - self.assertEqual( - state["postgresql"]["url_env"], "REGISTRY_NOTARY_POSTGRES_URL" - ) - self.assertEqual( - state["postgresql"]["root_certificate_path"], - "/etc/solmara/postgres/root.crt", - ) - self.assertNotIn("replay", config) - - expected_database = f"solmara_notary_{database_key}" - expected_runtime = f"{expected_database}_runtime" - expected_migrator = f"{expected_database}_migrator" - expected_owner = f"{expected_database}_owner" - installer_name = f"{service_name}-state-install" - public_workload_agent = f"{database_key}-workload-agent" - workload_agent = f"{database_key}-consultation-workload-agent" - - runtime = services[service_name] - installer = services[installer_name] - agent = services[workload_agent] - consultation_relay_name = f"{relay_name}-consultation" - public_relay = services[relay_name] - consultation_relay = services[consultation_relay_name] - runtime_url = runtime["environment"]["REGISTRY_NOTARY_POSTGRES_URL"] - migrator_url = installer["environment"][ - "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL" - ] - relay_url = consultation_relay["environment"][ - "REGISTRY_RELAY_CONSULTATION_DATABASE_URL" - ] - relay_cache = f"{database_key}-relay-consultation-cache" - workload_token = f"{database_key}-consultation-workload-token" - self.assertIn(relay_cache, declared_volumes) - self.assertIn(workload_token, declared_volumes) - self.assertIn( - f"{relay_cache}:/var/lib/registry-relay/cache", - consultation_relay["volumes"], - ) - self.assertNotIn( - "REGISTRY_RELAY_CONSULTATION_DATABASE_URL", - public_relay["environment"], - ) - self.assertEqual( - public_relay["command"], - ["--config", "/etc/registry-relay/relay.yaml"], - ) - self.assertEqual( - consultation_relay["command"], - [ - "--config", - "/etc/registry-relay/relay-consultation.yaml", - "--bind", - "127.0.0.1:8080", - ], - ) - self.assertEqual( - consultation_relay["environment"]["REGISTRY_RELAY_BIND"], - "127.0.0.1:8080", - ) - self.assertIn(f"{expected_runtime}:", runtime_url) - self.assertIn(f"/{expected_database}?sslmode=require", runtime_url) - self.assertNotIn( - "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL", runtime["environment"] - ) - self.assertIn(f"{expected_migrator}:", migrator_url) - self.assertIn(f"/{expected_database}?sslmode=require", migrator_url) - self.assertIn(expected_owner, installer["command"]) - self.assertIn(expected_runtime, installer["command"]) - self.assertEqual(installer["restart"], "no") - self.assertEqual( - runtime["network_mode"], f"service:{consultation_relay_name}" - ) - self.assertEqual(runtime["user"], "65534:65534") - self.assertEqual( - runtime["healthcheck"]["test"], - [ - "CMD", - "/usr/local/bin/registry-notary", - "healthcheck", - "--url", - "http://127.0.0.1:8081/ready", - ], - ) - self.assertEqual( - services[public_workload_agent]["network_mode"], - f"service:{relay_name}", - ) - self.assertEqual( - agent["network_mode"], f"service:{consultation_relay_name}" - ) - self.assertNotIn("WORKLOAD_BIND_HOST", agent["environment"]) - self.assertEqual( - agent["environment"]["WORKLOAD_ISSUER"], - "http://127.0.0.1:8090", - ) - self.assertIn( - f"{workload_token}:/run/secrets:ro", runtime["volumes"] - ) - self.assertIn( - f"{workload_token}:/run/secrets:ro", installer["volumes"] - ) - authored_environment = yaml.safe_load( - ( - ROOT - / "projects" - / project - / "environments" - / "local.yaml" - ).read_text(encoding="utf-8") - ) - self.assertEqual( - authored_environment["relay"]["jwks_url"], - "http://127.0.0.1:8090/.well-known/jwks.json", - ) - self.assertEqual( - installer["depends_on"]["registry-postgresql-bootstrap"][ - "condition" - ], - "service_completed_successfully", - ) - self.assertEqual( - installer["depends_on"][workload_agent]["condition"], - "service_healthy", - ) - self.assertEqual( - runtime["depends_on"][installer_name]["condition"], - "service_completed_successfully", - ) - self.assertEqual( - runtime["depends_on"][workload_agent]["condition"], - "service_healthy", - ) - - runtime_urls.add(runtime_url) - migrator_urls.add(migrator_url) - relay_urls.add(relay_url) - - self.assertEqual(len(runtime_urls), len(notaries)) - self.assertEqual(len(migrator_urls), len(notaries)) - self.assertEqual(len(relay_urls), len(notaries)) - - esignet = yaml.safe_load( - (ROOT / "compose.coolify.esignet.yaml").read_text(encoding="utf-8") - ) - self.assertIn("esignet-redis", esignet["services"]) - - def test_hosted_child_benefit_topology_is_source_owned(self) -> None: - core = yaml.safe_load( - (ROOT / "compose.coolify.yaml").read_text(encoding="utf-8") - ) - core_services = core["services"] - self.assertIn("child-benefit-federator", core_services) - self.assertNotIn("child-benefit-notary", core_services) - federator = core_services["child-benefit-federator"] - federator_env = federator["environment"] - self.assertEqual( - federator["labels"]["solmara.lab.host"], - "child-benefit-federator.solmara.registrystack.org", - ) - - expected = ( - ( - "compose.coolify.interior.yaml", - "cra-notary", - "cra-civil-relay", - "cra-civil", - "CRA_NOTARY_URL", - "CRA_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - {"birth-is-registered", "child-age-under-5"}, - ), - ( - "compose.coolify.interior.yaml", - "nia-notary", - "nia-population-relay", - "nia-population", - "NIA_NOTARY_URL", - "NIA_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - {"population-record-active"}, - ), - ( - "compose.coolify.social-development.yaml", - "sro-notary", - "sro-social-relay", - "sro-social", - "SRO_NOTARY_URL", - "SRO_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - {"household-below-poverty-threshold"}, - ), - ( - "compose.coolify.social-development.yaml", - "programme-notary", - "programme-mis-relay", - "mosd-programme", - "PROGRAMME_NOTARY_URL", - "PROGRAMME_CHILD_BENEFIT_CLIENT_TOKEN_HASH", - {"not-already-enrolled"}, - ), - ) - for ( - compose_name, - service_id, - relay_id, - project, - url_env, - token_hash_env, - expected_claims, - ) in expected: - with self.subTest(service=service_id): - compose = yaml.safe_load( - (ROOT / compose_name).read_text(encoding="utf-8") - ) - services = compose["services"] - self.assertNotIn("child-benefit-notary", services) - service = services[service_id] - self.assertEqual( - service["network_mode"], - f"service:{relay_id}-consultation", - ) - self.assertEqual( - service["labels"]["solmara.lab.host"], - f"{service_id}.solmara.registrystack.org", - ) - self.assertIn(token_hash_env, service["environment"]) - - public_url = f"https://{service_id}.solmara.registrystack.org" - self.assertEqual(federator_env[url_env], public_url) - config = yaml.safe_load( - ( - ROOT - / "runtime" - / "registry-projects" - / "hosted" - / project - / "notary" - / "notary.yaml" - ).read_text(encoding="utf-8") - ) - self.assertEqual(config["instance"]["id"], service_id) - self.assertEqual(config["evidence"]["service_id"], service_id) - self.assertEqual( - config["evidence"]["relay"]["base_url"], - "http://127.0.0.1:8080", - ) - self.assertTrue( - config["evidence"]["relay"]["allow_insecure_localhost"] - ) - self.assertEqual( - config["evidence"]["relay"]["workload_client_id"], service_id - ) - self.assertEqual( - config["evidence"]["relay"]["token_file"], - f"/run/secrets/{service_id}-relay-token", - ) - self.assertEqual( - { - claim["id"] - for claim in config["evidence"]["claims"] - if claim["purpose"] - == "https://id.registrystack.org/solmara/purpose/child-benefit-review" - }, - expected_claims, - ) - - self.assertFalse((ROOT / "hosted" / "notaries" / "child-benefit.yaml").exists()) - - def test_story_preview_smoke_passes_current_tree(self) -> None: - result = subprocess.run( - [str(ROOT / "scripts" / "smoke-story-previews.py")], - cwd=ROOT, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_live_smoke_extracts_claim_values(self) -> None: - smoke_live = load_smoke_live() - values = smoke_live.claim_values( - { - "results": [ - {"claim_id": "population-record-active", "value": True}, - {"claim_id": "not-already-enrolled", "satisfied": False}, - ] - } - ) - - self.assertEqual( - values, - {"population-record-active": True, "not-already-enrolled": False}, - ) - - def test_live_smoke_extracts_catalog_claim_ids(self) -> None: - smoke_live = load_smoke_live() - - self.assertEqual( - smoke_live.catalog_claim_ids( - {"data": [{"id": "person-is-deceased"}, {"id": "survivor-is-eligible"}]} - ), - {"person-is-deceased", "survivor-is-eligible"}, - ) - - def test_live_smoke_waits_for_notary_readiness(self) -> None: - smoke_live = load_smoke_live() - urls: list[str] = [] - original_http_json = smoke_live.http_json - - def ready(method, url, headers, *, timeout): - urls.append(url) - return SimpleNamespace(status=200, error="") - - smoke_live.http_json = ready - try: - self.assertIsNone( - smoke_live.wait_for_readiness("http://notary.test", "Test Notary") - ) - finally: - smoke_live.http_json = original_http_json - - self.assertEqual(urls, ["http://notary.test/ready"]) - - def test_child_benefit_offerings_advertise_only_the_endpoint_purpose(self) -> None: - expected_purposes = [ - "https://id.registrystack.org/solmara/purpose/child-benefit-review" - ] - offering_ids = ( - "cra-birth-registration-offering", - "nia-population-population-status-offering", - "sro-social-household-poverty-offering", - "mosd-programme-beneficiary-enrollment-offering", - ) - - for offering_id in offering_ids: - with self.subTest(offering=offering_id): - path = ( - ROOT - / "metadata" - / "public" - / "metadata" - / "evidence-offerings" - / f"{offering_id}.json" - ) - offering = json.loads(path.read_text(encoding="utf-8")) - self.assertEqual(offering["purposes"], expected_purposes) - - def test_child_benefit_authority_predicate_collection_is_publicly_discoverable( - self, - ) -> None: - catalog_path = ROOT / "metadata" / "public" / "metadata" / "catalog.json" - catalog = json.loads(catalog_path.read_text(encoding="utf-8")) - services = {service["id"]: service for service in catalog["data_services"]} - collector = services["child-benefit-evidence-collector-api"] - - self.assertEqual( - collector["endpoint_url"], - "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - ) - child_service = next( - service - for service in catalog["public_services"] - if service["id"] == "child-benefit-review" - ) - self.assertIn( - "child-benefit-evidence-collector-api", child_service["data_services"] - ) - - offering_path = ( - ROOT - / "metadata" - / "public" - / "metadata" - / "evidence-offerings" - / "solmara.child-benefit.authority-predicate-collection.json" - ) - offering = json.loads(offering_path.read_text(encoding="utf-8")) - self.assertEqual( - offering["access"]["endpoint_url"], - "https://child-benefit-federator.solmara.registrystack.org/v1/evaluations", - ) - self.assertEqual( - offering["access"]["media_type"], - "application/json", - ) - self.assertEqual( - offering["purposes"], - ["https://id.registrystack.org/solmara/purpose/child-benefit-review"], - ) - self.assertEqual(offering["public_services"], ["child-benefit-review"]) - - def test_compose_project_name_is_stable_and_checkout_scoped(self) -> None: - compose_names = load_compose_project_name() - - first = compose_names.compose_project_name(Path("/tmp/solmara-lab")) - second = compose_names.compose_project_name(Path("/tmp/other/solmara-lab")) - - self.assertRegex(first, r"^solmara-lab-[0-9a-f]{10}$") - self.assertNotEqual(first, second) - - def test_notary_bru_requests_match_configured_auth_and_disclosure(self) -> None: - requests = [ - ROOT - / "requests" - / "registry-lab" - / "20 - Child Benefit" - / "01 - Collect source predicates.bru", - ROOT - / "requests" - / "registry-lab" - / "30 - Pension Survivor" - / "01 - Evaluate pension stop.bru", - ROOT - / "requests" - / "registry-lab" - / "30 - Pension Survivor" - / "02 - Read active pension payment.bru", - ROOT - / "requests" - / "registry-lab" - / "30 - Pension Survivor" - / "03 - Read survivor eligibility.bru", - ROOT - / "requests" - / "registry-lab" - / "40 - NAgDI Voucher" - / "01 - Voucher eligibility.bru", - ROOT - / "requests" - / "registry-lab" - / "40 - NAgDI Voucher" - / "02 - Livestock movement control.bru", - ] - - for request_path in requests: - with self.subTest(request=request_path.name): - request = request_path.read_text() - self.assertIn("x-api-key: {{", request) - self.assertNotIn("Authorization: Bearer", request) - self.assertIn('"disclosure": "predicate"', request) - self.assertNotIn('"disclosure": "decision"', request) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_registry_stack_release_pin.py b/scripts/test_registry_stack_release_pin.py new file mode 100644 index 0000000..0a99b69 --- /dev/null +++ b/scripts/test_registry_stack_release_pin.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("check-registry-stack-release-pin.py") +SPEC = importlib.util.spec_from_file_location("release_pin", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def authenticator_values() -> dict[str, str]: + base = "https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/" + return { + "ESIGNET_AUTHENTICATOR_VERSION": "0.2.0", + "ESIGNET_AUTHENTICATOR_RELEASE_URL": "https://github.com/jeremi/esignet-relay-authenticator/releases/tag/v0.2.0", + "ESIGNET_AUTHENTICATOR_JAR_URL": base + "esignet-relay-authenticator-0.2.0.jar", + "ESIGNET_AUTHENTICATOR_JAR_SHA256": "e" * 64, + "ESIGNET_AUTHENTICATOR_CHECKSUM_URL": base + "esignet-relay-authenticator-0.2.0.jar.sha256", + } + + +class ReleasePinTests(unittest.TestCase): + def test_older_release_is_rejected_in_favour_of_coherent_release(self) -> None: + values = {"REGISTRY_STACK_REQUIRED_VERSION": "0.21.0", **authenticator_values()} + self.assertIn("must be 0.22.0", MODULE.validate(values, require_public=False)[0]) + + def test_missing_public_digest_is_an_explicit_blocker(self) -> None: + values = { + "REGISTRY_STACK_REQUIRED_VERSION": "0.22.0", + "REGISTRY_STACK_SOURCE_REF": "v0.22.0", + **authenticator_values(), + } + self.assertEqual(MODULE.validate(values, require_public=False), []) + self.assertIn("promotion is blocked", MODULE.validate(values, require_public=True)[0]) + + def test_digest_must_be_exact(self) -> None: + values = { + "REGISTRY_STACK_REQUIRED_VERSION": "0.22.0", + "REGISTRY_STACK_SOURCE_REF": "v0.22.0", + "REGISTRY_STACK_SOURCE_COMMIT": "f" * 40, + "REGISTRY_STACK_RELEASE_RELAY_DIGEST": "a" * 64, + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL": "https://github.com/registrystack/registry-stack/releases/download/v0.22.0/relayctl-v0.22.0-linux-amd64", + "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256": "d" * 64, + **authenticator_values(), + } + self.assertEqual(MODULE.validate(values, require_public=True), []) + values["REGISTRY_STACK_RELEASE_RELAY_DIGEST"] = "sha256:" + "a" * 64 + self.assertTrue(MODULE.validate(values, require_public=True)) + + def test_public_source_ref_must_bind_the_release_tag(self) -> None: + values = { + "REGISTRY_STACK_REQUIRED_VERSION": "0.22.0", + "REGISTRY_STACK_SOURCE_REF": "main", + **authenticator_values(), + } + self.assertIn( + "REGISTRY_STACK_SOURCE_REF must be v0.22.0", + MODULE.validate(values, require_public=False), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_registryctl_build_output.py b/scripts/test_registryctl_build_output.py deleted file mode 100644 index ab4663f..0000000 --- a/scripts/test_registryctl_build_output.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location( - "registryctl_build_output", - ROOT / "scripts" / "registryctl-build-output.py", -) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class RegistryctlBuildOutputTests(unittest.TestCase): - def setUp(self) -> None: - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.project = Path(self.temporary.name) / "project" - self.project.mkdir() - (self.project / "registry-stack.yaml").write_text( - "version: 1\nregistry:\n id: example\n", - encoding="utf-8", - ) - self.output = self.project / ".registry-stack" / "build" / "local" - relay = self.output / "private" / "relay" / "config" - relay.mkdir(parents=True) - (relay / "relay.yaml").write_text("instance: {}\n", encoding="utf-8") - notary = self.output / "private" / "notary" / "config" - notary.mkdir(parents=True) - (notary / "notary.yaml").write_text("instance: {}\n", encoding="utf-8") - - def report(self, **overrides: object) -> bytes: - report: dict[str, object] = { - "schema_version": MODULE.REPORT_SCHEMA, - "status": "built", - "project": "example", - "environment": "local", - "fixtures": [], - "semantic_changes": [], - "baseline": "initial_without_baseline", - "output": ".registry-stack/build/local", - } - report.update(overrides) - return json.dumps(report).encode("utf-8") - - def test_accepts_the_versioned_project_owned_build_root(self) -> None: - self.assertEqual( - MODULE.parse_build_output( - self.report(), - project_directory=self.project, - environment="local", - ), - self.output.resolve(), - ) - - def test_accepts_the_legacy_absolute_project_owned_build_root(self) -> None: - self.assertEqual( - MODULE.parse_build_output( - self.report(output=str(self.output)), - project_directory=self.project, - environment="local", - ), - self.output.resolve(), - ) - - def test_rejects_the_wrong_environment_without_echoing_report_values(self) -> None: - with self.assertRaisesRegex( - MODULE.BuildReportError, - "wrong environment binding", - ) as rejected: - MODULE.parse_build_output( - self.report(environment="hosted", secret="must-not-echo"), - project_directory=self.project, - environment="local", - ) - self.assertNotIn("must-not-echo", str(rejected.exception)) - - def test_rejects_an_output_root_outside_the_project(self) -> None: - outside = Path(self.temporary.name) / "outside" - outside.mkdir() - with self.assertRaisesRegex( - MODULE.BuildReportError, - "not a real project-owned directory", - ): - MODULE.parse_build_output( - self.report(output=str(outside)), - project_directory=self.project, - environment="local", - ) - - def test_rejects_a_relative_output_root_that_escapes_the_project(self) -> None: - outside = Path(self.temporary.name) / "outside" - outside.mkdir() - with self.assertRaisesRegex( - MODULE.BuildReportError, - "not a real project-owned directory", - ): - MODULE.parse_build_output( - self.report(output="../outside"), - project_directory=self.project, - environment="local", - ) - - def test_rejects_an_incomplete_product_closure(self) -> None: - (self.output / "private" / "notary" / "config" / "notary.yaml").unlink() - with self.assertRaisesRegex( - MODULE.BuildReportError, - "configuration closure is incomplete", - ): - MODULE.parse_build_output( - self.report(), - project_directory=self.project, - environment="local", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_registryctl_test_output.py b/scripts/test_registryctl_test_output.py deleted file mode 100644 index a79c048..0000000 --- a/scripts/test_registryctl_test_output.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location( - "registryctl_test_output", - ROOT / "scripts" / "registryctl-test-output.py", -) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class RegistryctlTestOutputTests(unittest.TestCase): - def report(self, **overrides: object) -> bytes: - report: dict[str, object] = { - "schema_version": MODULE.REPORT_SCHEMA, - "status": "passed", - "project": "example", - "fixtures": [{"fixture": "match", "passed": True}], - "fixture_coverage": { - "targets": [ - { - "requirements": [ - { - "state": "covered", - "requirement": MODULE.REQUEST_BINDING_REQUIREMENT, - "evidence": [ - { - "kind": "authored_fixture", - "id": "target/example/fixture/match", - } - ], - } - ] - } - ] - }, - } - report.update(overrides) - return json.dumps(report).encode("utf-8") - - def test_accepts_passing_authored_request_witnesses(self) -> None: - self.assertEqual( - MODULE.validate_test_report(self.report()), - ("example", 1, 1), - ) - - def test_rejects_mapping_derived_request_coverage(self) -> None: - coverage = { - "targets": [ - { - "requirements": [ - { - "state": "missing", - "requirement": MODULE.REQUEST_BINDING_REQUIREMENT, - "reason": "required_evidence_missing", - "evidence": [], - } - ] - } - ] - } - with self.assertRaisesRegex( - MODULE.TestReportError, - "every fixture target must cover", - ): - MODULE.validate_test_report( - self.report(fixture_coverage=coverage) - ) - - def test_rejects_non_authored_binding_evidence(self) -> None: - coverage = { - "targets": [ - { - "requirements": [ - { - "state": "covered", - "requirement": MODULE.REQUEST_BINDING_REQUIREMENT, - "evidence": [{"kind": "compiled_contract"}], - } - ] - } - ] - } - with self.assertRaisesRegex( - MODULE.TestReportError, - "requires authored fixture evidence", - ): - MODULE.validate_test_report( - self.report(fixture_coverage=coverage) - ) - - def test_rejects_a_non_passing_fixture_without_echoing_values(self) -> None: - with self.assertRaisesRegex( - MODULE.TestReportError, - "non-passing fixture", - ) as rejected: - MODULE.validate_test_report( - self.report( - fixtures=[ - { - "fixture": "secret-fixture-name", - "passed": False, - } - ] - ) - ) - self.assertNotIn("secret-fixture-name", str(rejected.exception)) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_relay_workload_identity_agent.py b/scripts/test_relay_workload_identity_agent.py deleted file mode 100644 index 96167bc..0000000 --- a/scripts/test_relay_workload_identity_agent.py +++ /dev/null @@ -1,590 +0,0 @@ -from __future__ import annotations - -import base64 -import contextlib -import importlib.util -import io -import json -import os -import stat -import sys -import tempfile -import threading -import unittest -import urllib.error -import urllib.request -from pathlib import Path -from unittest import mock - -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from cryptography.hazmat.primitives.serialization import ( - Encoding, - NoEncryption, - PrivateFormat, - PublicFormat, -) - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "relay_workload_identity_agent.py" -SPEC = importlib.util.spec_from_file_location("relay_workload_identity_agent", SCRIPT) -assert SPEC and SPEC.loader -agent = importlib.util.module_from_spec(SPEC) -sys.modules["relay_workload_identity_agent"] = agent -SPEC.loader.exec_module(agent) - - -def b64url(value: bytes) -> str: - return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -def decode_segment(value: str) -> dict[str, object]: - raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) - document = json.loads(raw) - assert isinstance(document, dict) - return document - - -def private_jwk( - *, kid: str = "test-workload-key" -) -> tuple[dict[str, str], Ed25519PrivateKey]: - private_key = Ed25519PrivateKey.generate() - private_bytes = private_key.private_bytes( - Encoding.Raw, PrivateFormat.Raw, NoEncryption() - ) - public_bytes = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) - return ( - { - "alg": "EdDSA", - "crv": "Ed25519", - "d": b64url(private_bytes), - "kid": kid, - "kty": "OKP", - "x": b64url(public_bytes), - }, - private_key, - ) - - -def identity_document( - directory: Path, - *, - azp: str = "cra-notary", - subject: str | None = None, - kid_env: str = "TEST_WORKLOAD_PRIVATE_JWK", - token_name: str = "relay-token", - scopes: list[str] | None = None, - token_uid: int | None = None, - token_gid: int | None = None, -) -> dict[str, object]: - document: dict[str, object] = { - "audience": "registry-relay", - "azp": azp, - "subject": subject or azp, - "scopes": scopes - or [ - "registry:consult:cra-child-benefit", - "registry:consult:cra-citizen-record", - ], - "token_file": str(directory / token_name), - "private_jwk_env": kid_env, - } - if token_uid is not None: - document["token_uid"] = token_uid - if token_gid is not None: - document["token_gid"] = token_gid - return document - - -def valid_environment(directory: Path) -> tuple[dict[str, str], Ed25519PrivateKey]: - jwk, private_key = private_jwk() - environment = { - "WORKLOAD_ISSUER": "http://127.0.0.1:8090", - "WORKLOAD_IDENTITIES_JSON": json.dumps([identity_document(directory)]), - "TEST_WORKLOAD_PRIVATE_JWK": json.dumps(jwk), - "WORKLOAD_TOKEN_UID": str(os.getuid()), - "WORKLOAD_TOKEN_GID": str(os.getgid()), - } - return environment, private_key - - -def add_esignet_identity( - environment: dict[str, str], directory: Path -) -> Ed25519PrivateKey: - jwk, private_key = private_jwk(kid="test-esignet-key") - environment["TEST_ESIGNET_PRIVATE_JWK"] = json.dumps(jwk) - identities = json.loads(environment["WORKLOAD_IDENTITIES_JSON"]) - identities.append( - identity_document( - directory, - azp="solmara-esignet", - kid_env="TEST_ESIGNET_PRIVATE_JWK", - token_name="esignet-relay-token", - scopes=["population:identity_release"], - token_uid=os.getuid(), - token_gid=os.getgid(), - ) - ) - environment["WORKLOAD_IDENTITIES_JSON"] = json.dumps(identities) - return private_key - - -class FakeClock: - def __init__(self, value: int) -> None: - self.value = value - - def __call__(self) -> float: - return float(self.value) - - -class WorkloadIdentityAgentTests(unittest.TestCase): - def test_https_dns_issuer_is_independent_from_loopback_listener(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - environment, _ = valid_environment(Path(temporary_directory)) - environment["WORKLOAD_ISSUER"] = ( - "https://workload-issuer.solmara.registrystack.org" - ) - - config = agent.Config.from_environ(environment) - - self.assertEqual(config.bind_host, "127.0.0.1") - self.assertEqual(config.port, 8090) - self.assertEqual( - config.issuer, - "https://workload-issuer.solmara.registrystack.org", - ) - - def test_each_identity_gets_exact_claims_and_its_own_signature(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, notary_key = valid_environment(directory) - esignet_key = add_esignet_identity(environment, directory) - config = agent.Config.from_environ(environment) - - tokens = [ - agent._mint_token(config, identity, 1_700_000_000)[0] - for identity in config.identities - ] - claims = [] - for token, expected_key in zip( - tokens, (notary_key, esignet_key), strict=True - ): - encoded_header, encoded_claims, encoded_signature = token.split(".") - signature = base64.urlsafe_b64decode( - encoded_signature + "=" * (-len(encoded_signature) % 4) - ) - expected_key.public_key().verify( - signature, f"{encoded_header}.{encoded_claims}".encode("ascii") - ) - claims.append(decode_segment(encoded_claims)) - - self.assertEqual(claims[0]["azp"], "cra-notary") - self.assertEqual(claims[0]["sub"], "cra-notary") - self.assertEqual( - claims[0]["scope"], - "registry:consult:cra-child-benefit registry:consult:cra-citizen-record", - ) - self.assertEqual(claims[1]["azp"], "solmara-esignet") - self.assertEqual(claims[1]["sub"], "solmara-esignet") - self.assertEqual(claims[1]["scope"], "population:identity_release") - self.assertNotEqual(claims[0]["jti"], claims[1]["jti"]) - for token_claims in claims: - self.assertEqual(token_claims["iss"], environment["WORKLOAD_ISSUER"]) - self.assertEqual(token_claims["aud"], "registry-relay") - self.assertEqual(token_claims["iat"], 1_700_000_000) - self.assertEqual(token_claims["nbf"], 1_700_000_000) - self.assertEqual(token_claims["exp"], 1_700_000_300) - - def test_public_jwks_contains_every_distinct_public_key_only(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, _ = valid_environment(directory) - add_esignet_identity(environment, directory) - config = agent.Config.from_environ(environment) - - document = agent.IdentityState(config).jwks_document() - - self.assertEqual( - {key["kid"] for key in document["keys"]}, - {"test-workload-key", "test-esignet-key"}, - ) - for key in document["keys"]: - self.assertEqual(set(key), {"alg", "crv", "kid", "kty", "x"}) - - def test_atomic_rotation_publishes_mode_and_ownership_together(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, _ = valid_environment(directory) - config = agent.Config.from_environ(environment) - identity = config.identities[0] - identity.token_file.write_text("previous-token\n", encoding="ascii") - os.chmod(identity.token_file, 0o644) - state = agent.IdentityState(config, clock=FakeClock(1_700_000_000)) - original_replace = os.replace - replace_observed = False - - def inspect_then_replace(source: str, destination: Path) -> None: - nonlocal replace_observed - replace_observed = True - source_status = os.stat(source) - self.assertEqual(stat.S_IMODE(source_status.st_mode), 0o600) - self.assertEqual(source_status.st_uid, os.getuid()) - self.assertEqual(source_status.st_gid, os.getgid()) - self.assertEqual( - identity.token_file.read_text(encoding="ascii"), "previous-token\n" - ) - original_replace(source, destination) - - with mock.patch.object( - agent.os, "replace", side_effect=inspect_then_replace - ): - state.rotate() - - self.assertTrue(replace_observed) - file_status = identity.token_file.stat() - self.assertEqual(stat.S_IMODE(file_status.st_mode), 0o600) - self.assertEqual(file_status.st_uid, os.getuid()) - self.assertEqual(file_status.st_gid, os.getgid()) - self.assertEqual( - len(identity.token_file.read_text(encoding="ascii").split(".")), 3 - ) - self.assertTrue(state.ready()) - - def test_readiness_requires_every_current_regular_output(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, _ = valid_environment(directory) - add_esignet_identity(environment, directory) - clock = FakeClock(1_700_000_000) - config = agent.Config.from_environ(environment) - state = agent.IdentityState(config, clock=clock) - state.rotate() - self.assertTrue(state.ready()) - - config.identities[1].token_file.write_text( - "not-the-current-token\n", encoding="ascii" - ) - self.assertFalse(state.ready()) - state.rotate() - self.assertTrue(state.ready()) - - config.identities[1].token_file.write_bytes(b"") - self.assertFalse(state.ready()) - state.rotate() - self.assertTrue(state.ready()) - - config.identities[1].token_file.unlink() - config.identities[1].token_file.symlink_to(config.identities[0].token_file) - self.assertFalse(state.ready()) - - config.identities[1].token_file.unlink() - state.rotate() - clock.value += config.token_ttl_seconds - self.assertFalse(state.ready()) - - def test_rotation_occurs_only_inside_the_configured_expiry_window(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - environment, _ = valid_environment(Path(temporary_directory)) - environment["WORKLOAD_TOKEN_TTL_SECONDS"] = "120" - environment["WORKLOAD_ROTATE_BEFORE_SECONDS"] = "30" - environment["WORKLOAD_ROTATION_INTERVAL_SECONDS"] = "5" - config = agent.Config.from_environ(environment) - identity = config.identities[0] - clock = FakeClock(1_700_000_000) - state = agent.IdentityState(config, clock=clock) - state.rotate() - first_claims = decode_segment( - identity.token_file.read_text(encoding="ascii").strip().split(".")[1] - ) - - clock.value += 89 - self.assertFalse(state.rotate_if_due()) - clock.value += 1 - self.assertTrue(state.rotate_if_due()) - rotated_claims = decode_segment( - identity.token_file.read_text(encoding="ascii").strip().split(".")[1] - ) - - self.assertNotEqual(first_claims["jti"], rotated_claims["jti"]) - self.assertEqual(rotated_claims["iat"], 1_700_000_090) - self.assertEqual(rotated_claims["exp"], 1_700_000_210) - - def test_health_and_jwks_reflect_all_published_state(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, _ = valid_environment(directory) - add_esignet_identity(environment, directory) - config = agent.Config.from_environ(environment) - state = agent.IdentityState(config, clock=FakeClock(1_700_000_000)) - server = agent.AgentHTTPServer(("127.0.0.1", 0), state) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - base_url = f"http://127.0.0.1:{server.server_address[1]}" - try: - status, health = self._get_json(f"{base_url}/health") - self.assertEqual(status, 503) - self.assertEqual(health, {"status": "not_ready"}) - - status, jwks = self._get_json(f"{base_url}/.well-known/jwks.json") - self.assertEqual(status, 200) - self.assertEqual(len(jwks["keys"]), 2) - self.assertTrue(all("d" not in key for key in jwks["keys"])) - - state.rotate() - status, health = self._get_json(f"{base_url}/health") - self.assertEqual(status, 200) - self.assertEqual(health, {"status": "ready"}) - - config.identities[1].token_file.write_bytes(b"") - status, health = self._get_json(f"{base_url}/health") - self.assertEqual(status, 503) - self.assertEqual(health, {"status": "not_ready"}) - - status, missing = self._get_json(f"{base_url}/other") - self.assertEqual(status, 404) - self.assertEqual(missing, {"error": "not_found"}) - finally: - server.shutdown() - server.server_close() - thread.join(timeout=5) - - def test_configuration_defaults_and_per_identity_ownership_override(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, _ = valid_environment(directory) - del environment["WORKLOAD_TOKEN_UID"] - del environment["WORKLOAD_TOKEN_GID"] - add_esignet_identity(environment, directory) - identities = json.loads(environment["WORKLOAD_IDENTITIES_JSON"]) - identities[1]["token_uid"] = 1001 - identities[1]["token_gid"] = 1001 - environment["WORKLOAD_IDENTITIES_JSON"] = json.dumps(identities) - - config = agent.Config.from_environ(environment) - - self.assertEqual(config.identities[0].token_uid, 65534) - self.assertEqual(config.identities[0].token_gid, 65534) - self.assertEqual(config.identities[1].token_uid, 1001) - self.assertEqual(config.identities[1].token_gid, 1001) - - @unittest.skipUnless(os.geteuid() == 0, "requires root to verify distinct owners") - def test_rotation_applies_distinct_configured_owners(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - environment, _ = valid_environment(directory) - add_esignet_identity(environment, directory) - identities = json.loads(environment["WORKLOAD_IDENTITIES_JSON"]) - identities[0]["token_uid"] = 65534 - identities[0]["token_gid"] = 65534 - identities[1]["token_uid"] = 1001 - identities[1]["token_gid"] = 1001 - environment["WORKLOAD_IDENTITIES_JSON"] = json.dumps(identities) - config = agent.Config.from_environ(environment) - - state = agent.IdentityState(config, clock=FakeClock(1_700_000_000)) - state.rotate() - - self.assertEqual( - ( - config.identities[0].token_file.stat().st_uid, - config.identities[0].token_file.stat().st_gid, - ), - (65534, 65534), - ) - self.assertEqual( - ( - config.identities[1].token_file.stat().st_uid, - config.identities[1].token_file.stat().st_gid, - ), - (1001, 1001), - ) - self.assertTrue(state.ready()) - - def test_configuration_rejects_unsafe_or_ambiguous_values(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - baseline, _ = valid_environment(directory) - jwk = json.loads(baseline["TEST_WORKLOAD_PRIVATE_JWK"]) - mismatched_jwk, _ = private_jwk() - - def identities(change: dict[str, object]) -> str: - document = identity_document(directory) - document.update(change) - return json.dumps([document]) - - cases: dict[str, dict[str, str | None]] = { - "non_loopback_bind": {"WORKLOAD_BIND_HOST": "0.0.0.0"}, - "non_loopback_issuer": {"WORKLOAD_ISSUER": "http://localhost:8090"}, - "https_ip_issuer": {"WORKLOAD_ISSUER": "https://127.0.0.1"}, - "https_localhost_issuer": {"WORKLOAD_ISSUER": "https://localhost"}, - "https_userinfo_issuer": { - "WORKLOAD_ISSUER": "https://user@example.test" - }, - "https_nonstandard_port": { - "WORKLOAD_ISSUER": "https://issuer.example.test:8443" - }, - "malformed_issuer": {"WORKLOAD_ISSUER": "http://["}, - "issuer_path": {"WORKLOAD_ISSUER": "http://127.0.0.1:8090/issuer"}, - "https_issuer_path": { - "WORKLOAD_ISSUER": "https://issuer.example.test/issuer" - }, - "issuer_port_mismatch": {"WORKLOAD_PORT": "8091"}, - "blank_audience": { - "WORKLOAD_IDENTITIES_JSON": identities({"audience": ""}) - }, - "spaced_azp": { - "WORKLOAD_IDENTITIES_JSON": identities({"azp": "cra notary"}) - }, - "duplicate_scope": { - "WORKLOAD_IDENTITIES_JSON": identities( - {"scopes": ["registry:read", "registry:read"]} - ) - }, - "empty_scope_entry": { - "WORKLOAD_IDENTITIES_JSON": identities( - {"scopes": ["registry:read", ""]} - ) - }, - "relative_token_file": { - "WORKLOAD_IDENTITIES_JSON": identities( - {"token_file": "relay-token"} - ) - }, - "missing_token_parent": { - "WORKLOAD_IDENTITIES_JSON": identities( - {"token_file": str(directory / "missing" / "relay-token")} - ) - }, - "ttl_too_long": {"WORKLOAD_TOKEN_TTL_SECONDS": "901"}, - "rotation_too_late": { - "WORKLOAD_TOKEN_TTL_SECONDS": "30", - "WORKLOAD_ROTATE_BEFORE_SECONDS": "26", - }, - "rotation_poll_too_slow": { - "WORKLOAD_ROTATE_BEFORE_SECONDS": "10", - "WORKLOAD_ROTATION_INTERVAL_SECONDS": "11", - }, - "negative_uid": { - "WORKLOAD_IDENTITIES_JSON": identities({"token_uid": -1}) - }, - "boolean_gid": { - "WORKLOAD_IDENTITIES_JSON": identities({"token_gid": True}) - }, - "bad_key_environment_name": { - "WORKLOAD_IDENTITIES_JSON": identities( - {"private_jwk_env": "bad-name"} - ) - }, - "missing_key_environment": {"TEST_WORKLOAD_PRIVATE_JWK": None}, - "wrong_curve": { - "TEST_WORKLOAD_PRIVATE_JWK": json.dumps({**jwk, "crv": "X25519"}) - }, - "unsupported_key_field": { - "TEST_WORKLOAD_PRIVATE_JWK": json.dumps( - {**jwk, "key_ops": ["sign"]} - ) - }, - "mismatched_public_key": { - "TEST_WORKLOAD_PRIVATE_JWK": json.dumps( - {**jwk, "x": mismatched_jwk["x"]} - ) - }, - "duplicate_key_field": { - "TEST_WORKLOAD_PRIVATE_JWK": ( - '{"kty":"OKP","kty":"OKP","crv":"Ed25519",' - '"alg":"EdDSA","kid":"key","x":"a","d":"a"}' - ) - }, - "empty_identity_list": {"WORKLOAD_IDENTITIES_JSON": "[]"}, - "too_many_identities": { - "WORKLOAD_IDENTITIES_JSON": json.dumps( - [identity_document(directory)] * (agent.MAX_IDENTITIES + 1) - ) - }, - "unsupported_identity_field": { - "WORKLOAD_IDENTITIES_JSON": identities({"name": "notary"}) - }, - "duplicate_identity_field": { - "WORKLOAD_IDENTITIES_JSON": ( - '[{"audience":"registry-relay","audience":"other"}]' - ) - }, - } - for name, changes in cases.items(): - with self.subTest(name=name): - environment = dict(baseline) - for key, value in changes.items(): - if value is None: - environment.pop(key, None) - else: - environment[key] = value - with self.assertRaises(agent.ConfigurationError): - agent.Config.from_environ(environment) - - def test_configuration_rejects_duplicate_identity_bindings(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - directory = Path(temporary_directory) - baseline, _ = valid_environment(directory) - second_jwk, _ = private_jwk(kid="second-key") - baseline["SECOND_PRIVATE_JWK"] = json.dumps(second_jwk) - cloned_key = json.loads(baseline["TEST_WORKLOAD_PRIVATE_JWK"]) - cloned_key["kid"] = "cloned-key-id" - baseline["CLONED_PRIVATE_JWK"] = json.dumps(cloned_key) - base = identity_document(directory) - independent = identity_document( - directory, - azp="second-client", - kid_env="SECOND_PRIVATE_JWK", - token_name="second-token", - ) - duplicate_cases = { - "azp": {**independent, "azp": base["azp"]}, - "subject": {**independent, "subject": base["subject"]}, - "kid": {**independent, "private_jwk_env": "TEST_WORKLOAD_PRIVATE_JWK"}, - "public key": { - **independent, - "private_jwk_env": "CLONED_PRIVATE_JWK", - }, - "token_file": {**independent, "token_file": base["token_file"]}, - } - for field, duplicate in duplicate_cases.items(): - with self.subTest(field=field): - environment = dict(baseline) - environment["WORKLOAD_IDENTITIES_JSON"] = json.dumps( - [base, duplicate] - ) - with self.assertRaisesRegex( - agent.ConfigurationError, rf"{field} values must be unique" - ): - agent.Config.from_environ(environment) - - def test_main_does_not_echo_rejected_key_material(self) -> None: - marker = "private-key-material-must-not-appear" - with tempfile.TemporaryDirectory() as temporary_directory: - environment, _ = valid_environment(Path(temporary_directory)) - environment["TEST_WORKLOAD_PRIVATE_JWK"] = marker - stderr = io.StringIO() - with mock.patch.dict(os.environ, environment, clear=True): - with contextlib.redirect_stderr(stderr): - exit_code = agent.main() - - self.assertEqual(exit_code, 2) - self.assertNotIn(marker, stderr.getvalue()) - self.assertEqual( - stderr.getvalue(), - "workload identity agent configuration rejected: " - "private JWK is not valid JSON\n", - ) - - def _get_json(self, url: str) -> tuple[int, dict[str, object]]: - try: - response = urllib.request.urlopen(url, timeout=2) - except urllib.error.HTTPError as error: - response = error - with response: - document = json.loads(response.read()) - return response.status, document - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_release_pins.py b/scripts/test_release_pins.py deleted file mode 100644 index 25042d8..0000000 --- a/scripts/test_release_pins.py +++ /dev/null @@ -1,321 +0,0 @@ -from __future__ import annotations - -import contextlib -import importlib.util -import io -import os -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -ROOT = Path(__file__).resolve().parents[1] -RELAY = "ghcr.io/registrystack/registry-relay@sha256:" + "1" * 64 -NOTARY = "ghcr.io/registrystack/registry-notary@sha256:" + "2" * 64 - - -def load_check_release_pins(): - spec = importlib.util.spec_from_file_location( - "check_release_pins", ROOT / "scripts" / "check-release-pins.py" - ) - if spec is None or spec.loader is None: - raise RuntimeError("could not load check-release-pins.py") - module = importlib.util.module_from_spec(spec) - sys.modules["check_release_pins"] = module - spec.loader.exec_module(module) - return module - - -class ReleasePinTests(unittest.TestCase): - def setUp(self) -> None: - self.environment = mock.patch.dict(os.environ, {}) - self.environment.start() - self.addCleanup(self.environment.stop) - os.environ.pop("REGISTRY_RELAY_IMAGE", None) - os.environ.pop("REGISTRY_NOTARY_IMAGE", None) - - self.module = load_check_release_pins() - self.resolve_tag_implementation = self.module.resolve_tag_commit - self.resolve_tag = mock.patch.object( - self.module, - "resolve_tag_commit", - return_value="a" * 40, - ) - self.resolve_tag.start() - self.addCleanup(self.resolve_tag.stop) - self.directory = tempfile.TemporaryDirectory() - self.root = Path(self.directory.name) - self.module.ROOT = self.root - (self.root / "versions.env").write_text( - f"REGISTRY_RELAY_IMAGE={RELAY}\n" - f"REGISTRY_NOTARY_IMAGE={NOTARY}\n" - "REGISTRYCTL_VERSION=1.0.0\n" - "REGISTRY_STACK_SOURCE_REF=v1.0.0\n" - f"REGISTRY_STACK_SOURCE_COMMIT={'a' * 40}\n", - encoding="utf-8", - ) - - def tearDown(self) -> None: - self.directory.cleanup() - - def test_matching_environment_overrides_validate_committed_pins(self) -> None: - with ( - mock.patch.dict( - os.environ, - {"REGISTRY_RELAY_IMAGE": RELAY, "REGISTRY_NOTARY_IMAGE": NOTARY}, - clear=True, - ), - mock.patch.object( - self.module, - "inspect_tag_digest", - side_effect=["sha256:" + "1" * 64, "sha256:" + "2" * 64], - ) as inspect, - ): - self.assertEqual(self.module.main(["check-release-pins.py", "v1.0.0"]), 0) - - self.assertEqual(inspect.call_count, 2) - - def test_temporary_versions_are_isolated_from_ambient_image_overrides(self) -> None: - self.assertNotIn("REGISTRY_RELAY_IMAGE", os.environ) - self.assertNotIn("REGISTRY_NOTARY_IMAGE", os.environ) - - def test_candidate_test_passes_with_workflow_image_environment(self) -> None: - committed = self.module.read_versions(ROOT / "versions.env") - environment = os.environ.copy() - environment.update( - { - "REGISTRY_RELAY_IMAGE": committed["REGISTRY_RELAY_IMAGE"], - "REGISTRY_NOTARY_IMAGE": committed["REGISTRY_NOTARY_IMAGE"], - } - ) - - result = subprocess.run( - [ - sys.executable, - "-m", - "unittest", - "test_release_pins.ReleasePinTests." - "test_candidate_prerelease_tag_is_accepted", - ], - cwd=ROOT / "scripts", - env=environment, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - - def test_candidate_prerelease_tag_is_accepted(self) -> None: - versions = (self.root / "versions.env").read_text(encoding="utf-8") - (self.root / "versions.env").write_text( - versions.replace( - "REGISTRYCTL_VERSION=1.0.0", - "REGISTRYCTL_VERSION=1.0.0-rc.1", - ), - encoding="utf-8", - ) - versions = (self.root / "versions.env").read_text(encoding="utf-8") - (self.root / "versions.env").write_text( - versions.replace( - "REGISTRY_STACK_SOURCE_REF=v1.0.0", - "REGISTRY_STACK_SOURCE_REF=v1.0.0-rc.1", - ), - encoding="utf-8", - ) - with mock.patch.object( - self.module, - "inspect_tag_digest", - side_effect=["sha256:" + "1" * 64, "sha256:" + "2" * 64], - ): - self.assertEqual( - self.module.main(["check-release-pins.py", "v1.0.0-rc.1"]), - 0, - ) - - def test_registryctl_version_must_match_release_tag(self) -> None: - versions = (self.root / "versions.env").read_text(encoding="utf-8") - (self.root / "versions.env").write_text( - versions.replace( - "REGISTRYCTL_VERSION=1.0.0", - "REGISTRYCTL_VERSION=0.13.0", - ), - encoding="utf-8", - ) - stderr = io.StringIO() - with ( - mock.patch.object(self.module, "inspect_tag_digest") as inspect, - contextlib.redirect_stderr(stderr), - ): - self.assertEqual(self.module.main(["check-release-pins.py", "v1.0.0"]), 1) - - inspect.assert_not_called() - self.assertIn( - "REGISTRYCTL_VERSION from versions.env is 0.13.0, expected 1.0.0", - stderr.getvalue(), - ) - - def test_source_commit_must_match_resolved_release_tag(self) -> None: - self.module.resolve_tag_commit.return_value = "b" * 40 - stderr = io.StringIO() - with ( - mock.patch.object(self.module, "inspect_tag_digest") as inspect, - contextlib.redirect_stderr(stderr), - ): - self.assertEqual(self.module.main(["check-release-pins.py", "v1.0.0"]), 1) - - inspect.assert_not_called() - self.assertIn( - f"REGISTRY_STACK_SOURCE_COMMIT from versions.env is {'a' * 40}", - stderr.getvalue(), - ) - self.assertIn( - f"v1.0.0 resolves to {'b' * 40}", - stderr.getvalue(), - ) - - def test_resolve_tag_commit_prefers_the_peeled_annotated_tag(self) -> None: - direct = "1" * 40 - peeled = "2" * 40 - completed = subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=( - f"{direct}\trefs/tags/v1.0.0\n" - f"{peeled}\trefs/tags/v1.0.0^{{}}\n" - ), - stderr="", - ) - with mock.patch.object(self.module.subprocess, "run", return_value=completed): - self.assertEqual( - self.resolve_tag_implementation("v1.0.0"), - peeled, - ) - - def test_resolve_tag_commit_accepts_a_lightweight_tag(self) -> None: - direct = "3" * 40 - completed = subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=f"{direct}\trefs/tags/v1.0.0\n", - stderr="", - ) - with mock.patch.object(self.module.subprocess, "run", return_value=completed): - self.assertEqual( - self.resolve_tag_implementation("v1.0.0"), - direct, - ) - - def test_source_ref_must_match_the_release(self) -> None: - versions = (self.root / "versions.env").read_text(encoding="utf-8") - (self.root / "versions.env").write_text( - versions.replace( - "REGISTRY_STACK_SOURCE_REF=v1.0.0", - "REGISTRY_STACK_SOURCE_REF=v0.13.0", - ), - encoding="utf-8", - ) - stderr = io.StringIO() - with ( - mock.patch.object(self.module, "inspect_tag_digest") as inspect, - contextlib.redirect_stderr(stderr), - ): - self.assertEqual(self.module.main(["check-release-pins.py", "v1.0.0"]), 1) - - inspect.assert_not_called() - self.assertIn( - "REGISTRY_STACK_SOURCE_REF from versions.env is v0.13.0, expected v1.0.0", - stderr.getvalue(), - ) - - def test_mismatched_environment_override_fails_before_registry_lookup(self) -> None: - stderr = io.StringIO() - with ( - mock.patch.dict( - os.environ, - {"REGISTRY_RELAY_IMAGE": RELAY[:-1] + "3"}, - clear=True, - ), - mock.patch.object(self.module, "inspect_tag_digest") as inspect, - contextlib.redirect_stderr(stderr), - ): - self.assertEqual(self.module.main(["check-release-pins.py", "v1.0.0"]), 1) - - self.assertEqual(inspect.call_count, 0) - self.assertIn( - "REGISTRY_RELAY_IMAGE environment override must match versions.env", - stderr.getvalue(), - ) - - def test_malicious_tags_are_rejected_before_registry_lookup(self) -> None: - malicious_tags = ( - "v1.0.0; echo INJECTED", - "v1.0.0 rc.1", - "v1.0.0'quoted", - 'v1.0.0"quoted', - "v1.0.0$(echo INJECTED)", - "v1.0.0\necho INJECTED", - ) - for tag in malicious_tags: - with self.subTest(tag=tag): - stderr = io.StringIO() - with ( - mock.patch.object(self.module, "inspect_tag_digest") as inspect, - contextlib.redirect_stderr(stderr), - ): - result = self.module.main(["check-release-pins.py", tag]) - - self.assertEqual(result, 2) - inspect.assert_not_called() - self.assertIn("tag must match", stderr.getvalue()) - - def test_release_recipe_dry_runs_do_not_interpolate_tag(self) -> None: - malicious_tags = ( - "v1.0.0; echo INJECTED", - "v1.0.0 rc.1", - "v1.0.0'quoted", - 'v1.0.0"quoted', - "v1.0.0$(echo INJECTED)", - "v1.0.0\necho INJECTED", - ) - recipes = { - "release-pins": 'scripts/check-release-pins.py "$1"', - "review-release": ( - 'scripts/check-release-pins.py "$1"\nscripts/review.sh' - ), - } - for recipe, expected in recipes.items(): - for tag in malicious_tags: - with self.subTest(recipe=recipe, tag=tag): - result = subprocess.run( - ["just", "--dry-run", recipe, tag], - cwd=ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stderr.strip(), expected) - - def test_review_release_recipe_requires_tag(self) -> None: - result = subprocess.run( - ["just", "--dry-run", "review-release"], - cwd=ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("takes 1", result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_runtime_topology.py b/scripts/test_runtime_topology.py new file mode 100644 index 0000000..98d4659 --- /dev/null +++ b/scripts/test_runtime_topology.py @@ -0,0 +1,819 @@ +from __future__ import annotations + +import importlib.util +import os +import shutil +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +SCRIPT = Path(__file__).with_name("check-runtime-topology.py") +SPEC = importlib.util.spec_from_file_location("runtime_topology", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class RuntimeTopologyTests(unittest.TestCase): + def test_local_up_recreates_immutable_runtime_consumers(self) -> None: + justfile = (SCRIPT.parents[1] / "justfile").read_text() + for recipe in ("up: prepare", "up-esignet: prepare"): + start = justfile.index(recipe) + command = justfile[start:].splitlines()[1] + self.assertIn("docker compose", command) + self.assertIn("--force-recreate", command) + + def test_authority_runtime_uses_the_versioned_relayctl_image(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + scripts = root / "scripts" + scripts.mkdir() + runtime_script = scripts / "prepare-authority-runtime.sh" + shutil.copy2(SCRIPT.with_name("prepare-authority-runtime.sh"), runtime_script) + (root / "versions.env").write_text( + "REGISTRY_RELAYCTL_IMAGE=example.invalid/relayctl:v0.20.1\n", + encoding="utf-8", + ) + publisher = scripts / "publish-relay-sources.sh" + publisher.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + publisher.chmod(0o755) + + fake_bin = root / "fake-bin" + fake_bin.mkdir() + fake_uv = fake_bin / "uv" + fake_uv.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_uv.chmod(0o755) + fake_docker = fake_bin / "docker" + fake_docker.write_text( + "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> \"$DOCKER_LOG\"\n" + "previous=\n" + "for argument in \"$@\"; do\n" + " if [ \"$previous\" = --output ]; then\n" + " mkdir -p \"$argument\"\n" + " : > \"$argument/relay-package.json\"\n" + " fi\n" + " previous=$argument\n" + "done\n", + encoding="utf-8", + ) + fake_docker.chmod(0o755) + + for authority in ("cra", "nia", "mosd", "sipf", "nagdi"): + (root / "relays" / authority).mkdir(parents=True) + + docker_log = root / "docker.log" + environment = os.environ.copy() + environment.update( + { + "DOCKER_LOG": str(docker_log), + "PATH": f"{fake_bin}:{environment['PATH']}", + "REGISTRY_RELAYCTL_IMAGE": "wrong.invalid/relayctl:ambient", + } + ) + subprocess.run( + [str(runtime_script)], + cwd=root, + env=environment, + check=True, + capture_output=True, + text=True, + ) + invocations = docker_log.read_text(encoding="utf-8").splitlines() + + self.assertEqual(len(invocations), 5) + self.assertTrue( + all("example.invalid/relayctl:v0.20.1" in line for line in invocations) + ) + self.assertTrue(all("--platform linux/amd64" in line for line in invocations)) + self.assertTrue(all("wrong.invalid" not in line for line in invocations)) + + def test_relay_publication_uses_pinned_linux_sqlite_runtime(self) -> None: + script = SCRIPT.with_name("publish-relay-sources.sh").read_text( + encoding="utf-8" + ) + self.assertIn("python:3.12-slim-trixie@sha256:", script) + self.assertIn("--platform linux/amd64", script) + self.assertIn("--network none", script) + self.assertIn("--read-only", script) + self.assertNotIn("uv run", script) + + def test_retired_surfaces_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "compose.yaml" + path.write_text( + "services:\n old:\n image: postgres:16\n", encoding="utf-8" + ) + self.assertEqual( + MODULE.failures([path]), ["compose.yaml:3: retired database"] + ) + + def test_current_active_topology_is_closed(self) -> None: + self.assertEqual(MODULE.failures(), []) + + def test_local_relays_use_the_v2_runtime_filesystem_contract(self) -> None: + compose_path = SCRIPT.parents[1] / "compose.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + + for authority in ("cra", "nia", "mosd", "sipf", "nagdi"): + service = compose["services"][f"{authority}-relay"] + self.assertEqual(service["user"], "65532:65532") + self.assertEqual(service["cap_drop"], ["ALL"]) + self.assertIn("no-new-privileges:true", service["security_opt"]) + self.assertEqual( + service["command"], + ["serve", "--runtime", f"/etc/relay/{authority}/runtime.yaml"], + ) + volumes = set(service["volumes"]) + self.assertIn(f"{authority}-relay-runtime:/etc/relay/{authority}:ro", volumes) + self.assertIn( + f"{authority}-relay-source:/var/lib/relay/source:ro", + volumes, + ) + self.assertIn( + f"{authority}-relay-audit:/var/lib/relay/audit", + volumes, + ) + self.assertEqual( + service["depends_on"]["relay-audit-init"]["condition"], + "service_completed_successfully", + ) + self.assertEqual( + service["depends_on"][f"{authority}-relay-runtime-stager"]["condition"], + "service_completed_successfully", + ) + self.assertEqual( + service["depends_on"]["relay-issuer-readiness"]["condition"], + "service_completed_successfully", + ) + self.assertEqual( + service["depends_on"][f"{authority}-source-publisher"]["condition"], + "service_completed_successfully", + ) + + publisher = compose["services"][f"{authority}-source-publisher"] + self.assertEqual(publisher["network_mode"], "none") + self.assertEqual(publisher["user"], "0:0") + self.assertTrue(publisher["read_only"]) + self.assertEqual(publisher["cap_drop"], ["ALL"]) + self.assertIn("no-new-privileges:true", publisher["security_opt"]) + self.assertEqual(publisher["restart"], "no") + self.assertEqual(publisher["command"], ["ensure-seeded"]) + publisher_volumes = set(publisher["volumes"]) + self.assertIn( + f"./output/sqlite/relay/{authority}.sqlite:/seed/{authority}.sqlite:ro", + publisher_volumes, + ) + self.assertIn( + f"{authority}-relay-source:/var/lib/relay/source", + publisher_volumes, + ) + for other in {"cra", "nia", "mosd", "sipf", "nagdi"} - {authority}: + self.assertFalse( + any( + f"{other}-relay-source:" in volume + for volume in publisher_volumes + ), + (authority, other), + ) + + initializer = compose["services"]["relay-audit-init"] + self.assertEqual(initializer["network_mode"], "none") + self.assertEqual(initializer["user"], "0:0") + self.assertEqual(initializer["cap_drop"], ["ALL"]) + self.assertEqual(set(initializer["cap_add"]), {"CHOWN", "FOWNER"}) + self.assertIn("no-new-privileges:true", initializer["security_opt"]) + self.assertTrue(initializer["read_only"]) + self.assertEqual(initializer["restart"], "no") + audit_init = initializer["command"][2] + self.assertIn("os.scandir(path)", audit_init) + self.assertIn("stat.S_ISREG", audit_init) + self.assertIn("metadata.st_nlink != 1", audit_init) + self.assertIn("audit\\.jsonl\\.\\d{8}", audit_init) + self.assertIn("follow_symlinks=False", audit_init) + self.assertIn("os.chown(path, 0, 0)", audit_init) + self.assertIn("os.chown(path, target_uid, target_gid)", audit_init) + self.assertIn( + "os.chown(entry.path, target_uid, target_gid, follow_symlinks=False)", + audit_init, + ) + self.assertIn("target_uid = 65532", audit_init) + self.assertIn("target_gid = 65532", audit_init) + for authority in ("cra", "nia", "mosd", "sipf", "nagdi"): + self.assertIn( + f"{authority}-relay-audit:/audit/{authority}", + initializer["volumes"], + ) + + issuer_readiness = compose["services"]["relay-issuer-readiness"] + self.assertEqual(issuer_readiness["user"], "65532:65532") + self.assertTrue(issuer_readiness["read_only"]) + self.assertEqual(issuer_readiness["cap_drop"], ["ALL"]) + self.assertIn("no-new-privileges:true", issuer_readiness["security_opt"]) + self.assertEqual(issuer_readiness["restart"], "no") + self.assertEqual(issuer_readiness["networks"], ["issuer-validation"]) + self.assertEqual( + issuer_readiness["depends_on"], + { + "mint": {"condition": "service_started"}, + "evidence-gateway": {"condition": "service_started"}, + }, + ) + readiness_code = issuer_readiness["command"][2] + self.assertIn("/.well-known/openid-configuration", readiness_code) + self.assertIn("document.get('issuer') == issuer", readiness_code) + self.assertIn("ssl.create_default_context", readiness_code) + self.assertNotIn("token", readiness_code.lower()) + + def test_authority_cells_have_fixed_private_addresses(self) -> None: + compose_path = SCRIPT.parents[1] / "compose.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + expected = { + "mint": "172.29.1.20", + "cra-evidence": "172.29.1.21", + "nia-evidence": "172.29.1.22", + "sro-evidence": "172.29.1.23", + "mosd-programme-evidence": "172.29.1.24", + "sipf-evidence": "172.29.1.25", + "nagdi-evidence": "172.29.1.26", + } + for service_name, address in expected.items(): + self.assertEqual( + compose["services"][service_name]["networks"]["runtime"][ + "ipv4_address" + ], + address, + ) + + caddy = (SCRIPT.parents[1] / "config/evidence/Caddyfile").read_text( + encoding="utf-8" + ) + evidence_routes = { + "cra": "172.29.1.21", + "nia": "172.29.1.22", + "sro": "172.29.1.23", + "mosd-programme": "172.29.1.24", + "sipf": "172.29.1.25", + "nagdi": "172.29.1.26", + } + for authority, address in evidence_routes.items(): + self.assertIn(f"handle_path /evidence/{authority}/*", caddy) + self.assertIn(f"reverse_proxy {address}:8080", caddy) + + def test_local_oidc_issuer_uses_an_isolated_testnet_address(self) -> None: + compose_path = SCRIPT.parents[1] / "compose.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + + issuer_network = compose["networks"]["issuer-validation"] + self.assertTrue(issuer_network["internal"]) + self.assertEqual( + issuer_network["ipam"]["config"], + [{"subnet": "192.0.2.0/26", "ip_range": "192.0.2.0/27"}], + ) + + gateway_networks = compose["services"]["evidence-gateway"]["networks"] + self.assertNotIn( + "mint.solmara.registrystack.org", + gateway_networks["runtime"]["aliases"], + ) + self.assertEqual( + gateway_networks["issuer-validation"], + { + "ipv4_address": "192.0.2.62", + "aliases": ["mint.solmara.registrystack.org"], + }, + ) + + strict_oidc_consumers = { + "cra-relay", + "nia-relay", + "mosd-relay", + "sipf-relay", + "nagdi-relay", + "cra-evidence", + "nia-evidence", + "sro-evidence", + "mosd-programme-evidence", + "sipf-evidence", + "nagdi-evidence", + } + for service_name in strict_oidc_consumers: + self.assertIn( + "issuer-validation", + compose["services"][service_name]["networks"], + service_name, + ) + + def test_local_root_signers_explicitly_accept_read_only_host_bind_owners(self) -> None: + compose = yaml.safe_load((SCRIPT.parents[1] / "compose.yaml").read_text()) + + for provider in ("mint", "cra", "nia", "sro", "mosd-programme", "sipf", "nagdi"): + signer = compose["services"][f"{provider}-signer"] + self.assertEqual(signer["user"], "0:0") + self.assertEqual(signer["cap_drop"], ["ALL"]) + self.assertEqual(signer["cap_add"], ["DAC_OVERRIDE"]) + self.assertEqual(signer["network_mode"], "none") + self.assertIn("--allow-root-bind-owner", signer["command"]) + self.assertTrue(any(volume.endswith("signing.jwk:ro") for volume in signer["volumes"])) + + def test_local_authority_runtimes_use_owned_read_only_secret_volumes(self) -> None: + compose = yaml.safe_load((SCRIPT.parents[1] / "compose.yaml").read_text()) + + services = { + "mint": ("mint", "mint-runtime-secrets"), + "cra-evidence": ("cra", "cra-evidence-runtime-secrets"), + "nia-evidence": ("nia", "nia-evidence-runtime-secrets"), + "sro-evidence": ("sro", "sro-evidence-runtime-secrets"), + "mosd-programme-evidence": ( + "mosd-programme", + "mosd-programme-evidence-runtime-secrets", + ), + "sipf-evidence": ("sipf", "sipf-evidence-runtime-secrets"), + "nagdi-evidence": ("nagdi", "nagdi-evidence-runtime-secrets"), + } + for service_name, (provider, secret_volume) in services.items(): + service = compose["services"][service_name] + self.assertEqual(service["user"], "0:0") + self.assertEqual(service["cap_drop"], ["ALL"]) + self.assertNotIn("cap_add", service) + self.assertTrue(service["read_only"]) + secret_mounts = [ + volume + for volume in service["volumes"] + if "/run/secrets/" in volume + ] + self.assertEqual(len(secret_mounts), 1, service_name) + self.assertTrue( + secret_mounts[0].startswith(f"{secret_volume}:"), service_name + ) + self.assertTrue(secret_mounts[0].endswith(":ro"), service_name) + self.assertNotIn("./runtime/evidence-cells/secrets/", secret_mounts[0]) + self.assertEqual( + service["depends_on"][f"{provider}-secret-stager"]["condition"], + "service_completed_successfully", + ) + self.assertEqual( + service["depends_on"]["authority-audit-init"]["condition"], + "service_completed_successfully", + ) + + def test_local_secret_stagers_are_authority_scoped_and_fail_closed(self) -> None: + compose = yaml.safe_load((SCRIPT.parents[1] / "compose.yaml").read_text()) + expected = { + "mint": {"audit-hmac-key"}, + "cra": { + "audit-hmac-key", + "subject-binding-hmac-key", + "cra-pension-evidence-client-id", + "cra-pension-evidence-client-key", + "cra-citizen-evidence-client-id", + "cra-citizen-evidence-client-key", + }, + "nia": {"audit-hmac-key", "subject-binding-hmac-key"}, + "sro": {"audit-hmac-key", "subject-binding-hmac-key"}, + "mosd-programme": { + "audit-hmac-key", + "subject-binding-hmac-key", + "mosd-child-benefit-evidence-client-id", + "mosd-child-benefit-evidence-client-key", + }, + "sipf": { + "audit-hmac-key", + "subject-binding-hmac-key", + "sipf-pension-evidence-client-id", + "sipf-pension-evidence-client-key", + "sipf-survivor-evidence-client-id", + "sipf-survivor-evidence-client-key", + }, + "nagdi": { + "audit-hmac-key", + "subject-binding-hmac-key", + "nagdi-voucher-evidence-client-id", + "nagdi-voucher-evidence-client-key", + "nagdi-livestock-evidence-client-id", + "nagdi-livestock-evidence-client-key", + }, + } + volumes = { + "mint": "mint-runtime-secrets", + "cra": "cra-evidence-runtime-secrets", + "nia": "nia-evidence-runtime-secrets", + "sro": "sro-evidence-runtime-secrets", + "mosd-programme": "mosd-programme-evidence-runtime-secrets", + "sipf": "sipf-evidence-runtime-secrets", + "nagdi": "nagdi-evidence-runtime-secrets", + } + for provider, names in expected.items(): + stager = compose["services"][f"{provider}-secret-stager"] + self.assertEqual(stager["command"][0], "stage") + self.assertEqual(set(stager["command"][1:]), names) + self.assertEqual(stager["user"], "0:0") + self.assertTrue(stager["read_only"]) + self.assertEqual(stager["cap_drop"], ["ALL"]) + self.assertEqual(set(stager["cap_add"]), {"CHOWN", "DAC_OVERRIDE"}) + self.assertIn("no-new-privileges:true", stager["security_opt"]) + self.assertEqual(stager["network_mode"], "none") + self.assertEqual(stager["restart"], "no") + self.assertEqual( + set(stager["volumes"]), + { + f"./runtime/evidence-cells/secrets/{provider}:/source:ro", + f"{volumes[provider]}:/staged", + }, + ) + + command = compose["services"]["mint-secret-stager"]["entrypoint"][2] + for required in ( + "set(source_entries) != expected", + "stat.S_ISREG", + "metadata.st_nlink != 1", + "metadata.st_size > max_secret_bytes", + "opened.st_size > max_secret_bytes", + "max_secret_bytes = 64 * 1024", + "if copied > max_secret_bytes", + "{0o400, 0o600}", + "os.O_NOFOLLOW", + "os.fchown(destination_fd, 0, 0)", + "os.fchmod(destination_fd, 0o700)", + "os.fchown(destination, 0, 0)", + "os.fchmod(destination, 0o600)", + "os.replace", + ): + self.assertIn(required, command) + self.assertNotIn("print(", command) + + def test_local_secret_stager_copies_without_rendering_secret_values(self) -> None: + compose = yaml.safe_load((SCRIPT.parents[1] / "compose.yaml").read_text()) + command = compose["services"]["mint-secret-stager"]["entrypoint"][2] + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source" + destination = root / "staged" + source.mkdir() + destination.mkdir() + secret = source / "audit-hmac-key" + secret.write_bytes(b"secret-canary\n") + secret.chmod(0o600) + patched = command.replace("'/source'", repr(str(source))).replace( + "'/staged'", repr(str(destination)) + ) + with ( + mock.patch("sys.argv", ["-c", "stage", "audit-hmac-key"]), + mock.patch("os.fchown"), + ): + exec(patched, {}) + + staged = destination / "audit-hmac-key" + self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o700) + self.assertEqual(staged.read_bytes(), b"secret-canary\n") + self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o600) + self.assertEqual(staged.stat().st_nlink, 1) + + peer = root / "peer" + os.link(secret, peer) + with ( + mock.patch("sys.argv", ["-c", "stage", "audit-hmac-key"]), + mock.patch("os.fchown"), + self.assertRaisesRegex( + RuntimeError, "secret source has unsafe metadata" + ), + ): + exec(patched, {}) + + peer.unlink() + secret.write_bytes(b"x" * (64 * 1024 + 1)) + with ( + mock.patch("sys.argv", ["-c", "stage", "audit-hmac-key"]), + mock.patch("os.fchown"), + self.assertRaisesRegex( + RuntimeError, "secret source has unsafe metadata" + ), + ): + exec(patched, {}) + + def test_local_authority_audit_initializer_is_metadata_only_and_isolated(self) -> None: + compose = yaml.safe_load((SCRIPT.parents[1] / "compose.yaml").read_text()) + initializer = compose["services"]["authority-audit-init"] + + self.assertEqual(initializer["network_mode"], "none") + self.assertEqual(initializer["user"], "0:0") + self.assertEqual(initializer["cap_drop"], ["ALL"]) + self.assertEqual(set(initializer["cap_add"]), {"CHOWN", "FOWNER"}) + self.assertIn("no-new-privileges:true", initializer["security_opt"]) + self.assertTrue(initializer["read_only"]) + self.assertEqual(initializer["restart"], "no") + self.assertEqual( + set(initializer["volumes"]), + { + "mint-v2-audit:/audit/mint", + "cra-evidence-audit:/audit/cra", + "nia-evidence-audit:/audit/nia", + "sro-evidence-audit:/audit/sro", + "mosd-evidence-audit:/audit/mosd-programme", + "sipf-evidence-audit:/audit/sipf", + "nagdi-evidence-audit:/audit/nagdi", + }, + ) + command = initializer["command"][2] + self.assertIn("os.lstat(path)", command) + self.assertIn("stat.S_ISDIR", command) + self.assertIn("os.chown(path, 0, 0)", command) + self.assertIn("mint_chain = '/audit/mint/audit'", command) + self.assertIn("os.mkdir(mint_chain, 0o700)", command) + self.assertIn("os.lstat(mint_chain)", command) + self.assertIn("with os.scandir(path) as entries", command) + self.assertIn("entry.stat(follow_symlinks=False)", command) + self.assertIn("metadata.st_nlink != 1", command) + self.assertIn("re.escape(active_name) + r'\\.\\d{8}'", command) + self.assertIn("os.chown(entry.path, 0, 0", command) + self.assertNotIn("os.chown(mint_chain, 65532, 65532)", command) + self.assertNotIn("os.chown(path, 65532, 65532)", command) + + def test_local_authority_audit_initializer_rejects_unsafe_known_files(self) -> None: + compose = yaml.safe_load((SCRIPT.parents[1] / "compose.yaml").read_text()) + command = compose["services"]["authority-audit-init"]["command"][2] + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + providers = ( + "mint", + "cra", + "nia", + "sro", + "mosd-programme", + "sipf", + "nagdi", + ) + paths = [root / provider for provider in providers] + for path in paths: + path.mkdir() + mint_chain = paths[0] / "audit" + patched = command.replace( + "['/audit/mint', '/audit/cra', '/audit/nia', '/audit/sro', '/audit/mosd-programme', '/audit/sipf', '/audit/nagdi']", + repr([str(path) for path in paths]), + ).replace("'/audit/mint/audit'", repr(str(mint_chain))) + for original, replacement in zip( + ( + "/audit/cra", + "/audit/nia", + "/audit/sro", + "/audit/mosd-programme", + "/audit/sipf", + "/audit/nagdi", + ), + paths[1:], + strict=True, + ): + patched = patched.replace(repr(original), repr(str(replacement))) + + target = root / "target" + target.write_text("audit-canary\n", encoding="utf-8") + unsafe = paths[1] / "evidence.jsonl" + unsafe.symlink_to(target) + with ( + mock.patch("os.chown"), + mock.patch("os.chmod"), + self.assertRaisesRegex( + RuntimeError, "audit sink has unsafe metadata" + ), + ): + exec(patched, {}) + + unsafe.unlink() + os.link(target, unsafe) + with ( + mock.patch("os.chown"), + mock.patch("os.chmod"), + self.assertRaisesRegex( + RuntimeError, "audit sink has unsafe metadata" + ), + ): + exec(patched, {}) + + def test_local_transit_signers_are_one_key_one_socket_sidecars(self) -> None: + compose_path = SCRIPT.parents[1] / "compose.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + providers = { + "mint": ("mint", "mint", "solmara-mint"), + "cra": ("cra", "cra-evidence", "solmara-evidence-cra"), + "nia": ("nia", "nia-evidence", "solmara-evidence-nia"), + "sro": ("sro", "sro-evidence", "solmara-evidence-sro"), + "mosd-programme": ( + "mosd-programme", + "mosd-programme-evidence", + "solmara-evidence-mosd-programme", + ), + "sipf": ("sipf", "sipf-evidence", "solmara-evidence-sipf"), + "nagdi": ("nagdi", "nagdi-evidence", "solmara-evidence-nagdi"), + } + for signer_name, (provider, consumer_name, key_name) in providers.items(): + signer = compose["services"][f"{signer_name}-signer"] + self.assertEqual(signer["network_mode"], "none") + self.assertEqual(signer["user"], "0:0") + self.assertEqual(signer["cap_drop"], ["ALL"]) + self.assertIn("no-new-privileges:true", signer["security_opt"]) + key_name_index = signer["command"].index("--key-name") + self.assertEqual(signer["command"][key_name_index + 1], key_name) + signer_volumes = set(signer["volumes"]) + self.assertIn( + f"./config/evidence/local/cells/{provider}/secrets/signing.jwk:/run/secrets/signing.jwk:ro", + signer_volumes, + ) + self.assertIn(f"{signer_name}-transit:/transit", signer_volumes) + + consumer = compose["services"][consumer_name] + socket_destination = ( + "/run/registry-mint" + if provider == "mint" + else f"/run/registry-evidence/{provider}" + ) + self.assertIn( + f"{signer_name}-transit:{socket_destination}:ro", + set(consumer["volumes"]), + ) + self.assertEqual( + consumer["depends_on"][f"{signer_name}-signer"]["condition"], + "service_healthy", + ) + + def test_hosted_relays_keep_runtime_and_source_read_only(self) -> None: + compose_paths = ( + SCRIPT.parents[1] / "compose.coolify.interior.yaml", + SCRIPT.parents[1] / "compose.coolify.social-development.yaml", + SCRIPT.parents[1] / "compose.coolify.labour-pensions.yaml", + SCRIPT.parents[1] / "compose.coolify.agriculture.yaml", + ) + services: dict[str, object] = {} + for compose_path in compose_paths: + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + services.update(compose["services"]) + + for authority in ("cra", "nia", "mosd", "sipf", "nagdi"): + service = services[f"{authority}-relay"] + self.assertEqual( + service["command"], + ["serve", "--runtime", f"/etc/relay/{authority}/runtime.yaml"], + ) + volumes = set(service["volumes"]) + self.assertIn( + f"{authority}-relay-runtime:/etc/relay/{authority}:ro", + volumes, + ) + self.assertIn( + f"{authority}-relay-source:/var/lib/relay/source:ro", + volumes, + ) + self.assertIn( + f"{authority}-relay-audit:/var/lib/relay/audit", + volumes, + ) + + def test_hosted_programme_services_receive_only_their_application_secrets( + self, + ) -> None: + compose_path = SCRIPT.parents[1] / "compose.coolify.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + services = compose["services"] + secret_name = "solmara-demo-client-key" + secret_target = "solmara-evidence-client.jwk" + federator_token = "${CHILD_BENEFIT_FEDERATOR_TOKEN:?required}" + + self.assertEqual( + compose["secrets"][secret_name], + {"environment": "SOLMARA_DEMO_CLIENT_PRIVATE_JWK"}, + ) + for service_name in ("scenario-runner", "child-benefit-federator", "portal"): + service = services[service_name] + self.assertEqual( + service["secrets"], + [{"source": secret_name, "target": secret_target}], + service_name, + ) + self.assertEqual( + service["environment"]["SOLMARA_EVIDENCE_CLIENT_ID"], + "solmara-demo", + service_name, + ) + self.assertEqual( + service["environment"]["SOLMARA_EVIDENCE_CLIENT_KEY"], + f"/run/secrets/{secret_target}", + service_name, + ) + self.assertEqual( + service["environment"]["CHILD_BENEFIT_FEDERATOR_TOKEN"], + federator_token, + service_name, + ) + + self.assertEqual( + services["scenario-runner"]["environment"]["CHILD_BENEFIT_FEDERATOR_URL"], + "${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org}", + ) + self.assertEqual( + services["portal"]["environment"]["CHILD_BENEFIT_FEDERATOR_URL"], + "${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org}", + ) + self.assertEqual( + services["child-benefit-federator"]["environment"][ + "CHILD_BENEFIT_FEDERATOR_HOST" + ], + "0.0.0.0", + ) + self.assertEqual( + services["child-benefit-federator"]["environment"][ + "CHILD_BENEFIT_FEDERATOR_PORT" + ], + "8080", + ) + + for service_name in ("home", "static-metadata"): + service = services[service_name] + self.assertNotIn("secrets", service, service_name) + environment = service.get("environment", {}) + self.assertNotIn("SOLMARA_EVIDENCE_CLIENT_KEY", environment, service_name) + self.assertNotIn("CHILD_BENEFIT_FEDERATOR_TOKEN", environment, service_name) + + def test_hosted_esignet_is_standalone_and_core_portal_owns_login_config( + self, + ) -> None: + esignet_path = SCRIPT.parents[1] / "compose.coolify.esignet.yaml" + esignet = yaml.safe_load(esignet_path.read_text(encoding="utf-8")) + services = esignet["services"] + self.assertEqual( + set(services), + {"esignet-database", "esignet-redis", "esignet", "esignet-ui", "esignet-seed"}, + ) + self.assertNotIn("portal", services) + + core = yaml.safe_load( + (SCRIPT.parents[1] / "compose.coolify.yaml").read_text(encoding="utf-8") + ) + portal = core["services"]["portal"]["environment"] + expected = { + "PORTAL_AUTH_PROVIDER": "${PORTAL_AUTH_PROVIDER:-mock}", + "PORTAL_SECURE_COOKIES": "true", + "PORTAL_ESIGNET_CLIENT_ID": "${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal}", + "PORTAL_ESIGNET_CLIENT_KEY_ID": "${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1}", + "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64": "${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:-}", + "PORTAL_ESIGNET_ISSUER": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}", + "PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT": "${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize", + "PORTAL_ESIGNET_TOKEN_ENDPOINT": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token", + "PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token", + "PORTAL_ESIGNET_USERINFO_ENDPOINT": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oidc/userinfo", + "PORTAL_ESIGNET_REDIRECT_URI": "${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback", + "PORTAL_ESIGNET_SCOPE": "openid profile", + "PORTAL_ESIGNET_SUBJECT_CLAIM": "sub", + } + for key, value in expected.items(): + self.assertEqual(portal[key], value) + + private_key_value = "${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:-}" + self.assertEqual( + { + service_name + for service_name, service in core["services"].items() + if service.get("environment", {}).get( + "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64" + ) + == private_key_value + }, + {"portal"}, + ) + self.assertEqual( + services["esignet-seed"]["environment"]["ESIGNET_CLIENT_PRIVATE_KEY_B64"], + "${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?required}", + ) + self.assertNotIn("NIA_ESIGNET_CLIENT_PRIVATE_JWK", portal) + + def test_bruno_workspace_covers_only_the_eight_governed_v2_lookups(self) -> None: + relay_requests = SCRIPT.parents[1] / "requests/registry-lab/50 - Relay V2" + rendered = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(relay_requests.glob("*.bru")) + ) + expected = { + "/v2/resources/civil-person/lookups/death-by-uin", + "/v2/resources/civil-person/lookups/citizen-link-by-uin", + "/v2/resources/population-person/lookups/esignet-userinfo", + "/v2/resources/beneficiary-enrolment/lookups/by-uin", + "/v2/resources/pension-payment/lookups/by-pensioner-uin", + "/v2/resources/survivor-case/lookups/by-spouse-uin", + "/v2/resources/farmer/lookups/voucher-by-farmer-id", + "/v2/resources/livestock-herd/lookups/movement-by-farmer-id", + } + self.assertEqual(len(list(relay_requests.glob("*.bru"))), len(expected)) + for path in expected: + self.assertIn(path, rendered) + self.assertNotIn("/records", rendered) + self.assertNotIn("/search", rendered) + self.assertNotIn("Data-Purpose", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_signer_public_keys.py b/scripts/test_signer_public_keys.py new file mode 100644 index 0000000..ec2645b --- /dev/null +++ b/scripts/test_signer_public_keys.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("check-signer-public-keys.py") +SPEC = importlib.util.spec_from_file_location("signer_public_keys", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class SignerPublicKeyTests(unittest.TestCase): + def test_current_generated_signers_match(self) -> None: + self.assertEqual(MODULE.failures(), []) + + def test_mismatch_fails_closed_without_private_value_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for provider in MODULE.PROVIDERS: + private = {"alg": "ES256", "crv": "P-256", "kid": provider, "kty": "EC", "x": "x", "y": "y", "d": "private"} + private_path = root / "config/evidence/local/cells" / provider / "secrets/signing.jwk" + private_path.parent.mkdir(parents=True) + private_path.write_text(json.dumps(private), encoding="utf-8") + public_root = root / "runtime/evidence-cells/mint/public-keys" if provider == "mint" else root / "runtime/evidence-cells/cells" / provider / "bundle/public-keys" + public_root.mkdir(parents=True) + public = {name: private[name] for name in MODULE.PUBLIC_MEMBERS} + if provider == "cra": + public["x"] = "different" + (public_root / f"{provider}.jwk.json").write_text(json.dumps(public), encoding="utf-8") + found = MODULE.failures(root) + self.assertEqual(found, ["cra: generated public key does not match operator signer"]) + self.assertNotIn("private", " ".join(found)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_smoke_child_benefit_application.py b/scripts/test_smoke_child_benefit_application.py deleted file mode 100644 index 7001f48..0000000 --- a/scripts/test_smoke_child_benefit_application.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations - -import importlib.util -import unittest -from pathlib import Path -from types import SimpleNamespace -from typing import Any - - -SCRIPT = Path(__file__).with_name("smoke-child-benefit-application.py") -SPEC = importlib.util.spec_from_file_location("smoke_child_benefit_application", SCRIPT) -assert SPEC is not None and SPEC.loader is not None -smoke = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(smoke) - - -class ChildBenefitApplicationSmokeTests(unittest.TestCase): - def test_accepts_source_owned_application_evidence(self) -> None: - claims = list(smoke.EXPECTED_CLAIM_OWNERS) - - failures = smoke.validated_evidence_failures( - 200, - valid_evidence(claims), - smoke.POSITIVE_SUBJECT, - claims, - ) - - self.assertEqual([], failures) - - def test_rejects_obsolete_federation_and_raw_source_fields(self) -> None: - claims = ["birth-is-registered"] - body = valid_evidence(claims) - body["federation_trace"] = [] - body["results"][0]["source_record"] = {"birth_brn": "protected"} - - failures = smoke.validated_evidence_failures( - 200, - body, - smoke.POSITIVE_SUBJECT, - claims, - ) - - self.assertTrue(any("obsolete federation_trace" in failure for failure in failures)) - self.assertTrue(any("raw source" in failure for failure in failures)) - - def test_rejects_wrong_owner_duplicate_result_and_subject_echo(self) -> None: - claims = ["birth-is-registered"] - body = valid_evidence(claims) - body["results"][0]["notary_service_id"] = "child-benefit-federator" - body["results"].append(dict(body["results"][0])) - body["debug_subject"] = smoke.POSITIVE_SUBJECT - - failures = smoke.validated_evidence_failures( - 200, - body, - smoke.POSITIVE_SUBJECT, - claims, - ) - - self.assertTrue(any("exactly the requested predicates" in failure for failure in failures)) - self.assertTrue(any("authority Notary" in failure for failure in failures)) - self.assertTrue(any("raw subject identifier" in failure for failure in failures)) - - def test_application_request_uses_ordinary_json(self) -> None: - claims = ["birth-is-registered"] - captured: dict[str, Any] = {} - original_http_json = smoke.http_json - - def fake_http_json( - method: str, - url: str, - headers: dict[str, str], - body: Any, - ) -> Any: - captured.update(method=method, url=url, headers=headers, body=body) - return SimpleNamespace(status=200, body=valid_evidence(claims)) - - smoke.http_json = fake_http_json - try: - failures = smoke.application_evidence_failures( - "http://application.test", - "test-token", - smoke.POSITIVE_SUBJECT, - claims, - ) - finally: - smoke.http_json = original_http_json - - self.assertEqual([], failures) - self.assertEqual("application/json", captured["headers"]["Accept"]) - self.assertEqual("application/json", captured["body"]["format"]) - self.assertEqual(claims, captured["body"]["claims"]) - self.assertEqual( - {"as_of_date": smoke.CHILD_BENEFIT_AS_OF_DATE}, - captured["body"]["variables"], - ) - - def test_raw_household_denial_requires_stable_purpose_problem(self) -> None: - captured: dict[str, Any] = {} - original_http_json = smoke.http_json - - def fake_http_json( - method: str, - url: str, - headers: dict[str, str], - body: Any, - ) -> Any: - captured.update(method=method, url=url, headers=headers, body=body) - return SimpleNamespace( - status=403, - headers={"content-type": "application/problem+json"}, - body={"code": "pdp.purpose_not_permitted"}, - ) - - smoke.http_json = fake_http_json - try: - failures = smoke.raw_household_denial_failures( - "http://application.test", - "test-token", - smoke.POSITIVE_SUBJECT, - ) - finally: - smoke.http_json = original_http_json - - self.assertEqual([], failures) - self.assertEqual(["household-poverty-score"], captured["body"]["claims"]) - self.assertEqual("value", captured["body"]["disclosure"]) - self.assertEqual("application/json", captured["headers"]["Accept"]) - - -def valid_evidence(claims: list[str]) -> dict[str, Any]: - services = {smoke.EXPECTED_CLAIM_OWNERS[claim_id] for claim_id in claims} - return { - "schema_version": "solmara-child-benefit-evidence/v1", - "orchestration": { - "service_id": "child-benefit-federator", - "decision": "not_composed", - }, - "target": {"type": "Person", "identifier_schemes": ["solmara_uin"]}, - "results": [ - { - "claim_id": claim_id, - "satisfied": True, - "disclosure": "predicate", - "notary_service_id": smoke.EXPECTED_CLAIM_OWNERS[claim_id], - } - for claim_id in claims - ], - "source_trace": [ - { - "service_id": service_id, - "response_summary": {"status": 200}, - } - for service_id in sorted(services) - ], - } - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_smoke_esignet.py b/scripts/test_smoke_esignet.py index b64dc3d..75a7ba2 100644 --- a/scripts/test_smoke_esignet.py +++ b/scripts/test_smoke_esignet.py @@ -12,6 +12,8 @@ from typing import Any from unittest import mock +import yaml + ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "smoke-esignet.py" @@ -81,6 +83,87 @@ def url(self) -> str: class SmokeEsignetTests(unittest.TestCase): + def test_local_and_hosted_profiles_use_the_v020_authenticator_contract(self) -> None: + local = yaml.safe_load((ROOT / "compose.esignet.yaml").read_text()) + hosted = yaml.safe_load((ROOT / "compose.coolify.esignet.yaml").read_text()) + local_env = local["services"]["esignet"]["environment"] + hosted_env = hosted["services"]["esignet"]["environment"] + self.assertEqual( + local["services"]["portal"]["environment"]["PORTAL_ESIGNET_SUBJECT_CLAIM"], + "sub", + ) + + for environment in (local_env, hosted_env): + self.assertIn("REGISTRY_MINT_PRIVATE_JWK", environment) + self.assertNotIn("REGISTRY_MINT_CLIENT_PRIVATE_JWK", environment) + self.assertEqual( + environment["MOSIP_ESIGNET_DATABASE_URL"], + "jdbc:postgresql://esignet-database:5432/mosip_esignet?currentSchema=esignet", + ) + self.assertEqual( + environment["MOSIP_ESIGNET_INTEGRATION_AUTHENTICATOR"], + "RelayAuthenticationService", + ) + self.assertIn("esignet-relay-authenticator.jar", environment["plugin_name_env"]) + self.assertEqual(environment["REGISTRY_ESIGNET_ACCOUNT_CHECK_CLAIMS"], "individualId") + scope_claims = environment["MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS"] + self.assertNotIn("'name'", scope_claims) + for claim in ( + "'given_name'", + "'family_name'", + "'gender'", + "'birthdate'", + "'individual_id'", + ): + self.assertIn(claim, scope_claims) + self.assertEqual( + environment["REGISTRY_RELAY_DEFAULT_CLAIMS"], + "individualId,givenName,familyName,birthdate,gender", + ) + claim_map = json.loads(environment["SPRING_APPLICATION_JSON"]) + self.assertEqual( + claim_map["registry"]["esignet"]["claim-map"], + { + "sub": "$$psut", + "individual_id": "individualId", + "given_name": "givenName", + "family_name": "familyName", + "birthdate": "birthdate", + "gender": "gender", + }, + ) + + self.assertEqual( + local_env["REGISTRY_MINT_TOKEN_ENDPOINT"], + "https://mint.solmara.registrystack.org/token", + ) + self.assertEqual( + hosted_env["REGISTRY_MINT_TOKEN_ENDPOINT"], + "https://mint-authority-cells.solmara.registrystack.org/token", + ) + self.assertEqual( + hosted_env["REGISTRY_RELAY_BASE_URL"], + "https://nia-relay-authority-cells.solmara.registrystack.org", + ) + + mint = yaml.safe_load((ROOT / "evidence" / "mint.yaml").read_text()) + self.assertEqual(mint["clientAssertion"]["algorithms"], ["ES256", "RS256"]) + + def test_browser_smoke_has_only_fixed_sanitized_output(self) -> None: + source = (ROOT / "scripts" / "smoke-esignet-login.mjs").read_text() + self.assertEqual(source.count("console.log("), 1) + self.assertIn("console.log('smoke-esignet-login: PASS')", source) + self.assertIn("getByRole('checkbox', { name: 'voluntary_claims' })", source) + self.assertIn("allClaims.check({ force: true })", source) + self.assertIn("url.pathname.endsWith('/consent')", source) + self.assertIn("timeout: 60_000", source) + self.assertNotIn("error.errorCode", source) + self.assertNotIn("page.title()", source) + + seed_source = (ROOT / "scripts" / "seed-esignet.py").read_text() + self.assertNotIn("Local static OTP", seed_source) + self.assertNotIn("Demo subject available", seed_source) + def test_load_env_file_handles_quoted_values(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / ".env" @@ -93,16 +176,18 @@ def test_load_env_file_handles_quoted_values(self) -> None: ) def test_discovery_requires_root_and_mosip_paths_to_share_issuer(self) -> None: - issuer_doc = {"issuer": "https://esignet.solmara.registrystack.org"} - routes = { - ("GET", "/v1/esignet/oidc/.well-known/openid-configuration"): ( - 200, - issuer_doc, - ), - ("GET", "/.well-known/openid-configuration"): (200, issuer_doc), - ("GET", "/.well-known/oauth-authorization-server"): (200, issuer_doc), - } - with StubServer(routes) as server: + with StubServer({}) as server: + issuer_doc = { + "issuer": server.url, + "token_endpoint": f"{server.url}/v1/esignet/oauth/v2/token", + } + server.routes.update( + { + ("GET", "/v1/esignet/oidc/.well-known/openid-configuration"): (200, issuer_doc), + ("GET", "/.well-known/openid-configuration"): (200, issuer_doc), + ("GET", "/.well-known/oauth-authorization-server"): (200, issuer_doc), + } + ) targets = smoke_esignet.SmokeTargets(server.url, server.url) smoke_esignet.check_esignet_discovery(targets, timeout=2) diff --git a/scripts/test_smoke_hosted.py b/scripts/test_smoke_hosted.py deleted file mode 100644 index d9866a7..0000000 --- a/scripts/test_smoke_hosted.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -import threading -import unittest -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "smoke-hosted.py" -SPEC = importlib.util.spec_from_file_location("smoke_hosted", SCRIPT) -smoke_hosted = importlib.util.module_from_spec(SPEC) -assert SPEC and SPEC.loader -sys.modules["smoke_hosted"] = smoke_hosted -SPEC.loader.exec_module(smoke_hosted) - - -class StubServer: - def __init__(self, routes: dict[tuple[str, str], Any]) -> None: - self.routes = routes - self.server: ThreadingHTTPServer | None = None - self.thread: threading.Thread | None = None - - def __enter__(self) -> "StubServer": - outer = self - - class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - self._handle("GET") - - def do_POST(self) -> None: - self._handle("POST") - - def _handle(self, method: str) -> None: - route = outer.routes.get((method, self.path)) - if route is None: - self.send_error(HTTPStatus.NOT_FOUND) - return - status, payload, *header_items = route - data = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - for key, value in dict(header_items[0] if header_items else {}).items(): - self.send_header(key, value) - self.end_headers() - self.wfile.write(data) - - def log_message(self, fmt: str, *args: object) -> None: - return - - self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - return self - - def __exit__(self, *args: object) -> None: - assert self.server is not None - self.server.shutdown() - self.server.server_close() - assert self.thread is not None - self.thread.join(timeout=5) - - @property - def url(self) -> str: - assert self.server is not None - host, port = self.server.server_address - return f"http://{host}:{port}" - - -def hosted_routes() -> dict[tuple[str, str], Any]: - return { - ("GET", "/api/scenarios"): ( - 200, - { - "default_scenario_id": "birth-to-child-benefit", - "scenarios": [ - {"id": "birth-to-child-benefit"}, - {"id": "death-to-pension-survivor"}, - {"id": "farmer-climate-smart-voucher"}, - {"id": "citizen-self-service"}, - ], - }, - ), - ("POST", "/api/scenarios/birth-to-child-benefit/steps/positive/run"): ( - 200, - { - "result": { - "response_source": { - "status": 200, - "body": { - "orchestration": { - "service_id": "child-benefit-federator", - "decision": "not_composed", - }, - "results": [ - { - "claim_id": "birth-is-registered", - "satisfied": True, - "notary_service_id": "cra-notary", - }, - { - "claim_id": "population-record-active", - "satisfied": True, - "notary_service_id": "nia-notary", - }, - { - "claim_id": "child-age-under-5", - "satisfied": True, - "notary_service_id": "cra-notary", - }, - { - "claim_id": "household-below-poverty-threshold", - "satisfied": True, - "notary_service_id": "sro-notary", - }, - { - "claim_id": "not-already-enrolled", - "satisfied": True, - "notary_service_id": "programme-notary", - }, - ], - "source_trace": [ - {"service_id": "cra-notary"}, - {"service_id": "nia-notary"}, - {"service_id": "sro-notary"}, - {"service_id": "programme-notary"}, - ], - }, - }, - } - }, - ), - ("POST", "/api/scenarios/birth-to-child-benefit/steps/purpose-denial/run"): ( - 200, - { - "result": { - "response_source": { - "status": 403, - "body": {"code": "pdp.purpose_not_permitted"}, - } - } - }, - ), - } - - -class HostedSmokeTests(unittest.TestCase): - def test_default_targets_use_public_solmara_domains(self) -> None: - targets = smoke_hosted.default_targets("https://solmara.registrystack.org/") - self.assertEqual(targets.home_url, "https://solmara.registrystack.org") - self.assertEqual(targets.portal_url, "https://portal.solmara.registrystack.org") - self.assertEqual( - targets.esignet_url, "https://esignet.solmara.registrystack.org" - ) - self.assertEqual( - targets.esignet_ui_url, "https://esignet-ui.solmara.registrystack.org" - ) - self.assertEqual(targets.wallet_url, "https://wallet.solmara.registrystack.org") - self.assertIn( - "https://cra-relay.solmara.registrystack.org", - {relay.base_url for relay in targets.relays}, - ) - authority_urls = { - "https://cra-notary.solmara.registrystack.org", - "https://nia-notary.solmara.registrystack.org", - "https://sro-notary.solmara.registrystack.org", - "https://programme-notary.solmara.registrystack.org", - "https://sipf-notary.solmara.registrystack.org", - "https://nagdi-notary.solmara.registrystack.org", - } - self.assertEqual( - authority_urls, - {notary.base_url for notary in targets.notaries}, - ) - self.assertEqual( - {"https://child-benefit-federator.solmara.registrystack.org"}, - {application.base_url for application in targets.applications}, - ) - - def test_hosted_env_overrides_public_service_urls_without_tokens(self) -> None: - targets = smoke_hosted.default_targets("solmara.registrystack.org") - env = smoke_hosted.hosted_env( - {"CHILD_BENEFIT_FEDERATOR_TOKEN": "keep-local"}, targets - ) - self.assertEqual( - env["CHILD_BENEFIT_FEDERATOR_URL"], - "https://child-benefit-federator.solmara.registrystack.org", - ) - self.assertEqual( - env["CRA_NOTARY_URL"], "https://cra-notary.solmara.registrystack.org" - ) - self.assertEqual( - env["NIA_NOTARY_URL"], "https://nia-notary.solmara.registrystack.org" - ) - self.assertEqual( - env["SRO_NOTARY_URL"], "https://sro-notary.solmara.registrystack.org" - ) - self.assertEqual( - env["PROGRAMME_NOTARY_URL"], - "https://programme-notary.solmara.registrystack.org", - ) - self.assertEqual( - env["SIPF_NOTARY_URL"], "https://sipf-notary.solmara.registrystack.org" - ) - self.assertEqual( - env["NAGDI_NOTARY_URL"], - "https://nagdi-notary.solmara.registrystack.org", - ) - self.assertEqual( - env["SOLMARA_CRA_RELAY_URL"], "https://cra-relay.solmara.registrystack.org" - ) - self.assertEqual( - env["SOLMARA_PORTAL_URL"], "https://portal.solmara.registrystack.org" - ) - self.assertEqual( - env["SOLMARA_ESIGNET_PUBLIC_BASE_URL"], - "https://esignet.solmara.registrystack.org", - ) - self.assertEqual( - env["SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL"], - "https://esignet-ui.solmara.registrystack.org", - ) - self.assertEqual( - env["SOLMARA_WALLET_URL"], "https://wallet.solmara.registrystack.org" - ) - self.assertEqual(env["SOLMARA_PORTAL_EXPECT_AUTH_REQUIRED"], "1") - self.assertEqual(env["CHILD_BENEFIT_FEDERATOR_TOKEN"], "keep-local") - - def test_normalize_argv_accepts_just_separator(self) -> None: - self.assertEqual( - smoke_hosted.normalize_argv(["--", "--browser"]), ["--browser"] - ) - self.assertEqual(smoke_hosted.normalize_argv(["--browser"]), ["--browser"]) - - def test_esignet_smoke_uses_only_supported_discovery_arguments(self) -> None: - targets = smoke_hosted.default_targets("solmara.registrystack.org") - - command = smoke_hosted.esignet_smoke_command(targets) - - self.assertNotIn("--relay-url", command) - self.assertEqual( - command[-4:], - [ - "--esignet-url", - "https://esignet.solmara.registrystack.org", - "--esignet-ui-url", - "https://esignet-ui.solmara.registrystack.org", - ], - ) - - def test_home_demo_accepts_expected_scenario_flow(self) -> None: - with StubServer(hosted_routes()) as server: - smoke_hosted.check_home_demo(server.url, timeout=2) - - def test_home_demo_rejects_composed_child_benefit_decision(self) -> None: - routes = hosted_routes() - positive = routes[ - ("POST", "/api/scenarios/birth-to-child-benefit/steps/positive/run") - ][1] - positive["result"]["response_source"]["body"]["orchestration"]["decision"] = ( - "eligible" - ) - with StubServer(routes) as server: - with self.assertRaises(smoke_hosted.SmokeFailure): - smoke_hosted.check_home_demo(server.url, timeout=2) - - def test_home_demo_requires_stable_denial_code(self) -> None: - routes = hosted_routes() - routes[ - ("POST", "/api/scenarios/birth-to-child-benefit/steps/purpose-denial/run") - ] = ( - 200, - {"result": {"response_source": {"status": 403, "body": {}}}}, - ) - with StubServer(routes) as server: - with self.assertRaises(smoke_hosted.SmokeFailure): - smoke_hosted.check_home_demo(server.url, timeout=2) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_smoke_hosted_provisioner_image.py b/scripts/test_smoke_hosted_provisioner_image.py new file mode 100644 index 0000000..a5ad19f --- /dev/null +++ b/scripts/test_smoke_hosted_provisioner_image.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import importlib.util +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest import mock + +SCRIPT = Path(__file__).with_name("smoke-hosted-provisioner-image.py") +SPEC = importlib.util.spec_from_file_location("smoke_hosted_provisioner_image", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class HostedProvisionerImageSmokeTests(unittest.TestCase): + def test_smoke_uses_the_provisioner_owned_bind_hosts(self) -> None: + provisioner = MODULE._provisioner_module() + self.assertEqual(provisioner.EXPECTED_BIND_HOST["cra"], "172.29.2.21") + self.assertEqual(provisioner.EXPECTED_BIND_HOST["mint"], "172.29.1.20") + + def test_container_uses_the_same_bounded_volume_initializer_identity_as_hosted( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + inputs = root / "inputs" + inputs.mkdir() + (inputs / "secret").write_text("value", encoding="utf-8") + output = root / "output" + output.mkdir() + with mock.patch.object(MODULE.subprocess, "run") as run: + MODULE._run( + "image@sha256:" + "a" * 64, + ["provision"], + [ + (inputs, "/tmp/solmara-provisioning", True), + (output, "/provisioned/runtime", False), + ], + ) + self.assertEqual(run.call_count, 2) + up = run.call_args_list[0] + self.assertIn("compose", up.args[0]) + self.assertIn("--exit-code-from", up.args[0]) + self.assertNotIn("value", " ".join(up.args[0])) + self.assertEqual(up.kwargs["env"]["SOLMARA_SMOKE_SECRET_0"], "value") + down = run.call_args_list[1] + self.assertIn("down", down.args[0]) + self.assertIn("--volumes", down.args[0]) + + def test_success_is_sanitized(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + output, error = StringIO(), StringIO() + with ( + mock.patch.object( + os.sys, + "argv", + [ + SCRIPT.name, + "--image", + "image@sha256:" + "a" * 64, + "--state-root", + temporary, + ], + ), + mock.patch.object(MODULE, "smoke") as smoke, + redirect_stdout(output), + redirect_stderr(error), + ): + self.assertEqual(MODULE.main(), 0) + smoke.assert_called_once() + self.assertEqual( + output.getvalue().strip(), "hosted provisioner image smoke passed" + ) + self.assertEqual(error.getvalue(), "") + + def test_failure_redacts_dependency_details(self) -> None: + canary = "PRIVATE-HOSTED-SMOKE-CANARY" + output, error = StringIO(), StringIO() + with ( + mock.patch.object(os.sys, "argv", [SCRIPT.name, "--image", "invalid"]), + mock.patch.object(MODULE, "smoke", side_effect=RuntimeError(canary)), + redirect_stdout(output), + redirect_stderr(error), + ): + self.assertEqual(MODULE.main(), 1) + self.assertEqual(output.getvalue(), "") + self.assertEqual(error.getvalue().strip(), MODULE.GENERIC_ERROR) + self.assertNotIn(canary, error.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_smoke_nia_attribute_release.py b/scripts/test_smoke_nia_attribute_release.py deleted file mode 100644 index 74804a8..0000000 --- a/scripts/test_smoke_nia_attribute_release.py +++ /dev/null @@ -1,109 +0,0 @@ -from __future__ import annotations - -import importlib.util -import io -import json -import os -import unittest -from contextlib import redirect_stderr -from pathlib import Path -from unittest import mock - - -ROOT = Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location( - "smoke_nia_attribute_release", - ROOT / "scripts" / "smoke-nia-attribute-release.py", -) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class FakeResponse: - def __init__(self, body: dict[str, object]) -> None: - self.status = 200 - self.body = body - - def __enter__(self) -> FakeResponse: - return self - - def __exit__(self, *_args: object) -> None: - return None - - def read(self) -> bytes: - return json.dumps(self.body).encode("utf-8") - - -class NiaAttributeReleaseSmokeTests(unittest.TestCase): - def test_live_request_uses_the_minimized_profile_contract(self) -> None: - response = FakeResponse( - { - "profile_id": "solmara-nia-userinfo", - "profile_version": "v1", - "claims": { - "individual_id": MODULE.SUBJECT, - "name": "Elena Dela Cruz", - }, - } - ) - with ( - mock.patch.dict( - os.environ, - {"NIA_ESIGNET_RELAY_TOKEN": "runtime-token"}, - clear=True, - ), - mock.patch.object( - MODULE.urllib.request, - "urlopen", - return_value=response, - ) as urlopen, - ): - self.assertEqual(MODULE.main(), 0) - - request = urlopen.call_args.args[0] - self.assertEqual(request.method, "POST") - self.assertEqual(request.get_header("Authorization"), "Bearer runtime-token") - self.assertEqual(request.get_header("Data-purpose"), MODULE.PURPOSE) - self.assertEqual( - json.loads(request.data), - { - "subject": { - "id_type": "national_id", - "value": MODULE.SUBJECT, - } - }, - ) - - def test_source_metadata_is_rejected(self) -> None: - stderr = io.StringIO() - with ( - mock.patch.dict( - os.environ, - {"NIA_ESIGNET_RELAY_TOKEN": "runtime-token"}, - clear=True, - ), - mock.patch.object( - MODULE.urllib.request, - "urlopen", - return_value=FakeResponse( - { - "profile_id": "solmara-nia-userinfo", - "profile_version": "v1", - "claims": { - "individual_id": MODULE.SUBJECT, - "name": "Elena Dela Cruz", - }, - "source": {"dataset": "population"}, - } - ), - ), - redirect_stderr(stderr), - ): - self.assertEqual(MODULE.main(), 1) - - self.assertIn("response disclosed source metadata", stderr.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_smoke_programme_acceptance.py b/scripts/test_smoke_programme_acceptance.py new file mode 100644 index 0000000..a4950ee --- /dev/null +++ b/scripts/test_smoke_programme_acceptance.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import re +import sys +import threading +import unittest +from contextlib import redirect_stderr, redirect_stdout +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "smoke-programme-acceptance.py" +SPEC = importlib.util.spec_from_file_location("smoke_programme_acceptance", SCRIPT) +smoke = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules["smoke_programme_acceptance"] = smoke +SPEC.loader.exec_module(smoke) + + +class StubServer: + def __init__(self, routes: dict[tuple[str, str], tuple[int, Any]]) -> None: + self.routes = routes + self.server: ThreadingHTTPServer | None = None + self.thread: threading.Thread | None = None + + def __enter__(self) -> "StubServer": + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.handle_request("GET") + + def do_POST(self) -> None: + self.handle_request("POST") + + def handle_request(self, method: str) -> None: + status, payload = outer.routes.get( + (method, self.path), + (404, {"canary": "private-not-found-detail"}), + ) + encoded = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, fmt: str, *args: object) -> None: + return + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + return self + + def __exit__(self, *args: object) -> None: + assert self.server is not None and self.thread is not None + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + @property + def url(self) -> str: + assert self.server is not None + host, port = self.server.server_address + return f"http://{host}:{port}" + + +def scenario_path(scenario: str, step: str) -> str: + return f"/v1/scenarios/{scenario}/steps/{step}/run" + + +def success_payload( + scenario: str, + claims: dict[str, str], + services: dict[str, tuple[str, str]], + *, + derived_decisions: dict[str, Any] | None = None, +) -> dict[str, Any]: + presentation_by_issuer = { + issuer: { + "authority": f"Public authority {index}", + "issuer": issuer, + "provider": f"provider-{index}", + "source": source, + } + for index, (issuer, source) in enumerate(services.values(), start=1) + } + result: dict[str, Any] = { + "response_source": {"status": 200, "code": "ok"}, + "results": [ + { + "claim_id": claim, + "concept_id": f"https://id.registrystack.org/solmara/concept/{claim}", + "satisfied": True, + "value": True, + "presentation": presentation_by_issuer[issuer], + } + for claim, issuer in claims.items() + ], + "presentations": [ + presentation_by_issuer[issuer] for issuer, _ in services.values() + ], + "source_trace": [ + { + "authority": f"Public authority {index}", + "service_id": service, + "issuer": issuer, + "provider": f"provider-{index}", + "source": source, + "status": 200, + } + for index, (service, (issuer, source)) in enumerate(services.items(), start=1) + ], + } + if derived_decisions is not None: + result["derived_decisions"] = derived_decisions + return { + "schema_version": "solmara-scenario-runner/v1", + "scenario_id": scenario, + "result": result, + } + + +def refusal_payload(scenario: str) -> dict[str, Any]: + return { + "schema_version": "solmara-scenario-runner/v1", + "scenario_id": scenario, + "result": { + "response_source": {"status": 403, "code": "request_refused"}, + "results": [], + "presentations": [], + "source_trace": [], + }, + } + + +def passing_routes() -> dict[tuple[str, str], tuple[int, Any]]: + child_services = { + "cra-evidence": (smoke.CRA, "immutable extract"), + "nia-evidence": (smoke.NIA, "immutable extract"), + "sro-evidence": (smoke.SRO, "immutable extract"), + "mosd-programme-evidence": (smoke.MOSD, "Relay lookup"), + } + pension_services = { + "cra-evidence": (smoke.CRA, "Relay lookup"), + "sipf-evidence": (smoke.SIPF, "Relay lookup"), + } + sipf_services = {"sipf-evidence": (smoke.SIPF, "Relay lookup")} + nagdi_services = {"nagdi-evidence": (smoke.NAGDI, "Relay lookup")} + return { + ("GET", "/health"): (200, {"service": "scenario-runner", "status": "ok"}), + ("POST", scenario_path("birth-to-child-benefit", "positive")): ( + 200, + success_payload("birth-to-child-benefit", smoke.CHILD_CLAIMS, child_services), + ), + ("POST", scenario_path("death-to-pension-survivor", "stop-payment")): ( + 200, + success_payload( + "death-to-pension-survivor", + smoke.PENSION_CLAIMS, + pension_services, + derived_decisions={ + "pension-payment-should-stop": True, + "owner": "pension-review-application", + }, + ), + ), + ("POST", scenario_path("death-to-pension-survivor", "survivor-benefit")): ( + 200, + success_payload( + "death-to-pension-survivor", + smoke.SURVIVOR_CLAIMS, + sipf_services, + ), + ), + ("POST", scenario_path("farmer-climate-smart-voucher", "positive")): ( + 200, + success_payload( + "farmer-climate-smart-voucher", + smoke.VOUCHER_CLAIMS, + nagdi_services, + ), + ), + ("POST", scenario_path("farmer-climate-smart-voucher", "movement-permit")): ( + 200, + success_payload( + "farmer-climate-smart-voucher", + smoke.LIVESTOCK_CLAIMS, + nagdi_services, + ), + ), + ("POST", scenario_path("birth-to-child-benefit", "purpose-denial")): ( + 200, + refusal_payload("birth-to-child-benefit"), + ), + ("POST", scenario_path("death-to-pension-survivor", "cause-of-death-denial")): ( + 200, + refusal_payload("death-to-pension-survivor"), + ), + ("POST", scenario_path("farmer-climate-smart-voucher", "purpose-denial")): ( + 200, + refusal_payload("farmer-climate-smart-voucher"), + ), + ("GET", "/v1/claims"): ( + 401, + { + "type": "https://id.registrystack.org/problems/solmara/authentication_required", + "title": "Authentication Required", + "status": 401, + "code": "authentication_required", + "detail": "A valid local application token is required.", + }, + ), + } + + +class ProgrammeAcceptanceSmokeTests(unittest.TestCase): + def run_main(self, routes: dict[tuple[str, str], tuple[int, Any]]) -> tuple[int, str, str]: + with StubServer(routes) as server: + previous_runner = smoke.RUNNER_URL + previous_federator = smoke.FEDERATOR_URL + previous_timeout = smoke.READY_TIMEOUT_SECONDS + smoke.RUNNER_URL = server.url + smoke.FEDERATOR_URL = server.url + smoke.READY_TIMEOUT_SECONDS = 0.2 + stdout = io.StringIO() + stderr = io.StringIO() + try: + with redirect_stdout(stdout), redirect_stderr(stderr): + result = smoke.main() + finally: + smoke.RUNNER_URL = previous_runner + smoke.FEDERATOR_URL = previous_federator + smoke.READY_TIMEOUT_SECONDS = previous_timeout + return result, stdout.getvalue(), stderr.getvalue() + + def test_all_programme_and_generic_refusal_checks_pass(self) -> None: + status, stdout, stderr = self.run_main(passing_routes()) + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + lines = stdout.splitlines() + self.assertEqual(len(lines), 10) + self.assertTrue( + all(re.fullmatch(r"programme-acceptance: PASS [a-z0-9-]+", line) for line in lines) + ) + + def test_failure_output_never_echoes_response_values(self) -> None: + routes = passing_routes() + routes[("POST", scenario_path("birth-to-child-benefit", "positive"))] = ( + 500, + { + "selector": "PRIVATE-SELECTOR-CANARY", + "token": "PRIVATE-TOKEN-CANARY", + "source_row": "PRIVATE-ROW-CANARY", + "detail": "PRIVATE-DEPENDENCY-CANARY", + }, + ) + status, stdout, stderr = self.run_main(routes) + self.assertEqual(status, 1) + self.assertEqual(stderr, "") + self.assertIn( + "programme-acceptance: FAIL child-benefit-four-authorities-five-concepts", + stdout, + ) + self.assertNotIn("CANARY", stdout) + self.assertTrue( + all( + re.fullmatch(r"programme-acceptance: (?:PASS|FAIL) [a-z0-9-]+", line) + for line in stdout.splitlines() + ) + ) + + def test_child_acceptance_requires_four_distinct_expected_issuers(self) -> None: + routes = passing_routes() + payload = routes[("POST", scenario_path("birth-to-child-benefit", "positive"))][1] + payload["result"]["source_trace"][0]["issuer"] = smoke.NIA + status, stdout, _ = self.run_main(routes) + self.assertEqual(status, 1) + self.assertIn("FAIL child-benefit-four-authorities-five-concepts", stdout) + + def test_pension_acceptance_refuses_cause_of_death_disclosure(self) -> None: + routes = passing_routes() + payload = routes[("POST", scenario_path("death-to-pension-survivor", "survivor-benefit"))][1] + payload["result"]["cause_of_death"] = "PRIVATE-CAUSE-CANARY" + status, stdout, _ = self.run_main(routes) + self.assertEqual(status, 1) + self.assertIn("FAIL pension-survivor-minimized-assertion", stdout) + self.assertNotIn("PRIVATE-CAUSE-CANARY", stdout) + + def test_generic_refusal_must_not_release_results(self) -> None: + routes = passing_routes() + payload = routes[("POST", scenario_path("farmer-climate-smart-voucher", "purpose-denial"))][1] + payload["result"]["results"] = [{"claim_id": "private", "value": True}] + status, stdout, _ = self.run_main(routes) + self.assertEqual(status, 1) + self.assertIn("FAIL agriculture-wrong-purpose-generic-refusal", stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_smoke_relay_sources.py b/scripts/test_smoke_relay_sources.py deleted file mode 100644 index 4087928..0000000 --- a/scripts/test_smoke_relay_sources.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import importlib.util -import sys -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "smoke-relay-sources.py" -SPEC = importlib.util.spec_from_file_location("smoke_relay_sources", SCRIPT) -smoke_relay_sources = importlib.util.module_from_spec(SPEC) -assert SPEC and SPEC.loader -sys.modules["smoke_relay_sources"] = smoke_relay_sources -SPEC.loader.exec_module(smoke_relay_sources) - - -class RelaySourceSmokeTests(unittest.TestCase): - def test_accepts_the_missing_credential_problem_for_an_anonymous_request( - self, - ) -> None: - response = ( - 401, - {"content-type": "application/problem+json"}, - {"code": "auth.missing_credential"}, - "", - ) - - self.assertIsNone( - smoke_relay_sources.validate_unauthenticated_denial(response) - ) - - def test_rejects_the_invalid_credential_problem_when_no_token_was_sent( - self, - ) -> None: - response = ( - 401, - {"content-type": "application/problem+json"}, - {"code": "auth.invalid_credentials"}, - "", - ) - - self.assertEqual( - smoke_relay_sources.validate_unauthenticated_denial(response), - "expected auth.missing_credential, got 'auth.invalid_credentials'", - ) - - def test_rejects_source_shaped_data_in_the_denial(self) -> None: - response = ( - 401, - {"content-type": "application/problem+json; charset=utf-8"}, - { - "code": "auth.missing_credential", - "source_record": {"uin": "must-not-be-returned"}, - }, - "", - ) - - self.assertEqual( - smoke_relay_sources.validate_unauthenticated_denial(response), - "unauthenticated denial included source-shaped data", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/versions.env b/versions.env index 8f9f620..fd381c2 100644 --- a/versions.env +++ b/versions.env @@ -1,12 +1,37 @@ -REGISTRY_RELAY_IMAGE=ghcr.io/registrystack/registry-relay@sha256:71082176f00107e420ffe79471f0bd3375c3dd1ae1c3c8c3a24100c602fa82b7 -REGISTRY_NOTARY_IMAGE=ghcr.io/registrystack/registry-notary@sha256:1cce41247a18f70cb7ff4276bdeb5a16c1e641a9ed7951be8f612daaf35ab87b -REGISTRYCTL_VERSION=0.15.2 -REGISTRY_STACK_SOURCE_REF=v0.15.2 -REGISTRY_STACK_SOURCE_COMMIT=835b824d862e4ffbc1b96307d9d717e41c8e268d -SOLMARA_RELAY_DEV_IMAGE=solmara-lab-registry-relay:source -SOLMARA_RELAY_BUNDLE_SEQUENCE=2 -REGISTRY_RELAY_STATE_EPOCH=v015 -POSTGRES_IMAGE=postgres:16-alpine -STATIC_METADATA_IMAGE=python:3.12-alpine +# Registry Stack v0.21.0 is the earliest release that publishes the Relay, +# Evidence, and Mint images required by the authority-cell reset. Solmara +# pins v0.22.0, the current release on that line. +REGISTRY_STACK_REQUIRED_VERSION=0.22.0 +REGISTRY_STACK_RELEASE_RELAY_DIGEST=6d58ea62b78db451409e77a96ed45c7f82a72cc17ebc9d4eb65230319fe13d83 +REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL=https://github.com/registrystack/registry-stack/releases/download/v0.22.0/relayctl-v0.22.0-linux-amd64 +REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256=1354946ffca568d0a95e32de37fb7b45805c3756f21217aca44d1d34babbaebc + +# Exact release source identity and local thin-image tags. Every Registry Stack +# executable is fetched from the published release or the digest-pinned OCI. +REGISTRY_STACK_SOURCE_REF=v0.22.0 +REGISTRY_STACK_SOURCE_COMMIT=d9a278a55fa3cd275e391cdf2d94dae342c65edc +REGISTRY_RELAY_IMAGE=ghcr.io/registrystack/relay@sha256:6d58ea62b78db451409e77a96ed45c7f82a72cc17ebc9d4eb65230319fe13d83 +REGISTRY_RELAYCTL_IMAGE=solmara-lab-relayctl:v0.22.0 +SOLMARA_EVIDENCE_IMAGE=ghcr.io/registrystack/evidence@sha256:175cb81e12125bbb17425bdc2df34b109b0da95b3b1df2d6cb2bfa3223f2c577 +SOLMARA_MINT_IMAGE=ghcr.io/registrystack/mint@sha256:a4aaf418493ce2d14994082b93fb1ccb32b1351d71b3850a694045ad00174e54 + +# Third-party runtime images are immutable. Refresh intentionally, never by tag. +PYTHON_STATIC_IMAGE=python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +NODE_BUILD_IMAGE=node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 +UV_BUILD_IMAGE=ghcr.io/astral-sh/uv@sha256:440fd6477af86a2f1b38080c539f1672cd22acb1b1a47e321dba5158ab08864d VOLUME_INIT_IMAGE=busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662 +EVIDENCE_GATEWAY_IMAGE=docker.io/library/caddy@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d +ESIGNET_REDIS_IMAGE=redis@sha256:b99ffd0554dc8d300230b9d1b9f2a129a6abf595bf8589883beb980ed1feae3d +ESIGNET_BASE_IMAGE=mosipid/esignet-with-plugins@sha256:47fffdb5a45198b29885a533841129877a7385a12bcb6020c0f6d4335477be39 +ESIGNET_UI_IMAGE=mosipid/oidc-ui@sha256:8a2a6839b4e22be6c967dabc6308190c165c54604a778c2d9b1aae8091db93e7 +ESIGNET_POSTGRES_IMAGE=postgres@sha256:9479eac93922431e8a512b016d7362ae264f4ba139f840891b51f02931e450db +ESIGNET_AUTHENTICATOR_VERSION=0.2.0 +ESIGNET_AUTHENTICATOR_RELEASE_URL=https://github.com/jeremi/esignet-relay-authenticator/releases/tag/v0.2.0 +ESIGNET_AUTHENTICATOR_JAR_URL=https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/esignet-relay-authenticator-0.2.0.jar +ESIGNET_AUTHENTICATOR_JAR_SHA256=2c36901acb990d3002b5ff7f691a3878f30ca1e21a350c12e54f71b8940da5a8 +ESIGNET_AUTHENTICATOR_CHECKSUM_URL=https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/esignet-relay-authenticator-0.2.0.jar.sha256 + +SOLMARA_SCENARIO_RUNNER_IMAGE=solmara-lab-scenario-runner:local +SOLMARA_CHILD_BENEFIT_FEDERATOR_IMAGE=solmara-lab-scenario-runner:local SOLMARA_PORTAL_IMAGE=solmara-lab-portal:local +SOLMARA_HOME_IMAGE=solmara-lab-home:local