diff --git a/.github/workflows/registry-schema.yml b/.github/workflows/registry-schema.yml new file mode 100644 index 00000000..35e7ffeb --- /dev/null +++ b/.github/workflows/registry-schema.yml @@ -0,0 +1,25 @@ +name: registry-schema + +on: + pull_request: + paths: + - 'quick-start/config/registry/schemas/Participant.json' + - 'charts/registry/files/schemas/Participant.json' + - 'scripts/check-registry-schema.py' + - 'scripts/registry-schema-cases.json' + - '.github/workflows/registry-schema.yml' + +jobs: + check: + name: Participant.json copies match and the cases hold + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - run: pip install jsonschema==4.23.0 + + - run: ./scripts/check-registry-schema.py diff --git a/api-collection/OpenAgriNet.registry-collection.json b/api-collection/OpenAgriNet.registry-collection.json index ee215815..e0a423da 100644 --- a/api-collection/OpenAgriNet.registry-collection.json +++ b/api-collection/OpenAgriNet.registry-collection.json @@ -251,7 +251,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"participantId\": \"{{consumer-participant-id}}\",\n \"name\": \"OAN consumer layer adapter\",\n \"type\": \"node\",\n \"status\": \"active\",\n \"baseUrl\": \"{{consumer-base-url}}\",\n \"role\": \"consumer\",\n \"keys\": [\n {\n \"alg\": \"ed25519\",\n \"key\": \"{{consumer-participant-key}}\",\n \"status\": \"active\",\n \"validFrom\": \"2026-01-01T00:00:00Z\",\n \"validUntil\": \"2030-01-01T00:00:00Z\"\n }\n ]\n}", + "raw": "{\n \"participantId\": \"{{consumer-participant-id}}\",\n \"name\": \"OAN consumer layer adapter\",\n \"type\": \"node\",\n \"status\": \"active\",\n \"baseUrl\": \"{{consumer-base-url}}\",\n \"role\": [\"consumer\"],\n \"keys\": [\n {\n \"alg\": \"ed25519\",\n \"key\": \"{{consumer-participant-key}}\",\n \"status\": \"active\",\n \"validFrom\": \"2026-01-01T00:00:00Z\",\n \"validUntil\": \"2030-01-01T00:00:00Z\"\n }\n ]\n}", "options": { "raw": { "language": "json" diff --git a/charts/registry/CHANGELOG.md b/charts/registry/CHANGELOG.md index 7cd1d8aa..da4001f7 100644 --- a/charts/registry/CHANGELOG.md +++ b/charts/registry/CHANGELOG.md @@ -5,6 +5,47 @@ All notable changes to the `registry` chart are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-10-28 + +### Added +- `files/schemas/Participant.json` carries the federation fields: `revision`, + `discoveryUrl` and `federationRole`, with a `source` and/or `requester` + admission per direction (`schemaPacks`, `descriptorRevision`, `admittedAt`, + `updatedAt`, `status`, `suspendedAt`; `projectionTtl` on `source` only). + `PublicKey` gains an optional `keyId`, a peer's own `kid`. See ADR-0002. + +### Changed +- **BREAKING.** `role` is an array. In 0.4.1 it was a single string, so + every existing row holds one: it still reads, but its next update + (admitting a peer, rotating a key) is refused until it is rewritten as an + array. 0.4.1 refuses an array, so **right after upgrading**, and before any + other write, rewrite those rows (needs `jq` and an admin token): + + ```sh + REG=https://; TOKEN= + curl -s -X POST "$REG/api/v1/Participant/search" -H 'Content-Type: application/json' \ + -d '{"filters":{},"limit":10000}' \ + | jq -c '(.data // .)[] | select(.role | type == "string")' > string-roles.jsonl + while read -r row; do + jq -c 'with_entries(select(.key | startswith("os") | not)) | .role = [.role]' <<<"$row" \ + | curl -s -X PUT "$REG/api/v1/Participant/$(jq -r .osid <<<"$row")" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d @- + done < string-roles.jsonl + ``` + + An empty `string-roles.jsonl` means there is nothing to rewrite. Re-run the + search afterwards and confirm it is empty again. Run this even though + nothing forces it: the registry validates a PUT against the request body, so + an update that does not mention `role` succeeds and leaves the string there. + Only an update that sends `role` fails, with `#/Participant/role: expected + type: JSONArray, found: String`. Rehearsed against sunbird-rc-core v2.0.0: + the rows keep their keys and can then be admitted as peers. Tracked per + deployment by #98. +- `baseUrl` is required for an upstream and for a node without + `discoveryUrl`; an entry with `federationRole` requires `discoveryUrl`. +- `PublicKey.validFrom` is optional, and nested objects declare the registry's + system fields (as strings) so an update passes. + ## [0.4.1] - 2026-09-27 ### Changed diff --git a/charts/registry/Chart.yaml b/charts/registry/Chart.yaml index 3d4dcd19..f7ecd087 100644 --- a/charts/registry/Chart.yaml +++ b/charts/registry/Chart.yaml @@ -5,7 +5,7 @@ description: >- and the key material inbound signed requests are verified against. Requires PostgreSQL and Keycloak. type: application -version: 0.4.1 +version: 0.5.0 appVersion: "v2.0.0" maintainers: - name: OpenAgriNet Engineering Team diff --git a/charts/registry/files/schemas/Participant.json b/charts/registry/files/schemas/Participant.json index c8cff80f..ef9ec629 100644 --- a/charts/registry/files/schemas/Participant.json +++ b/charts/registry/files/schemas/Participant.json @@ -19,8 +19,7 @@ "participantId", "name", "type", - "status", - "baseUrl" + "status" ], "properties": { "participantId": { @@ -40,22 +39,74 @@ "$ref": "#/definitions/Status" }, "baseUrl": { - "description": "The base something is appended to: a Beckn action for a node, a binding's path for an upstream. https for a node, since that is its wire identity; an upstream may be plaintext, because nothing in the registry is sent to it.", + "description": "The base something is appended to: a Beckn action for a node, a binding's path for an upstream. https for a node, since that is its wire identity; an upstream may be plaintext, because nothing in the registry is sent to it. A peer network may omit it and give discoveryUrl instead.", "type": "string", "maxLength": 2000, "pattern": "^https?://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" }, + "discoveryUrl": { + "description": "A peer's discover endpoint, as its Descriptor states. Called as is. Required with federationRole.", + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" + }, "role": { "description": "What this party does on the network. consumer asks. provider answers. network exposes publish and discover, answering discover from published catalogs. Required on a node. Permitted on an upstream, where nothing reads it.", - "type": "string", - "enum": [ - "consumer", - "provider", - "network" - ] + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Role" + } + }, + "federationRole": { + "description": "What we agreed with this peer, by direction. Absent outside federation.", + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "description": "We consume the listed packs from this peer.", + "$ref": "#/definitions/FederationAdmission" + }, + "requester": { + "description": "This peer may request the listed packs from us.", + "allOf": [ + { + "$ref": "#/definitions/FederationAdmission" + }, + { + "not": { + "required": [ + "projectionTtl" + ] + } + } + ] + }, + "osid": { + "type": "string" + }, + "osCreatedAt": { + "type": "string" + }, + "osUpdatedAt": { + "type": "string" + }, + "osCreatedBy": { + "type": "string" + }, + "osUpdatedBy": { + "type": "string" + } + } + }, + "revision": { + "description": "Descriptor revision. On our own entry, the last one we published; on a peer, the latest we verified. Only increases.", + "type": "integer", + "minimum": 1 }, "keys": { - "description": "Plural for rotation: an overlap where old and new are both valid. The sender names which it used by osid in the Authorization header. Required on a node, since a node's signatures are verified against them. Permitted on an upstream, which does not sign anything this stack verifies.", + "description": "Plural for rotation: an overlap where old and new are both valid. The sender names which it used in the Authorization header, by keyId when set, otherwise by osid. Required on a node, since a node's signatures are verified against them. Permitted on an upstream, which does not sign anything this stack verifies.", "type": "array", "minItems": 1, "maxItems": 8, @@ -91,19 +142,149 @@ "baseUrl": { "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" } - } + }, + "anyOf": [ + { + "required": [ + "baseUrl" + ] + }, + { + "required": [ + "discoveryUrl" + ] + } + ] + } + }, + { + "description": "An upstream is reached at its baseUrl.", + "if": { + "properties": { + "type": { + "const": "upstream" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "baseUrl" + ] + } + }, + { + "description": "An admitted peer is reached at its discoveryUrl.", + "if": { + "required": [ + "federationRole" + ] + }, + "then": { + "required": [ + "discoveryUrl" + ] } } ] }, + "FederationAdmission": { + "description": "One admitted direction.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaPacks", + "descriptorRevision", + "admittedAt" + ], + "properties": { + "descriptorRevision": { + "description": "The Descriptor revision this direction was admitted against.", + "type": "integer", + "minimum": 1 + }, + "admittedAt": { + "description": "When this direction was admitted.", + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "description": "When this admission was last changed.", + "type": "string", + "format": "date-time" + }, + "schemaPacks": { + "description": "Schema pack URLs admitted in this direction.", + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/?$" + } + }, + "status": { + "description": "suspended withdraws this direction and keeps its record.", + "type": "string", + "enum": [ + "active", + "suspended" + ], + "default": "active" + }, + "suspendedAt": { + "description": "When this direction was last suspended.", + "type": "string", + "format": "date-time" + }, + "projectionTtl": { + "description": "Seconds the peer lets us keep its crawled data, as its Descriptor states. 0 means no retention: query live. A projectionTtl at the entry's root is legacy and ignored once readers move here (ADR-0002, #102).", + "type": "integer", + "minimum": 0 + }, + "osid": { + "type": "string" + }, + "osCreatedAt": { + "type": "string" + }, + "osUpdatedAt": { + "type": "string" + }, + "osCreatedBy": { + "type": "string" + }, + "osUpdatedBy": { + "type": "string" + } + }, + "if": { + "properties": { + "status": { + "const": "suspended" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "suspendedAt" + ] + } + }, "PublicKey": { - "description": "A public key, held as material: it is public, so there is nothing to protect. Identified by the osid the registry assigns on write -- which is what a sender names in the Authorization header, and so the only id a verifier can look up. alg carries the purpose: ed25519 signs, x25519 encrypts.", + "description": "A public key, held as material: it is public, so there is nothing to protect. A sender names it in the Authorization header by keyId when set, otherwise by osid. alg carries the purpose: ed25519 signs, x25519 encrypts.", "type": "object", "additionalProperties": false, "required": [ "alg", "key", - "validFrom", "status" ], "properties": { @@ -122,7 +303,8 @@ }, "validFrom": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Absent on a peer's key, whose lifetime is its JWKS." }, "validUntil": { "description": "Absent means open-ended. Overlap with the next key's validFrom is the rotation window.", @@ -136,6 +318,28 @@ "active", "revoked" ] + }, + "keyId": { + "description": "The kid a peer signs with, from its JWKS. Our own keys omit it and are named by osid.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^\\S+$" + }, + "osid": { + "type": "string" + }, + "osCreatedAt": { + "type": "string" + }, + "osUpdatedAt": { + "type": "string" + }, + "osCreatedBy": { + "type": "string" + }, + "osUpdatedBy": { + "type": "string" } } }, @@ -159,6 +363,14 @@ "node", "upstream" ] + }, + "Role": { + "type": "string", + "enum": [ + "consumer", + "provider", + "network" + ] } }, "_osConfig": { diff --git a/docs/adr/0002-federation-admission-record.md b/docs/adr/0002-federation-admission-record.md new file mode 100644 index 00000000..a579bf37 --- /dev/null +++ b/docs/adr/0002-federation-admission-record.md @@ -0,0 +1,133 @@ +# ADR-0002 — Federation admission is a per-direction record on the peer's Participant entry + +**Status:** Proposed +**Date:** 2026-10-28 +**Issues:** OpenAgriNet/helmcharts#71, OpenAgriNet/network-adapter#75 + +## Context + +A network admits a peer network before anything is shared with it. The +admission has to say which way sharing goes (we read from them, they read +from us) and which schema packs each way covers, and the adapter reads it on +every inbound call and every crawl. The architecture doc also asks that a +disputed interaction can be reconstructed later, including the admission that +applied at the time. + +The peer publishes its own facts in a signed Descriptor: base URL, keys, +revision, how long its data may be kept. Those change on the peer's schedule. +Admission changes only when our operator decides. + +Sunbird RC validates a PUT against the request body, not against the record +the merge will produce, and then merges the body into what it stores. It +stamps `osid` and `os*` fields into each nested object. + +## Decision + +1. Admission lives on the peer's `Participant` entry as `federationRole`, + keyed by direction: `source` (we consume from the peer) and `requester` + (the peer consumes from us). Either, both, or neither may be present. + Both directions use one definition, `FederationAdmission`. +2. Each direction holds `schemaPacks` (at least one), `descriptorRevision`, + `admittedAt`, `updatedAt`, `status` and `suspendedAt`. `source` also + holds `projectionTtl`, the seconds the peer lets us keep its data; + `requester` may not carry it. +3. Only our operator decides admission. Nothing read from a peer, its + Descriptor or its calls, creates a direction, adds packs or changes + `status`. A refresh from the Descriptor updates the peer's facts and, + inside `source`, only `projectionTtl`, which limits what we may keep and + cannot widen anything. Admission is between two networks and never passes + along a chain. +4. A direction is withdrawn by setting `status` to `suspended` with + `suspendedAt`. The direction stays in the record. A missing `status` means + `active`. +5. Other facts the peer declares stay at the entry's top level: `revision`, + `baseUrl`, `discoveryUrl` and `keys`. `discoveryUrl` is the Descriptor's + `services.discovery`, called as is; `baseUrl` keeps meaning the base an + action is appended to. An entry with `federationRole` requires + `discoveryUrl`; `baseUrl` is required for an upstream and for a node + without `discoveryUrl`, so a peer never has to fill it. A top-level `revision` above a + direction's `descriptorRevision` means the peer changed since that + direction was admitted and is due for review. +6. Closed nested objects (`federationRole`, `FederationAdmission`, + `PublicKey`) keep `additionalProperties: false` and declare the Sunbird + system fields as strings, so a PUT passes and a misspelt or mistyped + field is still refused. `federationRole` has no `minProperties`: the + system fields would count towards it, so it could never require a + direction. A missing direction is not admitted. +7. `role` is an array of `Role`. +8. A peer's key carries the peer's own `kid` as `keyId`, any non-empty + string. Our registry's `osid` stays the local record id. A verifier + matches `keyId` first and falls back to `osid`, which is what our own + keys are named by. A peer does not have to run Sunbird RC. + +## Alternatives considered + +- **`federationRole` as a single string** (`source`, `requester`, `both`). + Cannot carry per-direction packs, revision or time, and cannot express + "admitted one way, suspended the other". +- **A separate `Admission` entity.** Puts a second lookup on every inbound + call, and the two records can disagree about whether a peer exists. +- **Suspend by deleting the direction.** Loses `admittedAt` and + `descriptorRevision`, which the attribution requirement needs. It also + needs `federationRole: {}` as an intermediate write when Sunbird merges + objects. Live Sunbird accepts that write on update and clears both + directions, so a failure between the two writes loses the one being kept. +- **`projectionTtl` at the entry's top level.** Exists on upstreams and + requester-only peers, where it means nothing, and sits away from the + agreement it limits. +- **A peer's `kid` as our `osid`.** Rejects any peer whose `kid` is not a + Sunbird record id, which the architecture doc does not require; its own + example is `governance-1`. Sunbird also keeps a supplied nested `osid` only + on create, so a rotated key could not be filed under its new `kid`. +- **The discovery URL in `baseUrl`.** `baseUrl` already means the base an + action is appended to, so a caller appending `/discover` gets + `/discover/discover`. +- **`additionalProperties: true` on nested objects.** Lets the system fields + through but also any typo, which then fails silently at read time. +- **`role` as string or array.** Every reader handles two shapes forever. + +## Consequences + +- The adapter must treat a `suspended` direction as not admitted, for both + the inbound requester check and the crawler. Until it does, setting + `suspended` does not stop sharing. +- Sunbird merges nested objects on update: a field the update leaves out + keeps its stored value, and an empty object clears everything in it. So + admitting a suspended direction again must write `status: active` + explicitly, `suspendedAt` stays as the last suspension, and an empty + `federationRole` must never be sent. +- On update, Sunbird enforces types, enums, `additionalProperties`, + `minItems` and `not`, but not `required`, `minProperties` or `if`/`then` + (checked against a live registry). It reads the request body alone, so a + rule that depends on a field the body leaves out does not run: the stored + record cannot constrain a partial update. Writers must keep those rules + themselves, such as sending `suspendedAt` with `suspended`, and their tests + should validate every payload against this schema. +- A direction stored before `status` existed has none and reads as + `active`, so no migration is needed. Sunbird does not apply the schema + `default`; readers must. +- The adapter must not apply this check to participants outside federation. + Our own consumers and providers carry no `federationRole` and must still + be served. +- A row stored with `role` as a string keeps working, and an update that + leaves `role` out still succeeds and leaves the string in place. Only an + update that sends `role` fails. So the migration is not forced by the first + write after the upgrade; it has to be run deliberately. +- The quick-start schema and the registry chart's copy must stay identical; + the `registry-schema` workflow fails a pull request where they differ. +- If Sunbird adds a system field, every closed nested object needs it + declared, or updates to that entry start failing. +- Until the admission tool writes `source.projectionTtl` and the crawler + reads it (helmcharts#90, network-adapter#83), the top-level value is still + the live one. The entry's top level allows extra fields, so the schema + cannot refuse it; once both move, a top-level value is legacy and ignored. + Tracked by #102. +- Likewise, until both move, peers admitted earlier hold the discovery URL in + `baseUrl` and have no `discoveryUrl`. +- Removing a value the peer stops declaring (`projectionTtl`, + `discoveryUrl`) is not possible through an update; the last value stays. +- `keyId` is not checked for uniqueness across keys by the schema. The + admission tool must refuse a peer whose JWKS repeats a `kid`. +- The adapter's key lookup and the crawler change with this record: match + `keyId` then `osid`, read `projectionTtl` from `source`, and call + `discoveryUrl` (falling back to `baseUrl` + `/discover`). diff --git a/docs/adr/README.md b/docs/adr/README.md index e4ffadcc..b78396be 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,3 +10,4 @@ Start from [`0000-template.md`](0000-template.md). | # | Decision | Issues | |---|---|---| | [0001](0001-single-network-adapter-deployment-mode.md) | One Beckn OAN network adapter process for all three tiers, by default (`single`); three adapters stay optional (`multi`) | helmcharts#55, #66 | +| [0002](0002-federation-admission-record.md) | Federation admission is a per-direction record on the peer's Participant entry, suspended by status, never widened by the peer | helmcharts#71 | diff --git a/quick-start/bin/setup.py b/quick-start/bin/setup.py index e6ee3950..5b214422 100755 --- a/quick-start/bin/setup.py +++ b/quick-start/bin/setup.py @@ -432,7 +432,7 @@ def seed(identities): ("provider", "OAN provider layer adapter", "provider")): identity = identities[role] ensure_participant(bearer, identity["participantId"], - node(identity["participantId"], name, network_role, + node(identity["participantId"], name, [network_role], env(f"{role.upper()}_BASE_URL"), identity["signingPublic"])) diff --git a/quick-start/config/registry/schemas/Participant.json b/quick-start/config/registry/schemas/Participant.json index c8cff80f..ef9ec629 100644 --- a/quick-start/config/registry/schemas/Participant.json +++ b/quick-start/config/registry/schemas/Participant.json @@ -19,8 +19,7 @@ "participantId", "name", "type", - "status", - "baseUrl" + "status" ], "properties": { "participantId": { @@ -40,22 +39,74 @@ "$ref": "#/definitions/Status" }, "baseUrl": { - "description": "The base something is appended to: a Beckn action for a node, a binding's path for an upstream. https for a node, since that is its wire identity; an upstream may be plaintext, because nothing in the registry is sent to it.", + "description": "The base something is appended to: a Beckn action for a node, a binding's path for an upstream. https for a node, since that is its wire identity; an upstream may be plaintext, because nothing in the registry is sent to it. A peer network may omit it and give discoveryUrl instead.", "type": "string", "maxLength": 2000, "pattern": "^https?://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" }, + "discoveryUrl": { + "description": "A peer's discover endpoint, as its Descriptor states. Called as is. Required with federationRole.", + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" + }, "role": { "description": "What this party does on the network. consumer asks. provider answers. network exposes publish and discover, answering discover from published catalogs. Required on a node. Permitted on an upstream, where nothing reads it.", - "type": "string", - "enum": [ - "consumer", - "provider", - "network" - ] + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Role" + } + }, + "federationRole": { + "description": "What we agreed with this peer, by direction. Absent outside federation.", + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "description": "We consume the listed packs from this peer.", + "$ref": "#/definitions/FederationAdmission" + }, + "requester": { + "description": "This peer may request the listed packs from us.", + "allOf": [ + { + "$ref": "#/definitions/FederationAdmission" + }, + { + "not": { + "required": [ + "projectionTtl" + ] + } + } + ] + }, + "osid": { + "type": "string" + }, + "osCreatedAt": { + "type": "string" + }, + "osUpdatedAt": { + "type": "string" + }, + "osCreatedBy": { + "type": "string" + }, + "osUpdatedBy": { + "type": "string" + } + } + }, + "revision": { + "description": "Descriptor revision. On our own entry, the last one we published; on a peer, the latest we verified. Only increases.", + "type": "integer", + "minimum": 1 }, "keys": { - "description": "Plural for rotation: an overlap where old and new are both valid. The sender names which it used by osid in the Authorization header. Required on a node, since a node's signatures are verified against them. Permitted on an upstream, which does not sign anything this stack verifies.", + "description": "Plural for rotation: an overlap where old and new are both valid. The sender names which it used in the Authorization header, by keyId when set, otherwise by osid. Required on a node, since a node's signatures are verified against them. Permitted on an upstream, which does not sign anything this stack verifies.", "type": "array", "minItems": 1, "maxItems": 8, @@ -91,19 +142,149 @@ "baseUrl": { "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" } - } + }, + "anyOf": [ + { + "required": [ + "baseUrl" + ] + }, + { + "required": [ + "discoveryUrl" + ] + } + ] + } + }, + { + "description": "An upstream is reached at its baseUrl.", + "if": { + "properties": { + "type": { + "const": "upstream" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "baseUrl" + ] + } + }, + { + "description": "An admitted peer is reached at its discoveryUrl.", + "if": { + "required": [ + "federationRole" + ] + }, + "then": { + "required": [ + "discoveryUrl" + ] } } ] }, + "FederationAdmission": { + "description": "One admitted direction.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaPacks", + "descriptorRevision", + "admittedAt" + ], + "properties": { + "descriptorRevision": { + "description": "The Descriptor revision this direction was admitted against.", + "type": "integer", + "minimum": 1 + }, + "admittedAt": { + "description": "When this direction was admitted.", + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "description": "When this admission was last changed.", + "type": "string", + "format": "date-time" + }, + "schemaPacks": { + "description": "Schema pack URLs admitted in this direction.", + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/?$" + } + }, + "status": { + "description": "suspended withdraws this direction and keeps its record.", + "type": "string", + "enum": [ + "active", + "suspended" + ], + "default": "active" + }, + "suspendedAt": { + "description": "When this direction was last suspended.", + "type": "string", + "format": "date-time" + }, + "projectionTtl": { + "description": "Seconds the peer lets us keep its crawled data, as its Descriptor states. 0 means no retention: query live. A projectionTtl at the entry's root is legacy and ignored once readers move here (ADR-0002, #102).", + "type": "integer", + "minimum": 0 + }, + "osid": { + "type": "string" + }, + "osCreatedAt": { + "type": "string" + }, + "osUpdatedAt": { + "type": "string" + }, + "osCreatedBy": { + "type": "string" + }, + "osUpdatedBy": { + "type": "string" + } + }, + "if": { + "properties": { + "status": { + "const": "suspended" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "suspendedAt" + ] + } + }, "PublicKey": { - "description": "A public key, held as material: it is public, so there is nothing to protect. Identified by the osid the registry assigns on write -- which is what a sender names in the Authorization header, and so the only id a verifier can look up. alg carries the purpose: ed25519 signs, x25519 encrypts.", + "description": "A public key, held as material: it is public, so there is nothing to protect. A sender names it in the Authorization header by keyId when set, otherwise by osid. alg carries the purpose: ed25519 signs, x25519 encrypts.", "type": "object", "additionalProperties": false, "required": [ "alg", "key", - "validFrom", "status" ], "properties": { @@ -122,7 +303,8 @@ }, "validFrom": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Absent on a peer's key, whose lifetime is its JWKS." }, "validUntil": { "description": "Absent means open-ended. Overlap with the next key's validFrom is the rotation window.", @@ -136,6 +318,28 @@ "active", "revoked" ] + }, + "keyId": { + "description": "The kid a peer signs with, from its JWKS. Our own keys omit it and are named by osid.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^\\S+$" + }, + "osid": { + "type": "string" + }, + "osCreatedAt": { + "type": "string" + }, + "osUpdatedAt": { + "type": "string" + }, + "osCreatedBy": { + "type": "string" + }, + "osUpdatedBy": { + "type": "string" } } }, @@ -159,6 +363,14 @@ "node", "upstream" ] + }, + "Role": { + "type": "string", + "enum": [ + "consumer", + "provider", + "network" + ] } }, "_osConfig": { diff --git a/scripts/check-registry-schema.py b/scripts/check-registry-schema.py new file mode 100755 index 00000000..b285a1cb --- /dev/null +++ b/scripts/check-registry-schema.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Check the Participant registry schema. + +Two things, both of which have been wrong before: + + 1. The chart's copy and the quick-start copy are the same file. + 2. The schema accepts and refuses what scripts/registry-schema-cases.json + says it does. + +Run locally exactly as CI runs it: + + pip install jsonschema + ./scripts/check-registry-schema.py +""" + +import json +import pathlib +import sys + +from jsonschema import Draft7Validator + +ROOT = pathlib.Path(__file__).resolve().parent.parent +QUICK_START_COPY = ROOT / "quick-start/config/registry/schemas/Participant.json" +CHART_COPY = ROOT / "charts/registry/files/schemas/Participant.json" +CASES = ROOT / "scripts/registry-schema-cases.json" + + +def copies_match(): + if QUICK_START_COPY.read_bytes() == CHART_COPY.read_bytes(): + return True + print(f"FAIL {QUICK_START_COPY.relative_to(ROOT)} and " + f"{CHART_COPY.relative_to(ROOT)} differ. Edit one, copy it to the other.") + return False + + +def participant_validator(): + # setup.py posts the participant itself, with no {"Participant": {...}} + # wrapper, so that is the shape the registry validates and the shape the + # cases carry. + schema = json.loads(QUICK_START_COPY.read_text()) + return Draft7Validator({"$ref": "#/definitions/Participant", + "definitions": schema["definitions"]}) + + +def cases_hold(): + validator = participant_validator() + passed = True + for case in json.loads(CASES.read_text()): + errors = sorted(validator.iter_errors(case["participant"]), key=str) + accepted = not errors + if accepted == case["valid"]: + print(f"ok {case['name']}") + continue + passed = False + print(f"FAIL {case['name']}") + print(f" the schema should {'accept' if case['valid'] else 'refuse'} this") + for error in errors[:3]: + print(f" {list(error.absolute_path)}: {error.message}") + return passed + + +if __name__ == "__main__": + # Both run even when the first fails: a drifted copy and a broken case + # are separate problems and the author should see both. + checks = [copies_match(), cases_hold()] + sys.exit(0 if all(checks) else 1) diff --git a/scripts/registry-schema-cases.json b/scripts/registry-schema-cases.json new file mode 100644 index 00000000..1accc1b0 --- /dev/null +++ b/scripts/registry-schema-cases.json @@ -0,0 +1,487 @@ +[ + { + "name": "a peer admitted as a source is reached at its discoveryUrl, with no baseUrl", + "valid": true, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z" + } + } + } + }, + { + "name": "federationRole may be empty: suspend leaves the key behind with nothing under it", + "valid": true, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": {} + } + }, + { + "name": "federationRole without a discoveryUrl is refused: there is nowhere to crawl", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z" + } + } + } + }, + { + "name": "a node must be reachable: neither baseUrl nor discoveryUrl is refused", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ] + } + }, + { + "name": "projectionTtl belongs to source: how long we may keep what we crawled", + "valid": true, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z", + "projectionTtl": 3600 + } + } + } + }, + { + "name": "projectionTtl on requester is refused: it is the peer's retention, not ours", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "requester": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z", + "projectionTtl": 60 + } + } + } + }, + { + "name": "projectionTtl of 0 is an agreement, not a missing value: query live", + "valid": true, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z", + "projectionTtl": 0 + } + } + } + }, + { + "name": "a suspended admission must say when it was suspended", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z", + "status": "suspended" + } + } + } + }, + { + "name": "a suspended admission with suspendedAt is accepted", + "valid": true, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z", + "status": "suspended", + "suspendedAt": "2026-10-28T09:00:00Z" + } + } + } + }, + { + "name": "an admission with no schemaPacks is refused: it admits nothing", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z" + } + } + } + }, + { + "name": "a typo in a member name is refused rather than ignored", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemapacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z" + } + } + } + }, + { + "name": "the system fields the registry injects pass back on an update", + "valid": true, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z", + "osid": "1-abc" + }, + "osid": "1-def" + } + } + }, + { + "name": "an osid the registry did not write is refused when it is not a string", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": [ + "network" + ], + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z" + }, + "osid": 7 + } + } + }, + { + "name": "role as a bare string is refused: 0.4.1 rows need the migration in the CHANGELOG", + "valid": false, + "participant": { + "participantId": "net-a.example.org", + "name": "Net A", + "type": "node", + "status": "active", + "role": "network", + "discoveryUrl": "https://net-a.example.org/discover", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ], + "federationRole": { + "source": { + "schemaPacks": [ + "https://net-a.example.org/packs/MandiPrice/v0.1/attributes.yaml" + ], + "descriptorRevision": 1, + "admittedAt": "2026-10-28T00:00:00Z" + } + } + } + }, + { + "name": "an upstream is reached at its baseUrl and federates with nobody", + "valid": true, + "participant": { + "participantId": "agmarknet", + "name": "Agmarknet", + "type": "upstream", + "status": "active", + "baseUrl": "http://mock-agmarknet:8080" + } + }, + { + "name": "an upstream without a baseUrl is refused", + "valid": false, + "participant": { + "participantId": "agmarknet", + "name": "Agmarknet", + "type": "upstream", + "status": "active" + } + }, + { + "name": "our own consumer is an ordinary node with a baseUrl and no federation", + "valid": true, + "participant": { + "participantId": "consumer.oan.example.org", + "name": "OAN consumer adapter", + "type": "node", + "status": "active", + "role": [ + "consumer" + ], + "baseUrl": "https://consumer.oan.example.org", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ] + } + }, + { + "name": "a node reached over plain http is refused", + "valid": false, + "participant": { + "participantId": "consumer.oan.example.org", + "name": "OAN consumer adapter", + "type": "node", + "status": "active", + "role": [ + "consumer" + ], + "baseUrl": "http://consumer.oan.example.org", + "keys": [ + { + "alg": "ed25519", + "status": "active", + "validFrom": "2026-10-28T00:00:00Z", + "key": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + } + ] + } + } +]