diff --git a/Cargo.lock b/Cargo.lock index 65dc3fbe8..c3209c88f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5674,6 +5674,7 @@ dependencies = [ "reqwest", "rhai", "rustix", + "schemars 1.2.1", "serde", "serde-saphyr", "serde_json", @@ -6071,10 +6072,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -6188,6 +6202,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.150" diff --git a/Cargo.toml b/Cargo.toml index 08505f14d..0a4e77fdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ ryu-js = { version = "1.0.2" } secrecy = { version = "0.10" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } +schemars = { version = "=1.2.1" } serde_norway = { version = "0.9" } serde_path_to_error = { version = "0.1" } serde-saphyr = { version = "0.0.26" } diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index fc5522b4f..d94f64afe 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Relay now publishes a reproducible, product-owned Draft 2020-12 schema for + the complete runtime configuration. `registry-relay schema --format json` + prints the committed artifact byte for byte, and local CI checks schema drift, + maintained runtime fixtures, strict nested and tagged shapes, and exact + bidirectional configuration-reference key paths. Schema and runtime parsing + now share bounded listener-address and duration string grammars, integer + widths have explicit JSON Schema bounds, and environment references reflect + their consumer-specific runtime invariants. - BREAKING: configuration `${VAR}` expansion now rejects environment variables that are unset or empty. `${VAR:-fallback}` uses its fallback for either state, `${VAR:-}` explicitly expands to empty, and `${VAR:?message}` reports diff --git a/crates/registry-relay/Cargo.toml b/crates/registry-relay/Cargo.toml index caf78403e..f6da1a5aa 100644 --- a/crates/registry-relay/Cargo.toml +++ b/crates/registry-relay/Cargo.toml @@ -76,6 +76,7 @@ tokio-util = { version = "0.7" } # ─── serialization / config ────────────────────────────────────────────────── serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } +schemars.workspace = true rhai.workspace = true crosswalk-functions = { workspace = true, features = ["date", "redaction"] } # Optional Crosswalk CEL mapping support. Disabled by default so the core VC @@ -154,7 +155,7 @@ insta = { version = "1", features = ["json"] } # don't multiply build-time copies. zip = { version = "8", default-features = false, features = ["deflate"] } # Validates payloads in standards-adapter tests. -jsonschema = { version = "0.18" } +jsonschema = { version = "0.18", features = ["draft202012"] } # Ed25519 key generation for tests so they can mint fresh keypairs and # convert them to/from JWK without shelling out to openssl. ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index 1537f37fd..eaf0ffcee 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -1,5 +1,602 @@ # Registry Relay configuration guide +The following canonical paths are derived from the complete deserialization schema. +Named properties use dot notation, `[]` denotes array items, and `.*` denotes map values. +This inventory is checked in both directions so neither the schema nor this reference can gain a key +without the other. + +{/* registry-relay-config-key-paths:start */} +```text +audit +audit.chain +audit.format +audit.hash_secret_env +audit.include_health +audit.path +audit.rotate +audit.rotate.max_files +audit.rotate.max_size_mb +audit.sink +audit.write_policy +auth +auth.api_keys +auth.api_keys[] +auth.api_keys[].fingerprint +auth.api_keys[].fingerprint.name +auth.api_keys[].fingerprint.path +auth.api_keys[].fingerprint.provider +auth.api_keys[].id +auth.api_keys[].scopes +auth.api_keys[].scopes[] +auth.failure_throttle +auth.failure_throttle.enabled +auth.failure_throttle.max_failures +auth.failure_throttle.window_seconds +auth.mode +auth.oidc +auth.oidc.allow_dev_insecure_fetch_urls +auth.oidc.allowed_algorithms +auth.oidc.allowed_algorithms[] +auth.oidc.allowed_clients +auth.oidc.allowed_clients[] +auth.oidc.allowed_token_types +auth.oidc.allowed_token_types[] +auth.oidc.audiences +auth.oidc.audiences[] +auth.oidc.discovery_url +auth.oidc.issuer +auth.oidc.jwks_cache_ttl +auth.oidc.jwks_url +auth.oidc.leeway +auth.oidc.scope_claim +auth.oidc.scope_map +auth.oidc.scope_map.* +auth.oidc.scope_object_required_keys +auth.oidc.scope_object_required_keys[] +catalog +catalog.authority_type +catalog.base_url +catalog.default_spatial_coverage +catalog.participant_id +catalog.publisher +catalog.publisher_iri +catalog.title +config_trust +config_trust.antirollback_state_path +config_trust.break_glass_override_path +config_trust.bundle_path +config_trust.trust_anchor_path +consultation +consultation.artifacts +consultation.artifacts.evidence +consultation.artifacts.evidence[] +consultation.artifacts.evidence[].class +consultation.artifacts.evidence[].path +consultation.artifacts.evidence[].sha256 +consultation.artifacts.integration_packs +consultation.artifacts.integration_packs[] +consultation.artifacts.integration_packs[].hash +consultation.artifacts.integration_packs[].path +consultation.artifacts.integration_packs[].sha256 +consultation.artifacts.private_bindings +consultation.artifacts.private_bindings[] +consultation.artifacts.private_bindings[].hash +consultation.artifacts.private_bindings[].path +consultation.artifacts.private_bindings[].sha256 +consultation.artifacts.public_contracts +consultation.artifacts.public_contracts[] +consultation.artifacts.public_contracts[].hash +consultation.artifacts.public_contracts[].path +consultation.artifacts.public_contracts[].sha256 +consultation.artifacts.rhai_scripts +consultation.artifacts.rhai_scripts[] +consultation.artifacts.rhai_scripts[].path +consultation.artifacts.rhai_scripts[].sha256 +consultation.audit_pseudonym_materials +consultation.audit_pseudonym_materials[] +consultation.audit_pseudonym_materials[].key_id +consultation.audit_pseudonym_materials[].source +consultation.audit_pseudonym_materials[].source.name +consultation.audit_pseudonym_materials[].source.provider +consultation.authorized_workload +consultation.authorized_workload.audience +consultation.authorized_workload.client_claim_selector +consultation.authorized_workload.client_value +consultation.authorized_workload.principal_id +consultation.source_credentials +consultation.source_credentials[] +consultation.source_credentials[].client_id_env +consultation.source_credentials[].client_secret_env +consultation.source_credentials[].generation +consultation.source_credentials[].password_env +consultation.source_credentials[].ref +consultation.source_credentials[].token_env +consultation.source_credentials[].type +consultation.source_credentials[].username_env +consultation.source_credentials[].value_env +consultation.state_plane +consultation.state_plane.audit_pseudonym_keyring_lock_key +consultation.state_plane.chain_key_epoch_id +consultation.state_plane.database_url_env +consultation.state_plane.root_certificate_path +consultation.state_plane.serving_fence_lock_key +datasets +datasets[] +datasets[].access_rights +datasets[].aggregates +datasets[].aggregates[] +datasets[].aggregates[].access +datasets[].aggregates[].access.aggregate_only_execution +datasets[].aggregates[].access.aggregate_scope +datasets[].aggregates[].access.metadata_scope +datasets[].aggregates[].allowed_filters +datasets[].aggregates[].allowed_filters[] +datasets[].aggregates[].allowed_filters[].field +datasets[].aggregates[].allowed_filters[].ops +datasets[].aggregates[].allowed_filters[].ops[] +datasets[].aggregates[].default_group_by +datasets[].aggregates[].default_group_by[] +datasets[].aggregates[].description +datasets[].aggregates[].dimensions +datasets[].aggregates[].dimensions[] +datasets[].aggregates[].dimensions[].codelist +datasets[].aggregates[].dimensions[].field +datasets[].aggregates[].dimensions[].id +datasets[].aggregates[].dimensions[].label +datasets[].aggregates[].disclosure_control +datasets[].aggregates[].disclosure_control.method +datasets[].aggregates[].disclosure_control.method[] +datasets[].aggregates[].disclosure_control.min_cell_size +datasets[].aggregates[].disclosure_control.min_group_size +datasets[].aggregates[].disclosure_control.report_suppressed_rows +datasets[].aggregates[].disclosure_control.suppression +datasets[].aggregates[].group_by +datasets[].aggregates[].group_by[] +datasets[].aggregates[].id +datasets[].aggregates[].indicators +datasets[].aggregates[].indicators[] +datasets[].aggregates[].indicators[].column +datasets[].aggregates[].indicators[].decimals +datasets[].aggregates[].indicators[].definition_uri +datasets[].aggregates[].indicators[].frequency +datasets[].aggregates[].indicators[].function +datasets[].aggregates[].indicators[].id +datasets[].aggregates[].indicators[].label +datasets[].aggregates[].indicators[].unit_measure +datasets[].aggregates[].indicators[].unit_mult +datasets[].aggregates[].joins +datasets[].aggregates[].joins[] +datasets[].aggregates[].joins[].relationship +datasets[].aggregates[].measures +datasets[].aggregates[].measures[] +datasets[].aggregates[].measures[].column +datasets[].aggregates[].measures[].function +datasets[].aggregates[].measures[].name +datasets[].aggregates[].required_filter_bindings +datasets[].aggregates[].required_filter_bindings[] +datasets[].aggregates[].required_filter_bindings[].field +datasets[].aggregates[].required_filter_bindings[].source +datasets[].aggregates[].required_filters +datasets[].aggregates[].required_filters[] +datasets[].aggregates[].source_entity +datasets[].aggregates[].spatial +datasets[].aggregates[].spatial.bbox_fields +datasets[].aggregates[].spatial.bbox_fields.max_x +datasets[].aggregates[].spatial.bbox_fields.max_y +datasets[].aggregates[].spatial.bbox_fields.min_x +datasets[].aggregates[].spatial.bbox_fields.min_y +datasets[].aggregates[].spatial.collection_id +datasets[].aggregates[].spatial.dimension +datasets[].aggregates[].spatial.geometry_entity +datasets[].aggregates[].spatial.geometry_field +datasets[].aggregates[].spatial.geometry_id_field +datasets[].aggregates[].spatial.max_geometry_vertices +datasets[].aggregates[].spatial.mode +datasets[].aggregates[].temporal_field +datasets[].aggregates[].title +datasets[].applicable_legislation +datasets[].applicable_legislation[] +datasets[].conforms_to +datasets[].conforms_to[] +datasets[].defaults +datasets[].defaults.materialization +datasets[].defaults.refresh +datasets[].defaults.refresh.interval +datasets[].defaults.refresh.mode +datasets[].description +datasets[].entities +datasets[].entities[] +datasets[].entities[].access +datasets[].entities[].access.aggregate_scope +datasets[].entities[].access.evidence_verification_scope +datasets[].entities[].access.metadata_scope +datasets[].entities[].access.read_scope +datasets[].entities[].aggregates +datasets[].entities[].aggregates[] +datasets[].entities[].aggregates[].access +datasets[].entities[].aggregates[].access.aggregate_only_execution +datasets[].entities[].aggregates[].access.aggregate_scope +datasets[].entities[].aggregates[].access.metadata_scope +datasets[].entities[].aggregates[].allowed_filters +datasets[].entities[].aggregates[].allowed_filters[] +datasets[].entities[].aggregates[].allowed_filters[].field +datasets[].entities[].aggregates[].allowed_filters[].ops +datasets[].entities[].aggregates[].allowed_filters[].ops[] +datasets[].entities[].aggregates[].default_group_by +datasets[].entities[].aggregates[].default_group_by[] +datasets[].entities[].aggregates[].description +datasets[].entities[].aggregates[].dimensions +datasets[].entities[].aggregates[].dimensions[] +datasets[].entities[].aggregates[].dimensions[].codelist +datasets[].entities[].aggregates[].dimensions[].field +datasets[].entities[].aggregates[].dimensions[].id +datasets[].entities[].aggregates[].dimensions[].label +datasets[].entities[].aggregates[].disclosure_control +datasets[].entities[].aggregates[].disclosure_control.method +datasets[].entities[].aggregates[].disclosure_control.method[] +datasets[].entities[].aggregates[].disclosure_control.min_cell_size +datasets[].entities[].aggregates[].disclosure_control.min_group_size +datasets[].entities[].aggregates[].disclosure_control.report_suppressed_rows +datasets[].entities[].aggregates[].disclosure_control.suppression +datasets[].entities[].aggregates[].group_by +datasets[].entities[].aggregates[].group_by[] +datasets[].entities[].aggregates[].id +datasets[].entities[].aggregates[].indicators +datasets[].entities[].aggregates[].indicators[] +datasets[].entities[].aggregates[].indicators[].column +datasets[].entities[].aggregates[].indicators[].decimals +datasets[].entities[].aggregates[].indicators[].definition_uri +datasets[].entities[].aggregates[].indicators[].frequency +datasets[].entities[].aggregates[].indicators[].function +datasets[].entities[].aggregates[].indicators[].id +datasets[].entities[].aggregates[].indicators[].label +datasets[].entities[].aggregates[].indicators[].unit_measure +datasets[].entities[].aggregates[].indicators[].unit_mult +datasets[].entities[].aggregates[].joins +datasets[].entities[].aggregates[].joins[] +datasets[].entities[].aggregates[].joins[].relationship +datasets[].entities[].aggregates[].measures +datasets[].entities[].aggregates[].measures[] +datasets[].entities[].aggregates[].measures[].column +datasets[].entities[].aggregates[].measures[].function +datasets[].entities[].aggregates[].measures[].name +datasets[].entities[].aggregates[].required_filter_bindings +datasets[].entities[].aggregates[].required_filter_bindings[] +datasets[].entities[].aggregates[].required_filter_bindings[].field +datasets[].entities[].aggregates[].required_filter_bindings[].source +datasets[].entities[].aggregates[].required_filters +datasets[].entities[].aggregates[].required_filters[] +datasets[].entities[].aggregates[].source_entity +datasets[].entities[].aggregates[].spatial +datasets[].entities[].aggregates[].spatial.bbox_fields +datasets[].entities[].aggregates[].spatial.bbox_fields.max_x +datasets[].entities[].aggregates[].spatial.bbox_fields.max_y +datasets[].entities[].aggregates[].spatial.bbox_fields.min_x +datasets[].entities[].aggregates[].spatial.bbox_fields.min_y +datasets[].entities[].aggregates[].spatial.collection_id +datasets[].entities[].aggregates[].spatial.dimension +datasets[].entities[].aggregates[].spatial.geometry_entity +datasets[].entities[].aggregates[].spatial.geometry_field +datasets[].entities[].aggregates[].spatial.geometry_id_field +datasets[].entities[].aggregates[].spatial.max_geometry_vertices +datasets[].entities[].aggregates[].spatial.mode +datasets[].entities[].aggregates[].temporal_field +datasets[].entities[].aggregates[].title +datasets[].entities[].api +datasets[].entities[].api.allowed_expansions +datasets[].entities[].api.allowed_expansions[] +datasets[].entities[].api.allowed_filters +datasets[].entities[].api.allowed_filters[] +datasets[].entities[].api.allowed_filters[].field +datasets[].entities[].api.allowed_filters[].ops +datasets[].entities[].api.allowed_filters[].ops[] +datasets[].entities[].api.default_limit +datasets[].entities[].api.governed_policy +datasets[].entities[].api.governed_policy.allowed_assurance +datasets[].entities[].api.governed_policy.allowed_assurance[] +datasets[].entities[].api.governed_policy.max_source_age_seconds +datasets[].entities[].api.governed_policy.minimum_assurance +datasets[].entities[].api.governed_policy.permitted_jurisdictions +datasets[].entities[].api.governed_policy.permitted_jurisdictions[] +datasets[].entities[].api.governed_policy.permitted_purposes +datasets[].entities[].api.governed_policy.permitted_purposes[] +datasets[].entities[].api.governed_policy.redaction_fields +datasets[].entities[].api.governed_policy.redaction_fields[] +datasets[].entities[].api.governed_policy.require_consent +datasets[].entities[].api.governed_policy.require_legal_basis +datasets[].entities[].api.governed_policy.trusted_context +datasets[].entities[].api.governed_policy.trusted_context.asserted_assurance +datasets[].entities[].api.governed_policy.trusted_context.consent_ref +datasets[].entities[].api.governed_policy.trusted_context.jurisdiction +datasets[].entities[].api.governed_policy.trusted_context.legal_basis_ref +datasets[].entities[].api.governed_policy.trusted_context.source_observed_age_seconds +datasets[].entities[].api.max_limit +datasets[].entities[].api.require_purpose_header +datasets[].entities[].api.required_filter_bindings +datasets[].entities[].api.required_filter_bindings[] +datasets[].entities[].api.required_filter_bindings[].field +datasets[].entities[].api.required_filter_bindings[].source +datasets[].entities[].api.required_filters +datasets[].entities[].api.required_filters[] +datasets[].entities[].attribute_release_profiles +datasets[].entities[].attribute_release_profiles[] +datasets[].entities[].attribute_release_profiles[].claims +datasets[].entities[].attribute_release_profiles[].claims[] +datasets[].entities[].attribute_release_profiles[].claims[].expression +datasets[].entities[].attribute_release_profiles[].claims[].expression.cel +datasets[].entities[].attribute_release_profiles[].claims[].format +datasets[].entities[].attribute_release_profiles[].claims[].locale +datasets[].entities[].attribute_release_profiles[].claims[].name +datasets[].entities[].attribute_release_profiles[].claims[].required +datasets[].entities[].attribute_release_profiles[].claims[].sensitivity +datasets[].entities[].attribute_release_profiles[].claims[].shareable +datasets[].entities[].attribute_release_profiles[].claims[].source_field +datasets[].entities[].attribute_release_profiles[].description +datasets[].entities[].attribute_release_profiles[].id +datasets[].entities[].attribute_release_profiles[].purpose +datasets[].entities[].attribute_release_profiles[].release_conditions +datasets[].entities[].attribute_release_profiles[].release_conditions.denied_code +datasets[].entities[].attribute_release_profiles[].release_conditions.expression +datasets[].entities[].attribute_release_profiles[].release_conditions.expression.cel +datasets[].entities[].attribute_release_profiles[].release_scope +datasets[].entities[].attribute_release_profiles[].response +datasets[].entities[].attribute_release_profiles[].response.include_source_metadata +datasets[].entities[].attribute_release_profiles[].response.max_age_seconds +datasets[].entities[].attribute_release_profiles[].subject +datasets[].entities[].attribute_release_profiles[].subject.cardinality +datasets[].entities[].attribute_release_profiles[].subject.id_type +datasets[].entities[].attribute_release_profiles[].subject.input +datasets[].entities[].attribute_release_profiles[].subject.source_field +datasets[].entities[].attribute_release_profiles[].title +datasets[].entities[].attribute_release_profiles[].version +datasets[].entities[].concept_uri +datasets[].entities[].description +datasets[].entities[].fields +datasets[].entities[].fields[] +datasets[].entities[].fields[].codelist +datasets[].entities[].fields[].concept_uri +datasets[].entities[].fields[].from +datasets[].entities[].fields[].language +datasets[].entities[].fields[].name +datasets[].entities[].fields[].sensitive +datasets[].entities[].fields[].unit +datasets[].entities[].name +datasets[].entities[].relationships +datasets[].entities[].relationships[] +datasets[].entities[].relationships[].concept_uri +datasets[].entities[].relationships[].foreign_key +datasets[].entities[].relationships[].kind +datasets[].entities[].relationships[].name +datasets[].entities[].relationships[].target +datasets[].entities[].spatial +datasets[].entities[].spatial.bbox_fields +datasets[].entities[].spatial.bbox_fields.max_x +datasets[].entities[].spatial.bbox_fields.max_y +datasets[].entities[].spatial.bbox_fields.min_x +datasets[].entities[].spatial.bbox_fields.min_y +datasets[].entities[].spatial.collection_id +datasets[].entities[].spatial.datetime_field +datasets[].entities[].spatial.description +datasets[].entities[].spatial.geometry +datasets[].entities[].spatial.geometry.crs +datasets[].entities[].spatial.geometry.field +datasets[].entities[].spatial.geometry.kind +datasets[].entities[].spatial.geometry.latitude_field +datasets[].entities[].spatial.geometry.longitude_field +datasets[].entities[].spatial.max_bbox_degrees +datasets[].entities[].spatial.max_geometry_vertices +datasets[].entities[].spatial.title +datasets[].entities[].table +datasets[].entities[].title +datasets[].id +datasets[].owner +datasets[].public_services +datasets[].public_services[] +datasets[].public_services[].description +datasets[].public_services[].id +datasets[].public_services[].title +datasets[].sensitivity +datasets[].spatial_coverage +datasets[].status +datasets[].tables +datasets[].tables[] +datasets[].tables[].access +datasets[].tables[].access.aggregate_scope +datasets[].tables[].access.metadata_scope +datasets[].tables[].aggregates +datasets[].tables[].aggregates[] +datasets[].tables[].aggregates[].access +datasets[].tables[].aggregates[].access.aggregate_only_execution +datasets[].tables[].aggregates[].access.aggregate_scope +datasets[].tables[].aggregates[].access.metadata_scope +datasets[].tables[].aggregates[].allowed_filters +datasets[].tables[].aggregates[].allowed_filters[] +datasets[].tables[].aggregates[].allowed_filters[].field +datasets[].tables[].aggregates[].allowed_filters[].ops +datasets[].tables[].aggregates[].allowed_filters[].ops[] +datasets[].tables[].aggregates[].default_group_by +datasets[].tables[].aggregates[].default_group_by[] +datasets[].tables[].aggregates[].description +datasets[].tables[].aggregates[].dimensions +datasets[].tables[].aggregates[].dimensions[] +datasets[].tables[].aggregates[].dimensions[].codelist +datasets[].tables[].aggregates[].dimensions[].field +datasets[].tables[].aggregates[].dimensions[].id +datasets[].tables[].aggregates[].dimensions[].label +datasets[].tables[].aggregates[].disclosure_control +datasets[].tables[].aggregates[].disclosure_control.method +datasets[].tables[].aggregates[].disclosure_control.method[] +datasets[].tables[].aggregates[].disclosure_control.min_cell_size +datasets[].tables[].aggregates[].disclosure_control.min_group_size +datasets[].tables[].aggregates[].disclosure_control.report_suppressed_rows +datasets[].tables[].aggregates[].disclosure_control.suppression +datasets[].tables[].aggregates[].group_by +datasets[].tables[].aggregates[].group_by[] +datasets[].tables[].aggregates[].id +datasets[].tables[].aggregates[].indicators +datasets[].tables[].aggregates[].indicators[] +datasets[].tables[].aggregates[].indicators[].column +datasets[].tables[].aggregates[].indicators[].decimals +datasets[].tables[].aggregates[].indicators[].definition_uri +datasets[].tables[].aggregates[].indicators[].frequency +datasets[].tables[].aggregates[].indicators[].function +datasets[].tables[].aggregates[].indicators[].id +datasets[].tables[].aggregates[].indicators[].label +datasets[].tables[].aggregates[].indicators[].unit_measure +datasets[].tables[].aggregates[].indicators[].unit_mult +datasets[].tables[].aggregates[].joins +datasets[].tables[].aggregates[].joins[] +datasets[].tables[].aggregates[].joins[].relationship +datasets[].tables[].aggregates[].measures +datasets[].tables[].aggregates[].measures[] +datasets[].tables[].aggregates[].measures[].column +datasets[].tables[].aggregates[].measures[].function +datasets[].tables[].aggregates[].measures[].name +datasets[].tables[].aggregates[].required_filter_bindings +datasets[].tables[].aggregates[].required_filter_bindings[] +datasets[].tables[].aggregates[].required_filter_bindings[].field +datasets[].tables[].aggregates[].required_filter_bindings[].source +datasets[].tables[].aggregates[].required_filters +datasets[].tables[].aggregates[].required_filters[] +datasets[].tables[].aggregates[].source_entity +datasets[].tables[].aggregates[].spatial +datasets[].tables[].aggregates[].spatial.bbox_fields +datasets[].tables[].aggregates[].spatial.bbox_fields.max_x +datasets[].tables[].aggregates[].spatial.bbox_fields.max_y +datasets[].tables[].aggregates[].spatial.bbox_fields.min_x +datasets[].tables[].aggregates[].spatial.bbox_fields.min_y +datasets[].tables[].aggregates[].spatial.collection_id +datasets[].tables[].aggregates[].spatial.dimension +datasets[].tables[].aggregates[].spatial.geometry_entity +datasets[].tables[].aggregates[].spatial.geometry_field +datasets[].tables[].aggregates[].spatial.geometry_id_field +datasets[].tables[].aggregates[].spatial.max_geometry_vertices +datasets[].tables[].aggregates[].spatial.mode +datasets[].tables[].aggregates[].temporal_field +datasets[].tables[].aggregates[].title +datasets[].tables[].api +datasets[].tables[].api.allowed_filters +datasets[].tables[].api.allowed_filters[] +datasets[].tables[].api.allowed_filters[].field +datasets[].tables[].api.allowed_filters[].ops +datasets[].tables[].api.allowed_filters[].ops[] +datasets[].tables[].api.default_limit +datasets[].tables[].api.max_limit +datasets[].tables[].api.require_purpose_header +datasets[].tables[].id +datasets[].tables[].materialization +datasets[].tables[].primary_key +datasets[].tables[].refresh +datasets[].tables[].refresh.interval +datasets[].tables[].refresh.mode +datasets[].tables[].schema +datasets[].tables[].schema.fields +datasets[].tables[].schema.fields[] +datasets[].tables[].schema.fields[].codelist +datasets[].tables[].schema.fields[].concept_uri +datasets[].tables[].schema.fields[].language +datasets[].tables[].schema.fields[].name +datasets[].tables[].schema.fields[].nullable +datasets[].tables[].schema.fields[].sensitive +datasets[].tables[].schema.fields[].type +datasets[].tables[].schema.fields[].unit +datasets[].tables[].schema.strict +datasets[].tables[].source +datasets[].tables[].source.change_token_sql +datasets[].tables[].source.connect_timeout +datasets[].tables[].source.connection_env +datasets[].tables[].source.format +datasets[].tables[].source.format.csv +datasets[].tables[].source.format.csv.delimiter +datasets[].tables[].source.format.csv.header_row +datasets[].tables[].source.format.csv.quote +datasets[].tables[].source.format.parquet +datasets[].tables[].source.format.xlsx +datasets[].tables[].source.format.xlsx.data_range +datasets[].tables[].source.format.xlsx.header_row +datasets[].tables[].source.format.xlsx.sheet +datasets[].tables[].source.path +datasets[].tables[].source.query +datasets[].tables[].source.query_timeout +datasets[].tables[].source.table +datasets[].tables[].source.table.name +datasets[].tables[].source.table.schema +datasets[].tables[].source.type +datasets[].title +datasets[].update_frequency +deployment +deployment.evidence +deployment.evidence.api_key_rotation +deployment.evidence.audit_ack_cursor_path +deployment.evidence.audit_ack_max_age_secs +deployment.evidence.audit_offhost_shipping +deployment.evidence.ingress_rate_limit +deployment.profile +deployment.waivers +deployment.waivers[] +deployment.waivers[].expires +deployment.waivers[].finding +deployment.waivers[].reason +instance +instance.environment +instance.id +instance.jurisdiction +instance.owner +metadata +metadata.ecosystem_binding +metadata.ecosystem_binding.id +metadata.ecosystem_binding.version +metadata.source +metadata.source.digest +metadata.source.path +server +server.admin_bind +server.bind +server.cache_dir +server.cors +server.cors.allowed_origins +server.cors.allowed_origins[] +server.http1_header_read_timeout +server.max_connections +server.max_source_file_bytes +server.openapi_requires_auth +server.request_body_timeout +server.request_timeout +server.trust_proxy +server.trust_proxy.enabled +server.trust_proxy.trusted_proxies +server.trust_proxy.trusted_proxies[] +server.xlsx_max_file_bytes +standards +standards.spdci +standards.spdci.disability_registry +standards.spdci.disability_registry.dataset +standards.spdci.disability_registry.disabled_positive_values +standards.spdci.disability_registry.disabled_positive_values[] +standards.spdci.disability_registry.disabled_status_field +standards.spdci.disability_registry.entity +standards.spdci.disability_registry.query_field +standards.spdci.disability_registry.query_key +standards.spdci.registries +standards.spdci.registries.* +standards.spdci.registries.*.dataset +standards.spdci.registries.*.default_limit +standards.spdci.registries.*.entity +standards.spdci.registries.*.expression_fields +standards.spdci.registries.*.expression_fields.* +standards.spdci.registries.*.identifiers +standards.spdci.registries.*.identifiers.* +standards.spdci.registries.*.record_type +standards.spdci.registries.*.registry_type +standards.spdci.registries.*.response_fields +standards.spdci.registries.*.response_fields.* +standards.spdci.registries.*.response_mapping_path +standards.spdci.registries.*.response_schema_path +vocabularies +vocabularies.* +``` +{/* registry-relay-config-key-paths:end */} + `registry-relay` is configured by one YAML document. The binary chooses the first available source: 1. `--config ` @@ -28,6 +625,16 @@ standards: {} # optional, feature-gated adapters Unknown fields are rejected for most blocks. Config validation runs after YAML parsing and checks ids, scopes, table/entity references, filter references, aggregate references, env var presence, and vocabulary prefixes. +The complete deserialization-oriented Draft 2020-12 schema is committed at +[`schemas/registry-relay.config.schema.json`](../../../schemas/registry-relay.config.schema.json). +Reproduce it from this directory with `just config-schema-generate`, verify +drift with `just config-schema-check`, or print the exact same bytes with +`registry-relay schema --format json`. The schema checks document structure, +closed objects, tagged variants, scalar shapes, and constrained reference +syntax. `registry-relay doctor` remains authoritative for environment and +secret availability, filesystem and source access, activation rules, and +cross-field runtime validation. + ## Environment expansion Relay expands `${VAR}` expressions before YAML parsing. `${VAR}` requires @@ -37,6 +644,21 @@ Relay expands `${VAR}` expressions before YAML parsing. `${VAR}` requires Whitespace-only values are non-empty. Diagnostics name the variable or use the supplied message; they never include the variable value. +Environment-reference fields follow the invariant of their runtime consumer: + +- `auth.api_keys[].fingerprint.name` accepts any non-empty operating-system + environment name except names containing `=` or NUL. It does not impose an + identifier grammar or a 128-byte limit, and its consumer permits + whitespace-only names. +- `audit.hash_secret_env` uses the same operating-system name rules but must + contain at least one non-whitespace character, matching the audit runtime's + fail-closed empty-name check. Names containing dots or hyphens remain valid. +- Postgres `connection_env` uses `[A-Za-z_][A-Za-z0-9_]*`, matching source + validation, without an artificial length limit. +- Consultation database, credential, and pseudonym secret references use the + portable `[A-Za-z_][A-Za-z0-9_]{0,127}` grammar enforced during + deserialization and consultation operations. + A minimal entity-serving deployment needs `server` (a listener), `catalog` (public metadata base), `auth` (one auth mode), `audit` (a sink and hash secret), and at least one entry in `datasets`. A consultation-only deployment can use `datasets: []` when the complete `consultation` block activates at least one profile. Every other root block is optional. This example shows the required shape. @@ -203,12 +825,29 @@ server: trusted_proxies: [] ``` -`bind` is the public data-plane listener. `admin_bind` is optional and must be private in production. `cache_dir` must be writable by the process. Source data must be mounted read-only. +`bind` is the public data-plane listener. +`admin_bind` is optional and must be private in production. +Listener addresses use canonical dotted-decimal IPv4 or bracketed hexadecimal +IPv6 followed by a canonical decimal port from `0` through `65535`. +IPv4-embedded IPv6 and IPv6 zone identifiers are outside this portable config +grammar. +`cache_dir` must be writable by the process. +Source data must be mounted read-only. `openapi_requires_auth` defaults to `true`. Set it to `false` only for local testing or controlled tooling environments that need unauthenticated access to `/openapi.json`; the unauthenticated document includes the full configured OpenAPI surface. `request_timeout` bounds total request service time after HTTP headers are parsed. `request_body_timeout` bounds body reads for handlers that consume a request body. `http1_header_read_timeout` closes incomplete HTTP/1 headers before request work is admitted, and `max_connections` caps concurrent accepted sockets per listener. All timeouts must be non-zero and `max_connections` must be greater than zero. +Every duration field uses the same stable humantime subset. +A value contains one or more non-negative integer components of at most 10 +digits, separated by one ASCII space, with units `ns`, `us`, `ms`, `s`, `m`, +`h`, `d`, or `w`. +The complete value is at most 255 bytes. +Examples include `30s`, `10m`, `1h`, and `2h 37m`. +Bare numbers, negative or fractional components, long unit aliases, adjacent +components such as `1h30m`, and repeated spaces are rejected by both runtime +deserialization and the JSON Schema. + HTTP/2 connections use the same finite connection cap and keepalive timeout. If production terminates HTTP/2 at a reverse proxy, configure bounded proxy header/body read timeouts and per-client connection limits before forwarding to Registry Relay. The default CORS policy is deny by omission. Add explicit trusted origins only. @@ -582,7 +1221,7 @@ audit: hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET ``` -`hash_secret_env` is required at runtime and must name an environment variable containing at least 32 bytes of deployment-specific random secret material. Startup fails closed when it is missing, empty, unset, or weak. +`hash_secret_env` is required at runtime and must be a non-whitespace environment variable name containing no `=` or NUL. The named variable must contain at least 32 bytes of deployment-specific random secret material. Startup fails closed when the name is missing or whitespace-only, or when the variable is unset, empty, or weak. Registry Relay uses this secret to pseudonymize sensitive audit handles. Values for configured sensitive fields, record primary keys, table identifiers, and diff --git a/crates/registry-relay/justfile b/crates/registry-relay/justfile index d85d74d98..1417f242c 100644 --- a/crates/registry-relay/justfile +++ b/crates/registry-relay/justfile @@ -117,9 +117,17 @@ container-security: python3 scripts/check_security_assurance.py dockerfile-secrets # Run the monorepo CI preflight for Registry Relay. -ci-preflight: +ci-preflight: config-schema-check scripts/ci-preflight.sh +# Reproduce the committed Draft 2020-12 runtime configuration schema. +config-schema-generate: + cargo run --quiet --locked --bin registry-relay -- schema --format json > ../../schemas/registry-relay.config.schema.json + +# Fail when the product-owned runtime schema has drifted from Config. +config-schema-check: + tmp_schema="$(mktemp)"; trap 'rm -f "$tmp_schema"' EXIT; cargo run --quiet --locked --bin registry-relay -- schema --format json > "$tmp_schema"; cmp -s ../../schemas/registry-relay.config.schema.json "$tmp_schema" || { diff -u ../../schemas/registry-relay.config.schema.json "$tmp_schema"; exit 1; } + # Run the full CI gate locally: monorepo preflight, Docker build contract, # fmt-check, default/all-feature lint, default/all-feature tests, and cargo-deny. ci: ci-preflight docker-build-contract fmt-check lint-default lint test-default test deny openapi-contract diff --git a/crates/registry-relay/src/config/consultation_artifacts.rs b/crates/registry-relay/src/config/consultation_artifacts.rs index 77ec5db6c..efa4533e4 100644 --- a/crates/registry-relay/src/config/consultation_artifacts.rs +++ b/crates/registry-relay/src/config/consultation_artifacts.rs @@ -8,6 +8,7 @@ use std::path::{Component, Path, PathBuf}; use registry_platform_config::{read_config_file_limited, sha256_uri, VerifiedConfigBundle}; use registry_platform_ops::{is_sha256_config_hash, ConfigSource, DeploymentProfile}; +use schemars::JsonSchema; use serde::Deserialize; use thiserror::Error; @@ -23,14 +24,16 @@ const MAX_ENABLED_PROFILES: usize = 64; const MAX_TOTAL_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024; /// One hash-pinned public contract or reviewed integration pack. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationTypedArtifactReferenceConfig { /// Normalized bundle-root-relative path. pub path: PathBuf, /// Domain-separated typed artifact hash consumed by the source-plan compiler. + #[schemars(pattern(r"^sha256:[0-9a-f]{64}$"))] pub hash: String, /// Raw file hash recorded by Registry Config Bundle v1. + #[schemars(pattern(r"^sha256:[0-9a-f]{64}$"))] pub sha256: String, } @@ -44,12 +47,13 @@ impl fmt::Debug for ConsultationTypedArtifactReferenceConfig { } /// One hash-pinned private binding or standalone Rhai script. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationArtifactReferenceConfig { /// Normalized bundle-root-relative path. pub path: PathBuf, /// Raw file hash recorded by Registry Config Bundle v1. + #[schemars(pattern(r"^sha256:[0-9a-f]{64}$"))] pub sha256: String, } @@ -63,7 +67,7 @@ impl fmt::Debug for ConsultationArtifactReferenceConfig { } /// Closed evidence classes understood by consultation source-plan v1. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum ConsultationEvidenceClassConfig { Conformance, @@ -72,11 +76,12 @@ pub enum ConsultationEvidenceClassConfig { } /// One bounded, hash-pinned integration evidence file. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationEvidenceArtifactConfig { pub class: ConsultationEvidenceClassConfig, pub path: PathBuf, + #[schemars(pattern(r"^sha256:[0-9a-f]{64}$"))] pub sha256: String, } @@ -91,7 +96,7 @@ impl fmt::Debug for ConsultationEvidenceArtifactConfig { } /// Complete artifact catalog for all consultations enabled at one restart. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationArtifactClosureConfig { pub public_contracts: Vec, diff --git a/crates/registry-relay/src/config/mod.rs b/crates/registry-relay/src/config/mod.rs index ced84850e..11d7d5c7f 100644 --- a/crates/registry-relay/src/config/mod.rs +++ b/crates/registry-relay/src/config/mod.rs @@ -27,12 +27,14 @@ use std::time::Duration; use registry_platform_audit::pseudonym_keyring::AuditPseudonymKeyId; use registry_platform_authcommon::CredentialFingerprintRef; use registry_platform_ops::{AuditWritePolicy, DeploymentProfile}; +use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; pub mod capabilities; mod consultation_artifacts; pub mod governed; pub mod loader; +pub mod schema; #[cfg(test)] #[doc(hidden)] pub mod test_support; @@ -55,7 +57,7 @@ pub(crate) const MAX_AUDIT_PSEUDONYM_MATERIALS: usize = 32; pub(crate) const MAX_CONSULTATION_SOURCE_CREDENTIALS: usize = 128; /// Root configuration document. Parsed from YAML at startup. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default)] @@ -95,10 +97,11 @@ pub struct Config { /// environment or network position. When `profile` is absent the deployment /// is undeclared and refuses startup. A profile value that is not one of the /// known variants fails startup (fail closed on typos). -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct DeploymentConfig { #[serde(default)] + #[schemars(with = "Option")] pub profile: Option, /// Per-deployment waivers. Each names one finding id, a free-text reason, /// and a mandatory expiry date. Expired waivers stop suppressing their @@ -114,7 +117,7 @@ pub struct DeploymentConfig { /// One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is /// validated at load time. Reasons must not carry secrets. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct DeploymentWaiverConfig { pub finding: String, @@ -126,7 +129,7 @@ pub struct DeploymentWaiverConfig { /// observe directly. Each flag defaults to `false`, meaning "no evidence /// declared", which keeps the corresponding gate active until the operator /// asserts the control is in place out of band. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct DeploymentEvidenceConfig { /// Operator asserts ingress rate limiting is enforced (for example by a @@ -156,7 +159,7 @@ pub struct DeploymentEvidenceConfig { } /// Stable deployment identity surfaced in redacted operations posture. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct InstanceConfig { #[serde(default = "default_instance_id")] @@ -188,7 +191,7 @@ fn default_instance_id() -> String { /// /// Simple local deployments omit this block. Bundle-aware deployments pin the /// local trust anchor, bundle, and anti-rollback state paths explicitly. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigTrustConfig { pub trust_anchor_path: PathBuf, @@ -199,7 +202,7 @@ pub struct ConfigTrustConfig { } /// Optional split metadata manifest loaded alongside the runtime config. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct MetadataConfig { pub source: MetadataSourceConfig, @@ -207,7 +210,7 @@ pub struct MetadataConfig { pub ecosystem_binding: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct MetadataSourceConfig { pub path: PathBuf, @@ -215,7 +218,7 @@ pub struct MetadataSourceConfig { pub digest: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct EcosystemBindingSelectorConfig { pub id: String, @@ -224,7 +227,7 @@ pub struct EcosystemBindingSelectorConfig { } /// External standards adapters layered over configured entities. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct StandardsConfig { #[serde(default)] @@ -233,7 +236,7 @@ pub struct StandardsConfig { /// Social Protection Digital Convergence Initiative (SP DCI) adapter /// configuration. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct SpdciStandardsConfig { #[serde(default)] @@ -244,7 +247,7 @@ pub struct SpdciStandardsConfig { /// Runtime binding from a DCI registry sync search API to one configured /// Registry Relay entity. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct SpdciRegistryConfig { pub dataset: DatasetId, @@ -275,7 +278,7 @@ pub struct SpdciRegistryConfig { /// Runtime binding from SP DCI Disability Registry sync APIs to one /// configured Registry Relay entity. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct SpdciDisabilityRegistryConfig { pub dataset: DatasetId, @@ -326,11 +329,14 @@ fn default_spdci_search_limit() -> u32 { } /// HTTP listener and adjacent server-wide knobs. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ServerConfig { + #[serde(deserialize_with = "schema::deserialize_socket_addr")] + #[schemars(with = "schema::SocketAddrSchema")] pub bind: SocketAddr, - #[serde(default)] + #[serde(default, deserialize_with = "schema::deserialize_optional_socket_addr")] + #[schemars(with = "Option")] pub admin_bind: Option, #[serde(default = "default_openapi_requires_auth")] pub openapi_requires_auth: bool, @@ -344,14 +350,23 @@ pub struct ServerConfig { pub trust_proxy: TrustProxyConfig, #[serde(default)] pub cors: CorsConfig, - #[serde(default = "default_request_timeout", with = "humantime_serde")] + #[serde( + default = "default_request_timeout", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] pub request_timeout: Duration, - #[serde(default = "default_request_body_timeout", with = "humantime_serde")] + #[serde( + default = "default_request_body_timeout", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] pub request_body_timeout: Duration, #[serde( default = "default_http1_header_read_timeout", - with = "humantime_serde" + deserialize_with = "schema::deserialize_duration" )] + #[schemars(with = "schema::HumantimeDurationSchema")] pub http1_header_read_timeout: Duration, #[serde(default = "default_max_connections")] pub max_connections: usize, @@ -392,7 +407,7 @@ fn default_max_source_file_bytes() -> u64 { /// `X-Forwarded-For` policy. Until the `ipnet` crate lands in deps we /// keep CIDR specs as strings and validate format in /// [`validate::run`]. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct TrustProxyConfig { #[serde(default)] @@ -402,7 +417,7 @@ pub struct TrustProxyConfig { } /// CORS allowlist; default-deny per Section 17 item 7. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct CorsConfig { #[serde(default)] @@ -410,7 +425,7 @@ pub struct CorsConfig { } /// Catalog-level metadata surfaced by `/metadata/*` and DCAT outputs. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct CatalogConfig { pub title: String, @@ -442,7 +457,7 @@ pub struct CatalogConfig { /// Authentication configuration. Exactly one of `api_keys` and `oidc` /// is consumed at startup, gated by `mode`; cross-field validation in /// [`validate`] enforces that only the active block is populated. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AuthConfig { pub mode: AuthMode, @@ -462,7 +477,7 @@ pub struct AuthConfig { /// is expected to absorb abusive traffic before it reaches this process. /// Disabled by default so deployments that never set this block observe no /// behavior change. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AuthFailureThrottleConfig { #[serde(default)] @@ -494,7 +509,7 @@ fn default_auth_failure_throttle_window_seconds() -> u64 { /// Authentication mode tag. Drives the provider built at startup in /// `crate::auth`. A given deployment runs in exactly one mode at a time; /// mixed-mode operation is not supported. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AuthMode { /// Hashed shared secret in an environment variable. @@ -505,10 +520,11 @@ pub enum AuthMode { /// One configured API key, identified by an id and a fingerprint reference. /// The raw key never appears in config. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ApiKeyConfig { pub id: String, + #[schemars(with = "schema::CredentialFingerprintSchema")] pub fingerprint: CredentialFingerprintRef, #[serde(default)] pub scopes: Vec, @@ -517,7 +533,7 @@ pub struct ApiKeyConfig { /// OIDC / OAuth2 resource-server configuration. The relay validates /// incoming bearer JWTs against a configured external IdP. No tokens /// are minted here. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct OidcConfig { /// Issuer URL. Compared verbatim against the JWT `iss` claim. @@ -548,11 +564,19 @@ pub struct OidcConfig { /// JWKS cache TTL. Default 10 minutes. The provider also refreshes /// on unknown `kid` (rate-limited) so this controls the steady-state /// rotation pickup latency, not the upper bound. - #[serde(default = "default_oidc_jwks_cache_ttl", with = "humantime_serde")] + #[serde( + default = "default_oidc_jwks_cache_ttl", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] pub jwks_cache_ttl: Duration, /// Clock skew tolerance applied to `exp` and (when present) `nbf`. /// Default 60 seconds. Bounded at 5 minutes by validation. - #[serde(default = "default_oidc_leeway", with = "humantime_serde")] + #[serde( + default = "default_oidc_leeway", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] pub leeway: Duration, /// JWT claim whose value carries scopes. Defaults to `scope`, the /// RFC 8693 / RFC 9068 space-separated form. Some IdPs use `scp` @@ -593,7 +617,7 @@ pub struct OidcConfig { /// /// YAML values are the canonical JWA `alg` strings (`RS256`, `ES256`, /// `EdDSA`), case-sensitive. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] pub enum OidcAlgorithm { #[serde(rename = "RS256")] Rs256, @@ -634,7 +658,7 @@ fn default_oidc_token_types() -> Vec { /// and immutable, versioned secret references. PostgreSQL intentionally stores /// no secret-derived verifier, so replacing audit-pseudonym material behind an /// existing reference requires a new key id. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationConfig { /// Exact OIDC claims that identify the only caller allowed to execute @@ -689,7 +713,7 @@ impl ConsultationConfig { /// There is deliberately no automatic client-claim selection. Deployments /// must name exactly one verified claim, and later runtime compilation binds /// this configuration to the issuer from `auth.oidc`. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationWorkloadConfig { pub audience: String, @@ -699,7 +723,7 @@ pub struct ConsultationWorkloadConfig { } /// Closed set of verified OAuth claims that may identify Registry Notary. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConsultationClientClaimSelectorConfig { Azp, @@ -723,7 +747,7 @@ impl ConsultationClientClaimSelectorConfig { /// environment variable may be configured. Debug output also redacts that /// reference and the optional trust-root path so diagnostics do not disclose /// deployment secret topology. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ConsultationStatePlaneConfig { pub database_url_env: ConsultationDatabaseUrlEnvironmentName, @@ -751,8 +775,11 @@ impl fmt::Debug for ConsultationStatePlaneConfig { } /// Portable environment-variable name that resolves the state-plane URL. -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct ConsultationDatabaseUrlEnvironmentName(String); +#[derive(Clone, PartialEq, Eq, Hash, JsonSchema)] +#[serde(transparent)] +pub struct ConsultationDatabaseUrlEnvironmentName( + #[schemars(pattern(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$"))] String, +); impl ConsultationDatabaseUrlEnvironmentName { fn parse(value: String) -> Result { @@ -787,7 +814,7 @@ impl fmt::Debug for ConsultationDatabaseUrlEnvironmentName { /// The catalog contains references, never credential values. Full one-to-one /// closure against the compiled source-plan registry is enforced before any /// environment variable is read. -#[derive(Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Clone, Default, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(transparent)] pub struct ConsultationSourceCredentialCatalogConfig(Vec); @@ -812,7 +839,7 @@ impl fmt::Debug for ConsultationSourceCredentialCatalogConfig { /// Environment names are opaque references and are redacted from `Debug`. /// There is deliberately no field capable of carrying an embedded username, /// password, bearer token, or provider-specific extension. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum ConsultationSourceCredentialConfig { Basic { @@ -939,8 +966,11 @@ impl fmt::Debug for ConsultationSourceCredentialConfig { } /// Exact private-binding credential reference grammar. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ConsultationSourceCredentialReference(Box); +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, JsonSchema)] +#[serde(transparent)] +pub struct ConsultationSourceCredentialReference( + #[schemars(pattern(r"^[a-z][a-z0-9._-]{0,95}$"))] Box, +); impl ConsultationSourceCredentialReference { fn parse(value: String) -> Result { @@ -978,8 +1008,11 @@ fn is_consultation_source_credential_reference(value: &str) -> bool { } /// Portable environment-variable name used only as a credential reference. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ConsultationCredentialEnvironmentName(String); +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, JsonSchema)] +#[serde(transparent)] +pub struct ConsultationCredentialEnvironmentName( + #[schemars(pattern(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$"))] String, +); impl ConsultationCredentialEnvironmentName { fn parse(value: String) -> Result { @@ -1013,7 +1046,7 @@ impl fmt::Debug for ConsultationCredentialEnvironmentName { /// /// The 1..=32 bound and cross-entry uniqueness are enforced by config /// validation and repeated by the material provider before loading secrets. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(transparent)] pub struct AuditPseudonymMaterialCatalogConfig(Vec); @@ -1031,9 +1064,10 @@ impl fmt::Debug for AuditPseudonymMaterialCatalogConfig { } /// One public epoch id bound to one secret source reference. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct AuditPseudonymMaterialConfig { + #[schemars(with = "schema::AuditPseudonymKeyIdSchema")] pub key_id: AuditPseudonymKeyId, pub source: AuditPseudonymSecretSourceConfig, } @@ -1042,7 +1076,7 @@ pub struct AuditPseudonymMaterialConfig { /// /// The configured name is a reference only. Secret values cannot be embedded /// in this model and are loaded exactly once during runtime compilation. -#[derive(Clone, Deserialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)] pub enum AuditPseudonymSecretSourceConfig { Environment { @@ -1074,8 +1108,11 @@ impl fmt::Debug for AuditPseudonymSecretSourceConfig { /// /// Debug output is redacted even though the name is not itself key material, /// preventing configuration diagnostics from disclosing secret topology. -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct AuditPseudonymSecretEnvironmentName(String); +#[derive(Clone, PartialEq, Eq, Hash, JsonSchema)] +#[serde(transparent)] +pub struct AuditPseudonymSecretEnvironmentName( + #[schemars(pattern(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$"))] String, +); impl AuditPseudonymSecretEnvironmentName { fn parse(value: String) -> Result { @@ -1129,7 +1166,8 @@ fn is_portable_environment_name(value: &str) -> bool { /// not support combining it with `#[serde(flatten)]` on an internally /// tagged enum (unknown keys in `audit` are caught by the enum's own /// `deny_unknown_fields`). -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] +#[schemars(extend("unevaluatedProperties" = false))] pub struct AuditConfig { #[serde(flatten)] pub sink: AuditSinkConfig, @@ -1151,6 +1189,7 @@ pub struct AuditConfig { /// Per-route-family selection is out of scope; this is a single /// deployment-wide policy. #[serde(default = "default_audit_write_policy")] + #[schemars(with = "schema::AuditWritePolicySchema")] pub write_policy: AuditWritePolicy, /// Name of the environment variable holding the per-deploy secret /// used to HMAC sensitive audit values (single-record primary keys, @@ -1159,6 +1198,7 @@ pub struct AuditConfig { /// weak secret. Direct middleware tests can opt into the explicit /// unkeyed dev-only hasher without using runtime config. #[serde(default)] + #[schemars(with = "Option")] pub hash_secret_env: Option, } @@ -1171,7 +1211,7 @@ fn default_audit_write_policy() -> AuditWritePolicy { } /// Audit serialisation format. JSONL is the only V1 format. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum AuditFormat { @@ -1181,7 +1221,7 @@ pub enum AuditFormat { /// Audit sink tagged on `sink:` per the YAML example. `file` carries /// the rotation policy inline. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "sink", rename_all = "snake_case", deny_unknown_fields)] #[non_exhaustive] pub enum AuditSinkConfig { @@ -1195,7 +1235,7 @@ pub enum AuditSinkConfig { } /// In-process rotation for the `file` audit sink. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct RotateConfig { pub max_size_mb: u64, @@ -1214,7 +1254,7 @@ impl Default for RotateConfig { } /// BRegDCAT-AP `adms:status` vocabulary. Maps to the EU ADMS status codelists. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AdmsStatus { UnderDevelopment, @@ -1224,7 +1264,7 @@ pub enum AdmsStatus { } /// A single dataset declaration. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct DatasetConfig { pub id: DatasetId, @@ -1265,7 +1305,7 @@ pub struct DatasetConfig { pub aggregates: Vec, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct PublicServiceConfig { #[serde(default)] @@ -1276,7 +1316,7 @@ pub struct PublicServiceConfig { } /// Optional table defaults for reducing repetition within one dataset. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct DatasetDefaultsConfig { #[serde(default)] @@ -1294,7 +1334,7 @@ impl DatasetConfig { /// Source plugin selection. Tagged on `type:` so HTTP, S3, or additional /// database variants can land additively later. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] #[non_exhaustive] pub enum SourceConfig { @@ -1304,6 +1344,7 @@ pub enum SourceConfig { format: Option, }, Postgres { + #[schemars(with = "schema::PostgresEnvironmentNameSchema")] connection_env: String, #[serde(default)] table: Option, @@ -1311,9 +1352,17 @@ pub enum SourceConfig { query: Option, #[serde(default)] change_token_sql: Option, - #[serde(default = "default_postgres_connect_timeout", with = "humantime_serde")] + #[serde( + default = "default_postgres_connect_timeout", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] connect_timeout: Duration, - #[serde(default = "default_postgres_query_timeout", with = "humantime_serde")] + #[serde( + default = "default_postgres_query_timeout", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] query_timeout: Duration, }, } @@ -1329,7 +1378,7 @@ impl SourceConfig { /// Structured database table reference. Keeping schema/name separate /// avoids parsing dotted identifiers and leaves quoting to connectors. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct PostgresTableConfig { pub schema: String, @@ -1345,18 +1394,23 @@ fn default_postgres_query_timeout() -> Duration { } /// Refresh policy. Tagged on `mode:`. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] #[non_exhaustive] pub enum RefreshConfig { /// Poll source mtime on `interval` and re-ingest on change. Mtime { - #[serde(default = "default_mtime_interval", with = "humantime_serde")] + #[serde( + default = "default_mtime_interval", + deserialize_with = "schema::deserialize_duration" + )] + #[schemars(with = "schema::HumantimeDurationSchema")] interval: Duration, }, /// Unconditionally re-ingest on `interval`. Interval { - #[serde(with = "humantime_serde")] + #[serde(deserialize_with = "schema::deserialize_duration")] + #[schemars(with = "schema::HumantimeDurationSchema")] interval: Duration, }, /// Re-ingest only on explicit admin call. @@ -1369,7 +1423,7 @@ fn default_mtime_interval() -> Duration { } /// How a configured private table is registered for query planning. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum MaterializationMode { Snapshot, @@ -1380,7 +1434,7 @@ pub enum MaterializationMode { /// The public API should not expose these ids. Entity config maps one /// resource into one domain resource, with optional field renaming and /// relationship declarations. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ResourceConfig { pub id: ResourceId, @@ -1402,7 +1456,7 @@ pub struct ResourceConfig { /// Storage table format override. If omitted, ingest infers the format /// from the source file extension. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ResourceFormatConfig { #[serde(default)] @@ -1413,7 +1467,7 @@ pub struct ResourceFormatConfig { pub parquet: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct CsvFormatConfig { #[serde(default)] @@ -1424,7 +1478,7 @@ pub struct CsvFormatConfig { pub quote: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct XlsxFormatConfig { #[serde(default)] @@ -1435,7 +1489,7 @@ pub struct XlsxFormatConfig { pub data_range: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ParquetFormatConfig {} @@ -1511,7 +1565,7 @@ impl ResourceConfig { } } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct EntityConfig { pub name: String, @@ -1546,7 +1600,7 @@ pub struct EntityConfig { /// that declares a `purpose` requires a matching `data-purpose` at resolve time; /// one that omits it does not. Identified globally by the `(id, version)` pair; /// both are required path segments at resolve time. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AttributeReleaseProfile { /// Profile identifier, lower-kebab/snake (`^[a-z][a-z0-9_-]*$`). Globally @@ -1579,7 +1633,7 @@ pub struct AttributeReleaseProfile { } /// Subject-identification controls for an attribute-release profile. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseSubjectConfig { /// Request input that carries the subject identifier. @@ -1595,7 +1649,7 @@ pub struct ReleaseSubjectConfig { } /// Expected number of subjects a release lookup may match. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SubjectCardinality { One, @@ -1608,7 +1662,7 @@ fn default_subject_cardinality() -> SubjectCardinality { /// CEL release-condition gate. When present, the predicate must hold before /// any claim is projected; failure fails closed (subject denied). -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseConditionsConfig { pub expression: ReleaseExpressionConfig, @@ -1618,7 +1672,7 @@ pub struct ReleaseConditionsConfig { } /// A single CEL expression evaluated over the subject's source projection. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseExpressionConfig { pub cel: String, @@ -1627,7 +1681,7 @@ pub struct ReleaseExpressionConfig { /// A single released claim. Exactly one of `source_field` or `expression` /// must be set: a claim is either a direct source-field projection or a /// CEL-computed value. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseClaimConfig { /// Released claim name (lower-snake). @@ -1662,7 +1716,7 @@ fn default_claim_shareable() -> bool { /// Closed privacy-sensitivity classification for a released claim. This is a /// separate, release-specific taxonomy and is intentionally not the /// dataset-level `Sensitivity` enum. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ClaimSensitivity { DirectIdentifier, @@ -1672,7 +1726,7 @@ pub enum ClaimSensitivity { } /// Response-envelope controls for an attribute-release profile. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseResponseConfig { /// Whether to include profile-sourced metadata in the response body. @@ -1685,7 +1739,7 @@ pub struct ReleaseResponseConfig { pub const CRS84: &str = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"; -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct EntitySpatialConfig { #[serde(default)] @@ -1705,7 +1759,7 @@ pub struct EntitySpatialConfig { pub max_geometry_vertices: u32, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum SpatialGeometryConfig { Point { @@ -1727,7 +1781,7 @@ pub enum SpatialGeometryConfig { }, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct SpatialBboxFieldsConfig { pub min_x: String, @@ -1744,7 +1798,7 @@ fn default_max_geometry_vertices() -> u32 { 10_000 } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct EntityFieldConfig { pub name: String, @@ -1762,7 +1816,7 @@ pub struct EntityFieldConfig { pub language: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct EntityRelationshipConfig { pub name: String, @@ -1773,7 +1827,7 @@ pub struct EntityRelationshipConfig { pub concept_uri: Option, } -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RelationshipKind { BelongsTo, @@ -1781,7 +1835,7 @@ pub enum RelationshipKind { HasOne, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct EntityAccessConfig { pub metadata_scope: String, @@ -1791,7 +1845,7 @@ pub struct EntityAccessConfig { pub evidence_verification_scope: String, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct EntityApiConfig { pub default_limit: u32, @@ -1815,7 +1869,7 @@ pub struct EntityApiConfig { pub allowed_expansions: Vec, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct RequiredFilterBindingConfig { pub field: String, @@ -1823,14 +1877,14 @@ pub struct RequiredFilterBindingConfig { pub source: RequiredFilterBindingSource, } -#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RequiredFilterBindingSource { #[default] PrincipalId, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct GovernedPolicyConfig { #[serde(default)] @@ -1853,7 +1907,7 @@ pub struct GovernedPolicyConfig { pub trusted_context: GovernedTrustedContextConfig, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct GovernedTrustedContextConfig { #[serde(default)] @@ -1870,7 +1924,7 @@ pub struct GovernedTrustedContextConfig { /// Declared resource schema. `strict` is the spec's `strict_schema` /// flag; on mismatch ingestion refuses to register the resource. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct SchemaConfig { #[serde(default)] @@ -1880,7 +1934,7 @@ pub struct SchemaConfig { /// One column in a resource schema. Physical type and optional /// semantic annotations used by catalog and schema metadata. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct FieldConfig { pub name: String, @@ -1901,7 +1955,7 @@ pub struct FieldConfig { /// Physical type of a column. The set is fixed in V1; semantic types /// are carried via `concept_uri`. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FieldType { String, @@ -1914,7 +1968,7 @@ pub enum FieldType { /// Resource-level scope assignments. Private tables are not exposed as row /// resources in beta; row access is configured on public entities. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ResourceAccessConfig { pub metadata_scope: String, @@ -1923,7 +1977,7 @@ pub struct ResourceAccessConfig { /// Resource-level API knobs: per-field filter allowlist, limit caps, /// and the `Data-Purpose` requirement. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ResourceApiConfig { pub default_limit: u32, @@ -1946,7 +2000,7 @@ impl Default for ResourceApiConfig { } /// A single allowed filter: field name + permitted operators. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AllowedFilter { pub field: String, @@ -1954,7 +2008,7 @@ pub struct AllowedFilter { } /// Filter operator opted into per field. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum FilterOp { Eq, @@ -1966,7 +2020,7 @@ pub enum FilterOp { /// Aggregate declaration: group-by columns, measures, disclosure /// control. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AggregateConfig { pub id: AggregateId, @@ -2009,7 +2063,7 @@ pub struct AggregateConfig { pub disclosure_control: DisclosureControlConfig, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AggregateAccessConfig { #[serde(default)] @@ -2020,7 +2074,7 @@ pub struct AggregateAccessConfig { pub aggregate_only_execution: bool, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AggregateDimensionConfig { pub id: String, @@ -2030,7 +2084,7 @@ pub struct AggregateDimensionConfig { pub codelist: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AggregateIndicatorConfig { pub id: String, @@ -2048,7 +2102,7 @@ pub struct AggregateIndicatorConfig { pub definition_uri: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] pub enum AggregateSpatialConfig { AdminArea { @@ -2065,14 +2119,14 @@ pub enum AggregateSpatialConfig { }, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AggregateJoinConfig { pub relationship: String, } /// One measure inside an aggregate. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AggregateMeasure { pub name: String, @@ -2082,7 +2136,7 @@ pub struct AggregateMeasure { /// Aggregate function. V1 supports the basic set plus the /// optional functions (`median`, `count_distinct`, `stddev`). -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AggregateFunction { Count, @@ -2097,7 +2151,7 @@ pub enum AggregateFunction { /// Disclosure control settings per aggregate. Defaults to /// `min_group_size: 5`, `suppression: omit`. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct DisclosureControlConfig { #[serde(default = "default_disclosure_methods")] @@ -2128,7 +2182,7 @@ impl DisclosureControlConfig { } /// Disclosure suppression strategy. -#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Suppression { /// Remove rows below the threshold from the response entirely. @@ -2142,7 +2196,7 @@ pub enum Suppression { /// Sensitivity classification. Operator-defined values cover common /// personal and public dataset classifications in V1. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum Sensitivity { @@ -2154,7 +2208,7 @@ pub enum Sensitivity { } /// Access rights classification, mirrors DCAT-AP `dcterms:accessRights`. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum AccessRights { @@ -2165,7 +2219,7 @@ pub enum AccessRights { /// Update cadence; mirrors DCAT-AP `dcterms:accrualPeriodicity`. The /// V1 set is the codes used by the example plus the common alternates. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum UpdateFrequency { @@ -2186,19 +2240,19 @@ pub enum UpdateFrequency { // --------------------------------------------------------------------- /// Dataset identifier. Lower-snake, starts with a letter. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(transparent)] -pub struct DatasetId(String); +pub struct DatasetId(#[schemars(pattern(r"^[a-z][a-z0-9_]*$"))] String); /// Resource identifier within a dataset. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(transparent)] -pub struct ResourceId(String); +pub struct ResourceId(#[schemars(pattern(r"^[a-z][a-z0-9_]*$"))] String); /// Aggregate identifier within a resource. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(transparent)] -pub struct AggregateId(String); +pub struct AggregateId(#[schemars(pattern(r"^[a-z][a-z0-9_]*$"))] String); macro_rules! impl_id { ($ty:ident) => { diff --git a/crates/registry-relay/src/config/schema.rs b/crates/registry-relay/src/config/schema.rs new file mode 100644 index 000000000..f4ae91637 --- /dev/null +++ b/crates/registry-relay/src/config/schema.rs @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Reproducible Draft 2020-12 schema for the complete Relay runtime config. + +#![allow( + dead_code, + reason = "schema adapter types are compile-time descriptions and are never constructed" +)] + +use std::borrow::Cow; +use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6}; +use std::path::PathBuf; +use std::time::Duration; + +use schemars::{generate::SchemaSettings, json_schema, JsonSchema, Schema, SchemaGenerator}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value}; + +use super::Config; + +/// Stable identifier for the product-owned Relay runtime configuration schema. +pub const CONFIG_SCHEMA_ID: &str = + "https://id.registrystack.org/schemas/registry-relay/registry-relay.config.schema.json"; + +const IPV4_SOCKET_PATTERN: &str = concat!( + "^(?:", + r"(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.", + "){3}", + r"(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]):", + r"(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" +); +const IPV6_SOCKET_PATTERN: &str = concat!( + r"^\[(?:", + r"(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|", + r"(?:[0-9A-Fa-f]{1,4}:){1,7}:|", + r"(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|", + r"(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|", + r"(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|", + r"(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|", + r"(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|", + r"[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|", + r":(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)", + r")\]:", + r"(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" +); + +const DURATION_PATTERN: &str = + r"^[0-9]{1,10}(?:ns|us|ms|s|m|h|d|w)(?: [0-9]{1,10}(?:ns|us|ms|s|m|h|d|w))*$"; +const MAX_DURATION_TEXT_BYTES: usize = 255; + +/// Schema-only string contract for YAML listener socket addresses. +pub(crate) struct SocketAddrSchema; + +impl JsonSchema for SocketAddrSchema { + fn schema_name() -> Cow<'static, str> { + "SocketAddr".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "Canonical dotted-decimal IPv4 or bracketed IPv6 plus a decimal port from 0 through 65535", + "oneOf": [ + { "type": "string", "pattern": IPV4_SOCKET_PATTERN }, + { "type": "string", "pattern": IPV6_SOCKET_PATTERN } + ] + }) + } +} + +/// Schema-only string contract for the stable Relay humantime subset. +pub(crate) struct HumantimeDurationSchema; + +impl JsonSchema for HumantimeDurationSchema { + fn schema_name() -> Cow<'static, str> { + "HumantimeDuration".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "One or more non-negative integer duration components separated by one ASCII space; supported units are ns, us, ms, s, m, h, d, and w", + "type": "string", + "pattern": DURATION_PATTERN, + "maxLength": MAX_DURATION_TEXT_BYTES + }) + } +} + +#[derive(JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DeploymentProfileSchema { + Local, + HostedLab, + Production, + EvidenceGrade, +} + +#[derive(JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AuditWritePolicySchema { + AvailabilityFirst, + FailClosed, + FailClosedRouteFamilies, +} + +#[derive(JsonSchema)] +#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum CredentialFingerprintSchema { + Env { + #[schemars(with = "RuntimeEnvironmentNameSchema")] + name: RuntimeEnvironmentNameSchema, + }, + File { + path: PathBuf, + }, +} + +#[derive(JsonSchema)] +#[serde(transparent)] +pub(crate) struct AuditPseudonymKeyIdSchema( + #[schemars(pattern(r"^[a-z0-9][a-z0-9._-]{0,63}$"))] String, +); + +#[derive(JsonSchema)] +#[serde(transparent)] +pub(crate) struct RuntimeEnvironmentNameSchema(#[schemars(pattern(r"^[^=\x00]+$"))] String); + +#[derive(JsonSchema)] +#[serde(transparent)] +pub(crate) struct AuditHashSecretEnvironmentNameSchema( + #[schemars(pattern( + r"^[^=\x00]*[^=\x00\x09-\x0D\x20\x85\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000][^=\x00]*$" + ))] + String, +); + +#[derive(JsonSchema)] +#[serde(transparent)] +pub(crate) struct PostgresEnvironmentNameSchema( + #[schemars(pattern(r"^[A-Za-z_][A-Za-z0-9_]*$"))] String, +); + +pub(super) fn deserialize_socket_addr<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + parse_socket_addr(&value).map_err(serde::de::Error::custom) +} + +pub(super) fn deserialize_optional_socket_addr<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(|value| parse_socket_addr(&value)) + .transpose() + .map_err(serde::de::Error::custom) +} + +fn parse_socket_addr(value: &str) -> Result { + let parsed = value + .parse::() + .map_err(|_| "listener address must use portable socket syntax")?; + let port = match parsed { + SocketAddr::V4(address) => portable_ipv4_port(value, &address)?, + SocketAddr::V6(address) => portable_ipv6_port(value, &address)?, + }; + if port == parsed.port().to_string() { + Ok(parsed) + } else { + Err("listener port must use canonical decimal syntax") + } +} + +fn portable_ipv4_port<'a>(value: &'a str, address: &SocketAddrV4) -> Result<&'a str, &'static str> { + let (host, port) = value + .split_once(':') + .ok_or("IPv4 listener address must include a port")?; + if host == address.ip().to_string() { + Ok(port) + } else { + Err("IPv4 listener address must use canonical dotted-decimal syntax") + } +} + +fn portable_ipv6_port<'a>( + value: &'a str, + _address: &SocketAddrV6, +) -> Result<&'a str, &'static str> { + let (host, port) = value + .strip_prefix('[') + .and_then(|value| value.split_once("]:")) + .ok_or("IPv6 listener address must be bracketed and include a port")?; + if !host.is_empty() + && host + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b':') + { + Ok(port) + } else { + Err("IPv6 listener address must use hexadecimal address syntax without a zone id") + } +} + +pub(super) fn deserialize_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + if !is_portable_duration(&value) { + return Err(serde::de::Error::custom( + "duration must use the portable Relay humantime syntax", + )); + } + humantime_serde::re::humantime::parse_duration(&value).map_err(serde::de::Error::custom) +} + +fn is_portable_duration(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_DURATION_TEXT_BYTES + && value.split(' ').all(|component| { + let digit_count = component.bytes().take_while(u8::is_ascii_digit).count(); + (1..=10).contains(&digit_count) + && matches!( + &component[digit_count..], + "ns" | "us" | "ms" | "s" | "m" | "h" | "d" | "w" + ) + }) +} + +fn add_integer_bounds(value: &mut Value) { + match value { + Value::Array(values) => values.iter_mut().for_each(add_integer_bounds), + Value::Object(object) => { + add_integer_bounds_to_object(object); + object.values_mut().for_each(add_integer_bounds); + } + _ => {} + } +} + +fn add_integer_bounds_to_object(object: &mut Map) { + if !has_integer_type(object.get("type")) { + return; + } + let Some(format) = object.get("format").and_then(Value::as_str) else { + return; + }; + let Some((minimum, maximum)) = integer_bounds(format) else { + return; + }; + object.entry("minimum").or_insert(minimum); + object.entry("maximum").or_insert(maximum); +} + +fn has_integer_type(schema_type: Option<&Value>) -> bool { + match schema_type { + Some(Value::String(schema_type)) => schema_type == "integer", + Some(Value::Array(schema_types)) => schema_types + .iter() + .any(|schema_type| schema_type.as_str() == Some("integer")), + _ => false, + } +} + +fn integer_bounds(format: &str) -> Option<(Value, Value)> { + let bounds = match format { + "int8" => (i8::MIN.into(), i8::MAX.into()), + "int16" => (i16::MIN.into(), i16::MAX.into()), + "int32" => (i32::MIN.into(), i32::MAX.into()), + "int64" => (i64::MIN.into(), i64::MAX.into()), + "int" => ((isize::MIN as i64).into(), (isize::MAX as i64).into()), + "uint8" => (0.into(), u8::MAX.into()), + "uint16" => (0.into(), u16::MAX.into()), + "uint32" => (0.into(), u32::MAX.into()), + "uint64" => (0.into(), u64::MAX.into()), + "uint" => (0.into(), (usize::MAX as u64).into()), + _ => return None, + }; + Some(bounds) +} + +/// Generate the deserialization contract for [`Config`]. +#[must_use] +pub fn document() -> Value { + let schema = SchemaSettings::draft2020_12() + .into_generator() + .into_root_schema_for::(); + let mut value = serde_json::to_value(schema).expect("JSON Schema serializes to JSON"); + let root = value.as_object_mut().expect("root schema is an object"); + root.insert( + "$id".to_string(), + Value::String(CONFIG_SCHEMA_ID.to_string()), + ); + root.insert( + "title".to_string(), + Value::String("Registry Relay config".to_string()), + ); + add_integer_bounds(&mut value); + value +} + +/// Serialize the generated schema deterministically with exactly one trailing LF. +#[must_use] +pub fn document_json() -> String { + let mut output = serde_json::to_string_pretty(&document()).expect("JSON Schema serializes"); + output.push('\n'); + output +} + +#[cfg(test)] +mod tests { + use jsonschema::{Draft, JSONSchema}; + use schemars::schema_for; + use serde_json::json; + + use super::*; + + #[derive(Deserialize, JsonSchema)] + #[serde(deny_unknown_fields)] + struct NullableIntegerFixture { + signed: Option, + unsigned: Option, + } + + #[test] + fn integer_bound_postprocessor_handles_nullable_integer_type_arrays() { + let mut schema = + serde_json::to_value(schema_for!(NullableIntegerFixture)).expect("schema serializes"); + add_integer_bounds(&mut schema); + + assert_eq!(schema["properties"]["signed"]["minimum"], json!(i64::MIN)); + assert_eq!(schema["properties"]["signed"]["maximum"], json!(i64::MAX)); + assert_eq!(schema["properties"]["unsigned"]["minimum"], json!(0)); + assert_eq!(schema["properties"]["unsigned"]["maximum"], json!(u32::MAX)); + + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(&schema) + .expect("nullable integer fixture schema compiles"); + for valid in [ + json!({"signed": i64::MIN, "unsigned": 0}), + json!({"signed": i64::MAX, "unsigned": u32::MAX}), + json!({"signed": null, "unsigned": null}), + ] { + assert!(compiled.is_valid(&valid)); + assert!(serde_json::from_value::(valid).is_ok()); + } + for invalid in [ + json!({"signed": 9223372036854775808_u64, "unsigned": 0}), + json!({"signed": 0, "unsigned": -1}), + json!({"signed": 0, "unsigned": u64::from(u32::MAX) + 1}), + ] { + assert!(!compiled.is_valid(&invalid)); + assert!(serde_json::from_value::(invalid).is_err()); + } + } +} diff --git a/crates/registry-relay/src/main.rs b/crates/registry-relay/src/main.rs index 339027b24..679ae95ba 100644 --- a/crates/registry-relay/src/main.rs +++ b/crates/registry-relay/src/main.rs @@ -102,7 +102,7 @@ const DOCTOR_COMMAND: &str = "doctor"; /// Prints a redacted resolved configuration explanation. const EXPLAIN_CONFIG_COMMAND: &str = "explain-config"; -/// Prints a lightweight JSON schema for top-level config discovery. +/// Prints the complete product-owned Relay runtime configuration schema. const SCHEMA_COMMAND: &str = "schema"; /// Top-level namespace for operator configuration commands. @@ -546,7 +546,7 @@ async fn run_explain_config( async fn run_schema(format: OutputFormat) -> Result<(), Box> { match format { OutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&lightweight_schema())?); + print!("{}", config::schema::document_json()); Ok(()) } } @@ -1965,30 +1965,6 @@ fn parse_output_format(value: String) -> Result { } } -fn lightweight_schema() -> Value { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Registry Relay config", - "type": "object", - "required": ["server", "catalog", "auth", "audit", "datasets"], - "properties": { - "instance": { "type": "object" }, - "server": { "type": "object" }, - "config_trust": { "type": "object" }, - "metadata": { "type": "object" }, - "catalog": { "type": "object" }, - "vocabularies": { "type": "object" }, - "auth": { "type": "object" }, - "audit": { "type": "object" }, - "consultation": { "type": ["object", "null"] }, - "datasets": { "type": "array" }, - "standards": { "type": "object" }, - "deployment": { "type": "object" } - }, - "additionalProperties": false - }) -} - fn parse_deployment_profile(value: String) -> Result { match value.as_str() { "local" => Ok(DeploymentProfile::Local), @@ -2613,8 +2589,8 @@ fn install_sigterm_listener() -> io::Result { #[cfg(test)] mod tests { use super::{ - build_audit_chain_profile, build_audit_sink, compile_relay_runtime, lightweight_schema, - load_env_file_arg, parse_cli_command_from, parse_env_file_value, redacted_resolved_config, + build_audit_chain_profile, build_audit_sink, compile_relay_runtime, load_env_file_arg, + parse_cli_command_from, parse_env_file_value, redacted_resolved_config, relay_config_value_classification, relay_live_apply_classes, render_generated_api_key, required_env_report, run_audit_quarantine, run_healthcheck, url_contains_userinfo, CliCommand, ConfigValueClassification, ConsultationBootstrapStateCommand, @@ -3402,12 +3378,15 @@ audit: } #[test] - fn lightweight_schema_declares_optional_consultation_root() { - let schema = lightweight_schema(); - assert_eq!( - schema["properties"]["consultation"]["type"], - json!(["object", "null"]) - ); + fn config_schema_declares_optional_consultation_root() { + let schema = registry_relay::config::schema::document(); + let variants = schema["properties"]["consultation"]["anyOf"] + .as_array() + .expect("optional consultation uses alternatives"); + assert!(variants.iter().any(|variant| variant["type"] == "null")); + assert!(variants + .iter() + .any(|variant| { variant["$ref"] == "#/$defs/ConsultationConfig" })); assert!(!schema["required"] .as_array() .expect("required is an array") diff --git a/crates/registry-relay/tests/config_schema.rs b/crates/registry-relay/tests/config_schema.rs new file mode 100644 index 000000000..c55830e65 --- /dev/null +++ b/crates/registry-relay/tests/config_schema.rs @@ -0,0 +1,921 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeSet, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use jsonschema::{Draft, JSONSchema}; +use registry_platform_audit::{AuditChainProfile, AuditError}; +use registry_relay::config::schema::{document, document_json, CONFIG_SCHEMA_ID}; +use registry_relay::config::Config; +use serde_json::{json, Value}; + +const SCHEMA_ARTIFACT: &str = "../../schemas/registry-relay.config.schema.json"; +const CONFIG_REFERENCE: &str = "docs/configuration.md"; +const KEY_PATHS_START: &str = "{/* registry-relay-config-key-paths:start */}"; +const KEY_PATHS_END: &str = "{/* registry-relay-config-key-paths:end */}"; +const NON_PORTABLE_ENVIRONMENT_NAME: &str = "REGISTRY.RELAY-SCHEMA-RUNTIME-KEY"; +const NON_PORTABLE_ENVIRONMENT_VALUE: &str = "0123456789abcdef0123456789abcdef"; +const NON_PORTABLE_ENVIRONMENT_CHILD: &str = "REGISTRY_RELAY_SCHEMA_ENV_CHILD"; +const WHITESPACE_AUDIT_ENVIRONMENT_CHILD: &str = "REGISTRY_RELAY_SCHEMA_WHITESPACE_AUDIT_ENV_CHILD"; +const UNICODE_AUDIT_ENVIRONMENT_CHILD: &str = "REGISTRY_RELAY_SCHEMA_UNICODE_AUDIT_ENV_CHILD"; +const NEXT_LINE: &str = "\u{0085}"; +const ZERO_WIDTH_NO_BREAK_SPACE: &str = "\u{feff}"; + +fn relay_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn parse_yaml(path: &Path) -> Value { + let text = fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + serde_norway::from_str(&text) + .unwrap_or_else(|error| panic!("failed to parse {} as YAML: {error}", path.display())) +} + +fn compile_schema(schema: &Value) -> JSONSchema { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(schema) + .unwrap_or_else(|error| panic!("Relay schema must compile as Draft 2020-12: {error}")) +} + +fn assert_valid(schema: &Value, instance: &Value, label: &str) { + let compiled = compile_schema(schema); + if let Err(errors) = compiled.validate(instance) { + let details = errors.map(|error| error.to_string()).collect::>(); + panic!("{label} must validate against the Relay schema: {details:#?}"); + }; +} + +fn assert_invalid(schema: &Value, instance: &Value, label: &str) { + assert!( + !compile_schema(schema).is_valid(instance), + "{label} must be rejected by the Relay schema" + ); +} + +fn assert_runtime_deserializes(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + serde_norway::from_str::(&yaml) + .unwrap_or_else(|error| panic!("{label} must deserialize at runtime: {error}")); +} + +fn assert_runtime_rejects(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + assert!( + serde_norway::from_str::(&yaml).is_err(), + "{label} must be rejected during runtime deserialization" + ); +} + +fn example_config() -> Value { + parse_yaml(&relay_root().join("config/example.yaml")) +} + +fn oidc_config() -> Value { + parse_yaml(&relay_root().join("config/example.oidc.yaml")) +} + +fn consultation_config() -> Value { + parse_yaml( + &relay_root().join("profiles/dhis2-2.41.9-enrollment-status/relay-config.example.yaml"), + ) +} + +fn postgres_config() -> Value { + let mut config = example_config(); + config["datasets"][0]["tables"][0]["source"] = json!({ + "type": "postgres", + "connection_env": "DATABASE_URL", + "table": { + "schema": "public", + "name": "individuals" + }, + "connect_timeout": "5s", + "query_timeout": "30s" + }); + config +} + +fn refresh_config(mode: &str) -> Value { + let mut config = example_config(); + config["datasets"][0]["tables"][0]["refresh"] = json!({ + "mode": mode, + "interval": "1h" + }); + config +} + +fn set_pointer(instance: &mut Value, pointer: &str, value: Value) { + if let Some(slot) = instance.pointer_mut(pointer) { + *slot = value; + return; + } + let (parent, name) = pointer + .rsplit_once('/') + .unwrap_or_else(|| panic!("invalid test config pointer {pointer}")); + instance + .pointer_mut(parent) + .and_then(Value::as_object_mut) + .unwrap_or_else(|| panic!("missing test config pointer parent {parent}")) + .insert(name.to_string(), value); +} + +fn maintained_runtime_fixtures() -> Vec { + let root = relay_root(); + let mut fixtures = vec![ + root.join("config/example.yaml"), + root.join("config/example.oidc.yaml"), + root.join("config/spdci_disability_registry.example.yaml"), + root.join("perf/config/small.yaml"), + root.join("perf/config/medium.yaml"), + root.join("perf/config/large.yaml"), + ]; + + for entry in fs::read_dir(root.join("demo/config")).expect("demo config directory exists") { + let path = entry.expect("demo config entry is readable").path(); + if path.extension().and_then(|value| value.to_str()) == Some("yaml") + && !path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.ends_with(".metadata.yaml")) + { + fixtures.push(path); + } + } + + for entry in fs::read_dir(root.join("profiles")).expect("profiles directory exists") { + let path = entry + .expect("profile directory entry is readable") + .path() + .join("relay-config.example.yaml"); + if path.is_file() { + fixtures.push(path); + } + } + + fixtures.sort(); + fixtures +} + +#[test] +fn generated_schema_is_draft_2020_12_with_stable_id_and_no_byte_drift() { + let generated = document(); + compile_schema(&generated); + assert_eq!( + generated["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!(generated["$id"], CONFIG_SCHEMA_ID); + + let artifact = fs::read_to_string(relay_root().join(SCHEMA_ARTIFACT)) + .expect("committed Relay schema exists"); + assert_eq!(artifact, document_json()); + assert!(artifact.ends_with('\n')); + assert!(!artifact.ends_with("\n\n")); +} + +#[test] +fn schema_command_is_exactly_the_committed_artifact() { + let output = Command::new(env!("CARGO_BIN_EXE_registry-relay")) + .args(["schema", "--format", "json"]) + .output() + .expect("schema command runs"); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + assert_eq!( + output.stdout, + fs::read(relay_root().join(SCHEMA_ARTIFACT)).expect("committed Relay schema exists") + ); +} + +#[test] +fn maintained_runtime_config_fixtures_validate() { + let schema = document(); + let fixtures = maintained_runtime_fixtures(); + assert!( + fixtures.len() >= 15, + "fixture discovery unexpectedly narrowed" + ); + for fixture in fixtures { + assert_valid( + &schema, + &parse_yaml(&fixture), + &fixture.display().to_string(), + ); + } +} + +#[test] +fn optional_consultation_and_empty_datasets_are_structurally_valid() { + let schema = document(); + let mut config = example_config(); + config.as_object_mut().unwrap().remove("consultation"); + config["datasets"] = json!([]); + assert_valid( + &schema, + &config, + "config without consultation and with no datasets", + ); +} + +#[test] +fn strict_nested_objects_tagged_variants_and_duration_shapes_are_enforced() { + let schema = document(); + + let mut unknown_server_key = example_config(); + unknown_server_key["server"]["not_a_relay_setting"] = json!(true); + assert_invalid(&schema, &unknown_server_key, "unknown nested server field"); + + let mut unknown_audit_key = example_config(); + unknown_audit_key["audit"]["not_a_relay_setting"] = json!(true); + assert_invalid(&schema, &unknown_audit_key, "unknown flattened audit field"); + + let mut bad_source_tag = example_config(); + bad_source_tag["datasets"][0]["tables"][0]["source"] = json!({"type": "s3"}); + assert_invalid(&schema, &bad_source_tag, "unknown source tag"); + + let mut mixed_source_variant = example_config(); + mixed_source_variant["datasets"][0]["tables"][0]["source"] = json!({ + "type": "file", + "path": "records.csv", + "connection_env": "DATABASE_URL" + }); + assert_invalid(&schema, &mixed_source_variant, "mixed source variants"); + + let mut duration_object = example_config(); + duration_object["server"]["request_timeout"] = json!({"secs": 30, "nanos": 0}); + assert_invalid(&schema, &duration_object, "object-form duration"); +} + +#[test] +fn socket_addresses_match_schema_and_runtime_portable_syntax() { + let schema = document(); + let mut ipv4 = example_config(); + ipv4["server"]["bind"] = json!("127.0.0.1:0"); + ipv4["server"]["admin_bind"] = json!("192.0.2.10:65535"); + assert_valid(&schema, &ipv4, "maintained IPv4 listener forms"); + assert_runtime_deserializes(&ipv4, "maintained IPv4 listener forms"); + + let mut ipv6 = example_config(); + ipv6["server"]["bind"] = json!("[::1]:8080"); + ipv6["server"]["admin_bind"] = json!("[2001:db8::1]:8443"); + assert_valid(&schema, &ipv6, "bracketed IPv6 listener forms"); + assert_runtime_deserializes(&ipv6, "bracketed IPv6 listener forms"); + + let malformed = [ + "not-a-socket", + "127.0.0.1", + "::1:8080", + "256.0.0.1:8080", + "127.0.0.1:65536", + "127.0.0.1:08080", + "[gggg::1]:8080", + "[fe80::1%1]:8080", + "[::ffff:192.0.2.1]:8080", + ]; + for field in ["bind", "admin_bind"] { + for value in malformed { + let mut config = example_config(); + config["server"][field] = json!(value); + let label = format!("malformed server.{field} value {value:?}"); + assert_invalid(&schema, &config, &label); + assert_runtime_rejects(&config, &label); + } + } +} + +fn duration_fields() -> Vec<(&'static str, Value, &'static str)> { + vec![ + ( + "server.request_timeout", + example_config(), + "/server/request_timeout", + ), + ( + "server.request_body_timeout", + example_config(), + "/server/request_body_timeout", + ), + ( + "server.http1_header_read_timeout", + example_config(), + "/server/http1_header_read_timeout", + ), + ( + "auth.oidc.jwks_cache_ttl", + oidc_config(), + "/auth/oidc/jwks_cache_ttl", + ), + ("auth.oidc.leeway", oidc_config(), "/auth/oidc/leeway"), + ( + "postgres.connect_timeout", + postgres_config(), + "/datasets/0/tables/0/source/connect_timeout", + ), + ( + "postgres.query_timeout", + postgres_config(), + "/datasets/0/tables/0/source/query_timeout", + ), + ( + "refresh.mtime.interval", + refresh_config("mtime"), + "/datasets/0/tables/0/refresh/interval", + ), + ( + "refresh.interval.interval", + refresh_config("interval"), + "/datasets/0/tables/0/refresh/interval", + ), + ] +} + +#[test] +fn all_duration_fields_match_schema_and_runtime_portable_syntax() { + let schema = document(); + let documented = [ + "30s", "10s", "2h 37m", "10m", "60s", "5s", "30s", "60s", "1h", + ]; + for ((label, mut config, pointer), value) in duration_fields().into_iter().zip(documented) { + set_pointer(&mut config, pointer, json!(value)); + assert_valid(&schema, &config, label); + assert_runtime_deserializes(&config, label); + } + + let malformed = ["not-a-duration", "30", "-1s", "1h30m", "1h 30m", "1.5s"]; + for (label, baseline, pointer) in duration_fields() { + for value in malformed { + let mut config = baseline.clone(); + set_pointer(&mut config, pointer, json!(value)); + let case = format!("malformed {label} value {value:?}"); + assert_invalid(&schema, &config, &case); + assert_runtime_rejects(&config, &case); + } + } +} + +fn has_schema_type(object: &serde_json::Map, expected: &str) -> bool { + match object.get("type") { + Some(Value::String(schema_type)) => schema_type == expected, + Some(Value::Array(schema_types)) => schema_types + .iter() + .any(|schema_type| schema_type.as_str() == Some(expected)), + _ => false, + } +} + +fn expected_integer_bounds(format: &str) -> Option<(Value, Value)> { + match format { + "int8" => Some((i8::MIN.into(), i8::MAX.into())), + "int16" => Some((i16::MIN.into(), i16::MAX.into())), + "int32" => Some((i32::MIN.into(), i32::MAX.into())), + "int64" => Some((i64::MIN.into(), i64::MAX.into())), + "int" => Some(((isize::MIN as i64).into(), (isize::MAX as i64).into())), + "uint8" => Some((0.into(), u8::MAX.into())), + "uint16" => Some((0.into(), u16::MAX.into())), + "uint32" => Some((0.into(), u32::MAX.into())), + "uint64" => Some((0.into(), u64::MAX.into())), + "uint" => Some((0.into(), (usize::MAX as u64).into())), + _ => None, + } +} + +fn collect_integer_formats(schema: &Value, formats: &mut Vec<(String, bool)>) { + match schema { + Value::Array(values) => { + for value in values { + collect_integer_formats(value, formats); + } + } + Value::Object(object) => { + if has_schema_type(object, "integer") { + if let Some(format) = object.get("format").and_then(Value::as_str) { + let actual_minimum = object + .get("minimum") + .unwrap_or_else(|| panic!("{format} is missing minimum")) + .clone(); + let actual_maximum = object + .get("maximum") + .unwrap_or_else(|| panic!("{format} is missing maximum")) + .clone(); + let (expected_minimum, expected_maximum) = expected_integer_bounds(format) + .unwrap_or_else(|| panic!("unrecognized Rust integer format {format}")); + assert_eq!(actual_minimum, expected_minimum, "wrong {format} minimum"); + assert_eq!(actual_maximum, expected_maximum, "wrong {format} maximum"); + formats.push((format.to_string(), has_schema_type(object, "null"))); + } + } + for value in object.values() { + collect_integer_formats(value, formats); + } + } + _ => {} + } +} + +fn assert_runtime_rejects_adjacent_integer( + mut instance: Value, + pointer: &str, + boundary: Value, + adjacent_literal: &str, + label: &str, +) { + set_pointer(&mut instance, pointer, boundary.clone()); + let yaml = serde_norway::to_string(&instance) + .unwrap_or_else(|error| panic!("failed to serialize {label}: {error}")); + let field = pointer + .rsplit('/') + .next() + .expect("field pointer has a name"); + let boundary = boundary.to_string(); + let needle = format!("{field}: {boundary}"); + assert!( + yaml.contains(&needle), + "missing boundary literal for {label}" + ); + let yaml = yaml.replacen(&needle, &format!("{field}: {adjacent_literal}"), 1); + assert!( + serde_norway::from_str::(&yaml).is_err(), + "{label} must be rejected during runtime deserialization" + ); +} + +#[test] +fn rust_integer_formats_have_explicit_bounds_and_reject_adjacent_values() { + let schema = document(); + let mut formats = Vec::new(); + collect_integer_formats(&schema, &mut formats); + assert!(formats.iter().any(|(format, _)| format == "uint64")); + assert!(formats.iter().any(|(format, _)| format == "int64")); + assert!(formats + .iter() + .any(|(format, nullable)| format == "uint32" && *nullable)); + assert!(formats + .iter() + .any(|(format, nullable)| format == "int32" && *nullable)); + assert!(formats + .iter() + .any(|(format, nullable)| format == "uint64" && *nullable)); + assert_eq!( + schema["$defs"]["ServerConfig"]["properties"]["xlsx_max_file_bytes"]["maximum"], + json!(u64::MAX) + ); + assert_eq!( + schema["$defs"]["ConsultationStatePlaneConfig"]["properties"]["serving_fence_lock_key"] + ["minimum"], + json!(i64::MIN) + ); + + let mut below_u64 = example_config(); + below_u64["server"]["xlsx_max_file_bytes"] = json!(-1); + assert_invalid(&schema, &below_u64, "u64 value one below zero"); + + // serde_json::Value cannot represent the two adjacent out-of-range literals + // exactly. The exact schema bounds above cover external JSON validators; + // these textual YAML checks cover Relay's runtime deserializer. + assert_runtime_rejects_adjacent_integer( + example_config(), + "/server/xlsx_max_file_bytes", + json!(u64::MAX), + "18446744073709551616", + "u64 value one above maximum", + ); + assert_runtime_rejects_adjacent_integer( + consultation_config(), + "/consultation/state_plane/serving_fence_lock_key", + json!(i64::MIN), + "-9223372036854775809", + "i64 value one below minimum", + ); + + let mut above_i64 = consultation_config(); + above_i64["consultation"]["state_plane"]["serving_fence_lock_key"] = + json!(9223372036854775808_u64); + assert_invalid(&schema, &above_i64, "i64 value one above maximum"); +} + +#[test] +fn nullable_config_integers_enforce_rust_boundaries() { + let schema = document(); + + for value in [json!(0), json!(u32::MAX)] { + let mut config = example_config(); + config["datasets"][0]["aggregates"][0]["indicators"][0]["decimals"] = value; + assert_valid(&schema, &config, "aggregate decimals u32 boundary"); + assert_runtime_deserializes(&config, "aggregate decimals u32 boundary"); + } + for value in [json!(-1), json!(u64::from(u32::MAX) + 1), json!(1.5)] { + let mut config = example_config(); + config["datasets"][0]["aggregates"][0]["indicators"][0]["decimals"] = value; + assert_invalid(&schema, &config, "aggregate decimals outside u32"); + assert_runtime_rejects(&config, "aggregate decimals outside u32"); + } + + for value in [json!(i32::MIN), json!(i32::MAX)] { + let mut config = example_config(); + config["datasets"][0]["aggregates"][0]["indicators"][0]["unit_mult"] = value; + assert_valid(&schema, &config, "aggregate unit_mult i32 boundary"); + assert_runtime_deserializes(&config, "aggregate unit_mult i32 boundary"); + } + for value in [ + json!(i64::from(i32::MIN) - 1), + json!(i64::from(i32::MAX) + 1), + ] { + let mut config = example_config(); + config["datasets"][0]["aggregates"][0]["indicators"][0]["unit_mult"] = value; + assert_invalid(&schema, &config, "aggregate unit_mult outside i32"); + assert_runtime_rejects(&config, "aggregate unit_mult outside i32"); + } + + let mut audit_ack_max = example_config(); + audit_ack_max["deployment"]["evidence"] = json!({ + "audit_ack_cursor_path": "/tmp/registry-relay-audit-ack.json", + "audit_ack_max_age_secs": u64::MAX + }); + assert_valid(&schema, &audit_ack_max, "audit ack age u64 maximum"); + assert_runtime_deserializes(&audit_ack_max, "audit ack age u64 maximum"); + + let mut audit_ack_negative = example_config(); + audit_ack_negative["deployment"]["evidence"] = json!({ + "audit_ack_cursor_path": "/tmp/registry-relay-audit-ack.json", + "audit_ack_max_age_secs": -1 + }); + assert_invalid(&schema, &audit_ack_negative, "audit ack age below zero"); + assert_runtime_rejects(&audit_ack_negative, "audit ack age below zero"); + + let mut audit_ack_boundary = example_config(); + audit_ack_boundary["deployment"]["evidence"] = json!({ + "audit_ack_cursor_path": "/tmp/registry-relay-audit-ack.json" + }); + assert_runtime_rejects_adjacent_integer( + audit_ack_boundary, + "/deployment/evidence/audit_ack_max_age_secs", + json!(u64::MAX), + "18446744073709551616", + "audit ack age one above u64 maximum", + ); +} + +#[test] +fn constrained_identifiers_references_hashes_and_fingerprints_are_enforced() { + let schema = document(); + + let mut bad_dataset_id = example_config(); + bad_dataset_id["datasets"][0]["id"] = json!("Invalid-Dataset"); + assert_invalid(&schema, &bad_dataset_id, "malformed dataset id"); + + let mut object_dataset_id = example_config(); + object_dataset_id["datasets"][0]["id"] = json!({"value": "dataset"}); + assert_invalid( + &schema, + &object_dataset_id, + "object-form transparent dataset id", + ); + + let mut consultation = consultation_config(); + consultation["consultation"]["state_plane"]["database_url_env"] = json!("INVALID-ENV"); + assert_invalid(&schema, &consultation, "malformed environment reference"); + + let mut consultation = consultation_config(); + consultation["consultation"]["audit_pseudonym_materials"][0]["key_id"] = json!("UPPER"); + assert_invalid(&schema, &consultation, "malformed pseudonym id"); + + let mut consultation = consultation_config(); + consultation["consultation"]["source_credentials"][0]["ref"] = json!("Invalid Ref"); + assert_invalid(&schema, &consultation, "malformed credential reference"); + + let mut consultation = consultation_config(); + consultation["consultation"]["source_credentials"][0]["type"] = json!("embedded_secret"); + assert_invalid(&schema, &consultation, "unknown source credential tag"); + + let mut consultation = consultation_config(); + consultation["consultation"]["artifacts"]["public_contracts"][0]["sha256"] = + json!("sha256:not-a-hash"); + assert_invalid( + &schema, + &consultation, + "malformed SHA-256 artifact reference", + ); + + let mut consultation = consultation_config(); + consultation["consultation"]["artifacts"]["public_contracts"][0]["hash"] = + json!("sha256:not-a-typed-hash"); + assert_invalid(&schema, &consultation, "malformed typed artifact hash"); + + let fingerprint_cases = [ + json!({"provider": "env", "name": "FINGERPRINT", "commitment": "forbidden"}), + json!({"provider": "env", "name": "FINGERPRINT", "path": "/tmp/fingerprint"}), + json!({"provider": "env", "path": "/tmp/fingerprint"}), + json!({"provider": "file", "path": "/tmp/fingerprint", "name": "FINGERPRINT"}), + json!({"provider": "file", "name": "FINGERPRINT"}), + ]; + for (index, fingerprint) in fingerprint_cases.into_iter().enumerate() { + let mut config = example_config(); + config["auth"]["api_keys"][0]["fingerprint"] = fingerprint; + assert_invalid( + &schema, + &config, + &format!("invalid fingerprint shape {index}"), + ); + } +} + +#[test] +fn environment_reference_syntax_matches_each_runtime_consumer() { + let schema = document(); + + let mut os_valid_non_portable = example_config(); + os_valid_non_portable["auth"]["api_keys"][0]["fingerprint"]["name"] = + json!(NON_PORTABLE_ENVIRONMENT_NAME); + os_valid_non_portable["audit"]["hash_secret_env"] = json!("REGISTRY.RELAY-AUDIT-KEY"); + assert_valid( + &schema, + &os_valid_non_portable, + "OS-valid non-portable environment references", + ); + assert_runtime_deserializes( + &os_valid_non_portable, + "OS-valid non-portable environment references", + ); + + let mut long_postgres_name = postgres_config(); + long_postgres_name["datasets"][0]["tables"][0]["source"]["connection_env"] = + json!(format!("A{}", "B".repeat(160))); + assert_valid( + &schema, + &long_postgres_name, + "Postgres environment name without an artificial 128-byte limit", + ); + assert_runtime_deserializes( + &long_postgres_name, + "Postgres environment name without an artificial 128-byte limit", + ); + + let mut whitespace_fingerprint = example_config(); + whitespace_fingerprint["auth"]["api_keys"][0]["fingerprint"]["name"] = json!(" \t "); + assert_valid( + &schema, + &whitespace_fingerprint, + "whitespace-only fingerprint environment name accepted by its consumer", + ); + assert_runtime_deserializes( + &whitespace_fingerprint, + "whitespace-only fingerprint environment name accepted by its consumer", + ); + + for value in ["", "INVALID=NAME", "INVALID\0NAME"] { + let mut fingerprint = example_config(); + fingerprint["auth"]["api_keys"][0]["fingerprint"]["name"] = json!(value); + assert_invalid( + &schema, + &fingerprint, + &format!("OS-invalid fingerprint environment name {value:?}"), + ); + + let mut audit = example_config(); + audit["audit"]["hash_secret_env"] = json!(value); + assert_invalid( + &schema, + &audit, + &format!("OS-invalid audit environment name {value:?}"), + ); + } + + for value in [" ", "\t", "\n", " \t\n "] { + let mut audit = example_config(); + audit["audit"]["hash_secret_env"] = json!(value); + assert_invalid( + &schema, + &audit, + &format!("whitespace-only audit environment name {value:?}"), + ); + } +} + +#[test] +fn emitted_schema_matches_rust_unicode_whitespace_semantics() { + let artifact = fs::read_to_string(relay_root().join(SCHEMA_ARTIFACT)) + .expect("committed Relay schema exists"); + let schema: Value = + serde_json::from_str(&artifact).expect("committed Relay schema is valid JSON"); + + let mut next_line_audit = example_config(); + next_line_audit["audit"]["hash_secret_env"] = json!(NEXT_LINE); + assert_invalid( + &schema, + &next_line_audit, + "U+0085-only audit environment name under Draft 2020-12", + ); + + let mut byte_order_mark_audit = example_config(); + byte_order_mark_audit["audit"]["hash_secret_env"] = json!(ZERO_WIDTH_NO_BREAK_SPACE); + assert_valid( + &schema, + &byte_order_mark_audit, + "U+FEFF audit environment name under Draft 2020-12", + ); + + for value in [NEXT_LINE, ZERO_WIDTH_NO_BREAK_SPACE] { + let mut fingerprint = example_config(); + fingerprint["auth"]["api_keys"][0]["fingerprint"]["name"] = json!(value); + assert_valid( + &schema, + &fingerprint, + &format!("broader fingerprint environment name {value:?}"), + ); + } +} + +#[test] +fn runtime_loads_os_valid_non_portable_environment_name() { + if std::env::var_os(NON_PORTABLE_ENVIRONMENT_CHILD).is_some() { + assert_eq!( + std::env::var(NON_PORTABLE_ENVIRONMENT_NAME).as_deref(), + Ok(NON_PORTABLE_ENVIRONMENT_VALUE) + ); + AuditChainProfile::registry_relay_from_env(NON_PORTABLE_ENVIRONMENT_NAME) + .expect("audit runtime loads an OS-valid name containing dot and hyphen"); + return; + } + + let output = Command::new(std::env::current_exe().expect("current test executable exists")) + .args([ + "--exact", + "runtime_loads_os_valid_non_portable_environment_name", + ]) + .env( + NON_PORTABLE_ENVIRONMENT_NAME, + NON_PORTABLE_ENVIRONMENT_VALUE, + ) + .env(NON_PORTABLE_ENVIRONMENT_CHILD, "1") + .output() + .expect("child test process runs"); + assert!( + output.status.success(), + "child environment read failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn audit_runtime_rejects_whitespace_only_environment_name() { + if std::env::var_os(WHITESPACE_AUDIT_ENVIRONMENT_CHILD).is_some() { + assert!(matches!( + AuditChainProfile::registry_relay_from_env(" \t "), + Err(AuditError::EmptyEnvVarName) + )); + return; + } + + let output = Command::new(std::env::current_exe().expect("current test executable exists")) + .args([ + "--exact", + "audit_runtime_rejects_whitespace_only_environment_name", + ]) + .env(WHITESPACE_AUDIT_ENVIRONMENT_CHILD, "1") + .output() + .expect("child audit runtime test process runs"); + assert!( + output.status.success(), + "child audit runtime rejection failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn audit_runtime_matches_rust_unicode_whitespace_semantics() { + if std::env::var_os(UNICODE_AUDIT_ENVIRONMENT_CHILD).is_some() { + assert!(matches!( + AuditChainProfile::registry_relay_from_env(NEXT_LINE), + Err(AuditError::EmptyEnvVarName) + )); + AuditChainProfile::registry_relay_from_env(ZERO_WIDTH_NO_BREAK_SPACE) + .expect("audit runtime accepts U+FEFF as a non-whitespace Unix environment name"); + return; + } + + let output = Command::new(std::env::current_exe().expect("current test executable exists")) + .args([ + "--exact", + "audit_runtime_matches_rust_unicode_whitespace_semantics", + ]) + .env(ZERO_WIDTH_NO_BREAK_SPACE, NON_PORTABLE_ENVIRONMENT_VALUE) + .env(UNICODE_AUDIT_ENVIRONMENT_CHILD, "1") + .output() + .expect("child Unicode audit runtime test process runs"); + assert!( + output.status.success(), + "child Unicode audit runtime parity failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn schema_command_does_not_read_config_source_environment_or_secret_values() { + let marker = "SCHEMA_MUST_NOT_READ_OR_EMIT_THIS_SECRET_VALUE"; + let temp = tempfile::tempdir().expect("temporary working directory"); + let output = Command::new(env!("CARGO_BIN_EXE_registry-relay")) + .args(["schema", "--format=json"]) + .current_dir(temp.path()) + .env( + "REGISTRY_RELAY_CONFIG", + temp.path().join("missing-config.yaml"), + ) + .env("REGISTRY_RELAY_SCHEMA_SECRET_SENTINEL", marker) + .output() + .expect("schema command runs without runtime inputs"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + assert_eq!(output.stdout, document_json().as_bytes()); + assert!(!String::from_utf8(output.stdout).unwrap().contains(marker)); +} + +fn collect_key_paths( + root: &Value, + schema: &Value, + prefix: &str, + paths: &mut BTreeSet, + visited_refs: &mut HashSet<(String, String)>, +) { + if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { + let visit = (reference.to_string(), prefix.to_string()); + if visited_refs.insert(visit) { + let target = root + .pointer(reference.strip_prefix('#').expect("local schema reference")) + .unwrap_or_else(|| panic!("unresolved schema reference {reference}")); + collect_key_paths(root, target, prefix, paths, visited_refs); + } + } + + for combinator in ["allOf", "anyOf", "oneOf"] { + if let Some(branches) = schema.get(combinator).and_then(Value::as_array) { + for branch in branches { + collect_key_paths(root, branch, prefix, paths, visited_refs); + } + } + } + + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + for (name, property) in properties { + let property_path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + }; + paths.insert(property_path.clone()); + collect_key_paths(root, property, &property_path, paths, visited_refs); + } + } + + if let Some(items) = schema.get("items").filter(|value| value.is_object()) { + let item_path = format!("{prefix}[]"); + paths.insert(item_path.clone()); + collect_key_paths(root, items, &item_path, paths, visited_refs); + } + + if let Some(values) = schema + .get("additionalProperties") + .filter(|value| value.is_object()) + { + let value_path = format!("{prefix}.*"); + paths.insert(value_path.clone()); + collect_key_paths(root, values, &value_path, paths, visited_refs); + } +} + +fn documented_key_paths(reference: &str) -> BTreeSet { + let Some((_, tail)) = reference.split_once(KEY_PATHS_START) else { + return BTreeSet::new(); + }; + let (block, _) = tail + .split_once(KEY_PATHS_END) + .unwrap_or_else(|| panic!("missing {KEY_PATHS_END}")); + block + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && *line != "```text" && *line != "```") + .map(str::to_string) + .collect() +} + +#[test] +fn schema_and_configuration_reference_have_exact_bidirectional_key_path_parity() { + let schema = document(); + let mut schema_paths = BTreeSet::new(); + collect_key_paths(&schema, &schema, "", &mut schema_paths, &mut HashSet::new()); + let reference = fs::read_to_string(relay_root().join(CONFIG_REFERENCE)) + .expect("configuration reference exists"); + let documented_paths = documented_key_paths(&reference); + assert_eq!( + documented_paths, + schema_paths, + "configuration key paths differ; generated schema paths follow:\n{}", + schema_paths.iter().cloned().collect::>().join("\n") + ); +} diff --git a/schemas/registry-relay.config.schema.json b/schemas/registry-relay.config.schema.json new file mode 100644 index 000000000..f0ad5514b --- /dev/null +++ b/schemas/registry-relay.config.schema.json @@ -0,0 +1,3418 @@ +{ + "$defs": { + "AccessRights": { + "description": "Access rights classification, mirrors DCAT-AP `dcterms:accessRights`.", + "enum": [ + "public", + "restricted", + "non_public" + ], + "type": "string" + }, + "AdmsStatus": { + "description": "BRegDCAT-AP `adms:status` vocabulary. Maps to the EU ADMS status codelists.", + "enum": [ + "under_development", + "completed", + "deprecated", + "withdrawn" + ], + "type": "string" + }, + "AggregateAccessConfig": { + "additionalProperties": false, + "properties": { + "aggregate_only_execution": { + "default": false, + "type": "boolean" + }, + "aggregate_scope": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "metadata_scope": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AggregateConfig": { + "additionalProperties": false, + "description": "Aggregate declaration: group-by columns, measures, disclosure\ncontrol.", + "properties": { + "access": { + "anyOf": [ + { + "$ref": "#/$defs/AggregateAccessConfig" + }, + { + "type": "null" + } + ] + }, + "allowed_filters": { + "items": { + "$ref": "#/$defs/AllowedFilter" + }, + "type": "array" + }, + "default_group_by": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "dimensions": { + "items": { + "$ref": "#/$defs/AggregateDimensionConfig" + }, + "type": "array" + }, + "disclosure_control": { + "$ref": "#/$defs/DisclosureControlConfig" + }, + "group_by": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "$ref": "#/$defs/AggregateId" + }, + "indicators": { + "items": { + "$ref": "#/$defs/AggregateIndicatorConfig" + }, + "type": "array" + }, + "joins": { + "description": "Legacy entity-local aggregate fields. These stay parseable while\nthe public surface moves to dataset-level aggregates.", + "items": { + "$ref": "#/$defs/AggregateJoinConfig" + }, + "type": "array" + }, + "measures": { + "items": { + "$ref": "#/$defs/AggregateMeasure" + }, + "type": "array" + }, + "required_filter_bindings": { + "description": "Principal-derived bindings that both satisfy the required filter gate\nand are applied to the aggregate query.", + "items": { + "$ref": "#/$defs/RequiredFilterBindingConfig" + }, + "type": "array" + }, + "required_filters": { + "default": [], + "description": "Alternative fields or dimensions that can satisfy the aggregate gate. A\nprincipal-bound equality filter on any listed field is sufficient, so\nlist multiple fields only when each is an acceptable boundary.", + "items": { + "type": "string" + }, + "type": "array" + }, + "source_entity": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "spatial": { + "anyOf": [ + { + "$ref": "#/$defs/AggregateSpatialConfig" + }, + { + "type": "null" + } + ] + }, + "temporal_field": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "title": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "description", + "disclosure_control" + ], + "type": "object" + }, + "AggregateDimensionConfig": { + "additionalProperties": false, + "properties": { + "codelist": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "field": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "field" + ], + "type": "object" + }, + "AggregateFunction": { + "description": "Aggregate function. V1 supports the basic set plus the\noptional functions (`median`, `count_distinct`, `stddev`).", + "enum": [ + "count", + "sum", + "avg", + "min", + "max", + "median", + "count_distinct", + "stddev" + ], + "type": "string" + }, + "AggregateId": { + "description": "Aggregate identifier within a resource.", + "pattern": "^[a-z][a-z0-9_]*$", + "type": "string" + }, + "AggregateIndicatorConfig": { + "additionalProperties": false, + "properties": { + "column": { + "type": "string" + }, + "decimals": { + "default": null, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "definition_uri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "frequency": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "function": { + "$ref": "#/$defs/AggregateFunction" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "unit_measure": { + "type": "string" + }, + "unit_mult": { + "default": null, + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "id", + "label", + "function", + "column", + "unit_measure" + ], + "type": "object" + }, + "AggregateJoinConfig": { + "additionalProperties": false, + "properties": { + "relationship": { + "type": "string" + } + }, + "required": [ + "relationship" + ], + "type": "object" + }, + "AggregateMeasure": { + "additionalProperties": false, + "description": "One measure inside an aggregate.", + "properties": { + "column": { + "type": "string" + }, + "function": { + "$ref": "#/$defs/AggregateFunction" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "function", + "column" + ], + "type": "object" + }, + "AggregateSpatialConfig": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "bbox_fields": { + "anyOf": [ + { + "$ref": "#/$defs/SpatialBboxFieldsConfig" + }, + { + "type": "null" + } + ] + }, + "collection_id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "dimension": { + "type": "string" + }, + "geometry_entity": { + "type": "string" + }, + "geometry_field": { + "type": "string" + }, + "geometry_id_field": { + "type": "string" + }, + "max_geometry_vertices": { + "default": 10000, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "mode": { + "const": "admin_area", + "type": "string" + } + }, + "required": [ + "mode", + "dimension", + "geometry_entity", + "geometry_id_field", + "geometry_field" + ], + "type": "object" + } + ] + }, + "AllowedFilter": { + "additionalProperties": false, + "description": "A single allowed filter: field name + permitted operators.", + "properties": { + "field": { + "type": "string" + }, + "ops": { + "items": { + "$ref": "#/$defs/FilterOp" + }, + "type": "array" + } + }, + "required": [ + "field", + "ops" + ], + "type": "object" + }, + "ApiKeyConfig": { + "additionalProperties": false, + "description": "One configured API key, identified by an id and a fingerprint reference.\nThe raw key never appears in config.", + "properties": { + "fingerprint": { + "$ref": "#/$defs/CredentialFingerprintSchema" + }, + "id": { + "type": "string" + }, + "scopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "fingerprint" + ], + "type": "object" + }, + "AttributeReleaseProfile": { + "additionalProperties": false, + "description": "A governed identity attribute-release profile. A profile is a\nprojection-limited, exactly-one-subject lookup that maps a configured set of\nsource fields (or CEL-computed expressions) into a minimised\nOIDC/UserInfo-style claim bundle. It is *optionally* purpose-bound: a profile\nthat declares a `purpose` requires a matching `data-purpose` at resolve time;\none that omits it does not. Identified globally by the `(id, version)` pair;\nboth are required path segments at resolve time.", + "properties": { + "claims": { + "description": "Claims released on success. Non-empty; at least one `required`.", + "items": { + "$ref": "#/$defs/ReleaseClaimConfig" + }, + "type": "array" + }, + "description": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Profile identifier, lower-kebab/snake (`^[a-z][a-z0-9_-]*$`). Globally\nunique with `version`.", + "type": "string" + }, + "purpose": { + "default": null, + "description": "Data-purpose this profile is bound to. Required (and must be a member\nof the entity's `governed_policy.permitted_purposes`) when the backing\nentity declares any permitted purposes.", + "type": [ + "string", + "null" + ] + }, + "release_conditions": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseConditionsConfig" + }, + { + "type": "null" + } + ], + "description": "Optional CEL release-condition gate evaluated before projection." + }, + "release_scope": { + "description": "Dataset-bound scope a caller must hold to invoke this release. Must\ndiffer from the entity's `read_scope`.", + "type": "string" + }, + "response": { + "$ref": "#/$defs/ReleaseResponseConfig", + "description": "Response envelope controls." + }, + "subject": { + "$ref": "#/$defs/ReleaseSubjectConfig", + "description": "How the subject is identified and looked up." + }, + "title": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "version": { + "description": "Profile version. Globally unique with `id`; no silent \"latest\".", + "type": "string" + } + }, + "required": [ + "id", + "version", + "release_scope", + "subject", + "claims" + ], + "type": "object" + }, + "AuditConfig": { + "description": "Audit configuration. Sink choice gates further fields via the\ntagged `AuditSinkConfig` enum. The enum is flattened onto the\ncontaining struct so that the YAML `sink:` key acts as the\ndiscriminator, matching the public example configuration.\n\n`deny_unknown_fields` is deliberately omitted here: `serde` does\nnot support combining it with `#[serde(flatten)]` on an internally\ntagged enum (unknown keys in `audit` are caught by the enum's own\n`deny_unknown_fields`).", + "oneOf": [ + { + "properties": { + "sink": { + "const": "stdout", + "type": "string" + } + }, + "required": [ + "sink" + ], + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "rotate": { + "$ref": "#/$defs/RotateConfig" + }, + "sink": { + "const": "file", + "type": "string" + } + }, + "required": [ + "sink", + "path" + ], + "type": "object" + }, + { + "properties": { + "sink": { + "const": "syslog", + "type": "string" + } + }, + "required": [ + "sink" + ], + "type": "object" + } + ], + "properties": { + "chain": { + "default": false, + "type": "boolean" + }, + "format": { + "$ref": "#/$defs/AuditFormat" + }, + "hash_secret_env": { + "anyOf": [ + { + "$ref": "#/$defs/AuditHashSecretEnvironmentNameSchema" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the environment variable holding the per-deploy secret\nused to HMAC sensitive audit values (single-record primary keys,\nsensitive query parameters). Runtime startup fails closed when\nthis field is unset, empty, or points to a missing, empty, or\nweak secret. Direct middleware tests can opt into the explicit\nunkeyed dev-only hasher without using runtime config." + }, + "include_health": { + "default": false, + "description": "Include `/healthz` liveness probes in the audit stream. `/ready` is\nalways excluded because auditing it would advance the chain after its\nzero-backlog shipping check and self-invalidate the next probe.", + "type": "boolean" + }, + "write_policy": { + "$ref": "#/$defs/AuditWritePolicySchema", + "default": "fail_closed", + "description": "Behavior when an audit record fails to write.\n\n`fail_closed` (default) fails the request with a stable error code so\nthat no request outcome is returned without a durable audit record.\n`availability_first` logs the failure and lets the request succeed for\ndeployments that explicitly accept best-effort audit durability.\nPer-route-family selection is out of scope; this is a single\ndeployment-wide policy." + } + }, + "type": "object", + "unevaluatedProperties": false + }, + "AuditFormat": { + "description": "Audit serialisation format. JSONL is the only V1 format.", + "oneOf": [ + { + "const": "jsonl", + "description": "JSON Lines: one record per line, UTF-8, LF-terminated.", + "type": "string" + } + ] + }, + "AuditHashSecretEnvironmentNameSchema": { + "pattern": "^[^=\\x00]*[^=\\x00\\x09-\\x0D\\x20\\x85\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000][^=\\x00]*$", + "type": "string" + }, + "AuditPseudonymKeyIdSchema": { + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", + "type": "string" + }, + "AuditPseudonymMaterialCatalogConfig": { + "description": "Bounded startup catalog of audit-pseudonym material references.\n\nThe 1..=32 bound and cross-entry uniqueness are enforced by config\nvalidation and repeated by the material provider before loading secrets.", + "items": { + "$ref": "#/$defs/AuditPseudonymMaterialConfig" + }, + "type": "array" + }, + "AuditPseudonymMaterialConfig": { + "additionalProperties": false, + "description": "One public epoch id bound to one secret source reference.", + "properties": { + "key_id": { + "$ref": "#/$defs/AuditPseudonymKeyIdSchema" + }, + "source": { + "$ref": "#/$defs/AuditPseudonymSecretSourceConfig" + } + }, + "required": [ + "key_id", + "source" + ], + "type": "object" + }, + "AuditPseudonymSecretEnvironmentName": { + "description": "Portable environment-variable name used only as a secret reference.\n\nDebug output is redacted even though the name is not itself key material,\npreventing configuration diagnostics from disclosing secret topology.", + "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$", + "type": "string" + }, + "AuditPseudonymSecretSourceConfig": { + "description": "Closed v1 set of audit-pseudonym secret source providers.\n\nThe configured name is a reference only. Secret values cannot be embedded\nin this model and are loaded exactly once during runtime compilation.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/AuditPseudonymSecretEnvironmentName" + }, + "provider": { + "const": "environment", + "type": "string" + } + }, + "required": [ + "provider", + "name" + ], + "type": "object" + } + ] + }, + "AuditWritePolicySchema": { + "enum": [ + "availability_first", + "fail_closed", + "fail_closed_route_families" + ], + "type": "string" + }, + "AuthConfig": { + "additionalProperties": false, + "description": "Authentication configuration. Exactly one of `api_keys` and `oidc`\nis consumed at startup, gated by `mode`; cross-field validation in\n[`validate`] enforces that only the active block is populated.", + "properties": { + "api_keys": { + "items": { + "$ref": "#/$defs/ApiKeyConfig" + }, + "type": "array" + }, + "failure_throttle": { + "$ref": "#/$defs/AuthFailureThrottleConfig", + "description": "Local, coarse, in-process throttle on repeated authentication\nfailures from one client address. Disabled by default." + }, + "mode": { + "$ref": "#/$defs/AuthMode" + }, + "oidc": { + "anyOf": [ + { + "$ref": "#/$defs/OidcConfig" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "AuthFailureThrottleConfig": { + "additionalProperties": false, + "description": "Local backstop against repeated authentication failures from a single\nclient address. This is not the primary defense: ingress rate limiting\nin front of the relay (declared via `deployment.evidence.ingress_rate_limit`)\nis expected to absorb abusive traffic before it reaches this process.\nDisabled by default so deployments that never set this block observe no\nbehavior change.", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "max_failures": { + "default": 20, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "window_seconds": { + "default": 60, + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "AuthMode": { + "description": "Authentication mode tag. Drives the provider built at startup in\n`crate::auth`. A given deployment runs in exactly one mode at a time;\nmixed-mode operation is not supported.", + "oneOf": [ + { + "const": "api_key", + "description": "Hashed shared secret in an environment variable.", + "type": "string" + }, + { + "const": "oidc", + "description": "Bearer JWT validated against an external OIDC / OAuth2 provider.", + "type": "string" + } + ] + }, + "CatalogConfig": { + "additionalProperties": false, + "description": "Catalog-level metadata surfaced by `/metadata/*` and DCAT outputs.", + "properties": { + "authority_type": { + "default": null, + "description": "BRegDCAT-AP: type IRI for the `foaf:Agent` publisher. When set, emits\n`dcterms:type` on the publisher node.\n\nBRegDCAT-AP 2.1.0 SHACL checks publisher type values against the ADMS\npublishertype scheme (`http://purl.org/adms/publishertype/...`).\nThe relay does not enforce a vocabulary: any IRI passes through.", + "type": [ + "string", + "null" + ] + }, + "base_url": { + "type": "string" + }, + "default_spatial_coverage": { + "default": null, + "description": "BRegDCAT-AP: default `dcterms:spatial` IRI applied to datasets that\ndo not declare their own `spatial_coverage`. Typically an EU\nauthority country IRI under\n`http://publications.europa.eu/resource/authority/country/`.", + "type": [ + "string", + "null" + ] + }, + "participant_id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "publisher": { + "type": "string" + }, + "publisher_iri": { + "default": null, + "description": "BRegDCAT-AP: identifier IRI for the `foaf:Agent` publisher. Use a\ncontrolled-vocabulary corporate body IRI when publishing strict\nBRegDCAT-AP.", + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "title", + "base_url", + "publisher" + ], + "type": "object" + }, + "ClaimSensitivity": { + "description": "Closed privacy-sensitivity classification for a released claim. This is a\nseparate, release-specific taxonomy and is intentionally not the\ndataset-level `Sensitivity` enum.", + "enum": [ + "direct_identifier", + "personal", + "public", + "pseudonymous" + ], + "type": "string" + }, + "ConfigTrustConfig": { + "additionalProperties": false, + "description": "Optional signed configuration bundle trust state.\n\nSimple local deployments omit this block. Bundle-aware deployments pin the\nlocal trust anchor, bundle, and anti-rollback state paths explicitly.", + "properties": { + "antirollback_state_path": { + "type": "string" + }, + "break_glass_override_path": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "bundle_path": { + "type": "string" + }, + "trust_anchor_path": { + "type": "string" + } + }, + "required": [ + "trust_anchor_path", + "bundle_path", + "antirollback_state_path" + ], + "type": "object" + }, + "ConsultationArtifactClosureConfig": { + "additionalProperties": false, + "description": "Complete artifact catalog for all consultations enabled at one restart.", + "properties": { + "evidence": { + "items": { + "$ref": "#/$defs/ConsultationEvidenceArtifactConfig" + }, + "type": "array" + }, + "integration_packs": { + "items": { + "$ref": "#/$defs/ConsultationTypedArtifactReferenceConfig" + }, + "type": "array" + }, + "private_bindings": { + "items": { + "$ref": "#/$defs/ConsultationTypedArtifactReferenceConfig" + }, + "type": "array" + }, + "public_contracts": { + "items": { + "$ref": "#/$defs/ConsultationTypedArtifactReferenceConfig" + }, + "type": "array" + }, + "rhai_scripts": { + "items": { + "$ref": "#/$defs/ConsultationArtifactReferenceConfig" + }, + "type": "array" + } + }, + "required": [ + "public_contracts", + "integration_packs", + "private_bindings", + "evidence" + ], + "type": "object" + }, + "ConsultationArtifactReferenceConfig": { + "additionalProperties": false, + "description": "One hash-pinned private binding or standalone Rhai script.", + "properties": { + "path": { + "description": "Normalized bundle-root-relative path.", + "type": "string" + }, + "sha256": { + "description": "Raw file hash recorded by Registry Config Bundle v1.", + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "path", + "sha256" + ], + "type": "object" + }, + "ConsultationClientClaimSelectorConfig": { + "description": "Closed set of verified OAuth claims that may identify Registry Notary.", + "enum": [ + "azp", + "client_id" + ], + "type": "string" + }, + "ConsultationConfig": { + "additionalProperties": false, + "description": "Restart-only activation configuration for governed consultations.\n\nThe block is all-or-nothing: validation requires OIDC authentication, the\nexact authorized consultation workload, PostgreSQL state-plane identity, an artifact closure,\nand immutable, versioned secret references. PostgreSQL intentionally stores\nno secret-derived verifier, so replacing audit-pseudonym material behind an\nexisting reference requires a new key id.", + "properties": { + "artifacts": { + "anyOf": [ + { + "$ref": "#/$defs/ConsultationArtifactClosureConfig" + }, + { + "type": "null" + } + ], + "description": "Complete restart-only source-plan artifact closure.\n\nEvery public contract in this catalog is an enabled consultation. A\nnon-local deployment may activate the catalog only from a verified\nRegistry Config Bundle v1. Local development still pins every file by\nhash, but intentionally has no signing requirement." + }, + "audit_pseudonym_materials": { + "$ref": "#/$defs/AuditPseudonymMaterialCatalogConfig" + }, + "authorized_workload": { + "$ref": "#/$defs/ConsultationWorkloadConfig", + "description": "Exact OIDC claims that identify the only caller allowed to execute\nconsultation profiles. The issuer is inherited from `auth.oidc`; the\nworkload, scope, tenant, and registry bindings come from each compiled\nprofile." + }, + "source_credentials": { + "$ref": "#/$defs/ConsultationSourceCredentialCatalogConfig", + "description": "Complete restart-only catalog of source credentials referenced by the\ncompiled consultation plans.\n\nV1 accepts environment-backed HTTP Basic and OAuth client credentials.\nEmpty is valid when no compiled source plan uses source authentication." + }, + "state_plane": { + "$ref": "#/$defs/ConsultationStatePlaneConfig", + "description": "Dedicated PostgreSQL control-plane connection and deployment identity." + } + }, + "required": [ + "authorized_workload", + "state_plane", + "audit_pseudonym_materials" + ], + "type": "object" + }, + "ConsultationCredentialEnvironmentName": { + "description": "Portable environment-variable name used only as a credential reference.", + "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$", + "type": "string" + }, + "ConsultationDatabaseUrlEnvironmentName": { + "description": "Portable environment-variable name that resolves the state-plane URL.", + "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$", + "type": "string" + }, + "ConsultationEvidenceArtifactConfig": { + "additionalProperties": false, + "description": "One bounded, hash-pinned integration evidence file.", + "properties": { + "class": { + "$ref": "#/$defs/ConsultationEvidenceClassConfig" + }, + "path": { + "type": "string" + }, + "sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "class", + "path", + "sha256" + ], + "type": "object" + }, + "ConsultationEvidenceClassConfig": { + "description": "Closed evidence classes understood by consultation source-plan v1.", + "enum": [ + "conformance", + "negative_security", + "minimization" + ], + "type": "string" + }, + "ConsultationSourceCredentialCatalogConfig": { + "description": "Bounded source-credential references loaded only during runtime startup.\n\nThe catalog contains references, never credential values. Full one-to-one\nclosure against the compiled source-plan registry is enforced before any\nenvironment variable is read.", + "items": { + "$ref": "#/$defs/ConsultationSourceCredentialConfig" + }, + "type": "array" + }, + "ConsultationSourceCredentialConfig": { + "description": "Closed V1 source-credential provider configuration.\n\nEnvironment names are opaque references and are redacted from `Debug`.\nThere is deliberately no field capable of carrying an embedded username,\npassword, bearer token, or provider-specific extension.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "generation": { + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "password_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + }, + "ref": { + "$ref": "#/$defs/ConsultationSourceCredentialReference" + }, + "type": { + "const": "basic", + "type": "string" + }, + "username_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + } + }, + "required": [ + "type", + "ref", + "generation", + "username_env", + "password_env" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "generation": { + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "ref": { + "$ref": "#/$defs/ConsultationSourceCredentialReference" + }, + "token_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + }, + "type": { + "const": "static_bearer", + "type": "string" + } + }, + "required": [ + "type", + "ref", + "generation", + "token_env" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "generation": { + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "ref": { + "$ref": "#/$defs/ConsultationSourceCredentialReference" + }, + "type": { + "const": "api_key_header", + "type": "string" + }, + "value_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + } + }, + "required": [ + "type", + "ref", + "generation", + "value_env" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "generation": { + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "ref": { + "$ref": "#/$defs/ConsultationSourceCredentialReference" + }, + "type": { + "const": "api_key_query", + "type": "string" + }, + "value_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + } + }, + "required": [ + "type", + "ref", + "generation", + "value_env" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "client_id_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + }, + "client_secret_env": { + "$ref": "#/$defs/ConsultationCredentialEnvironmentName" + }, + "generation": { + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "ref": { + "$ref": "#/$defs/ConsultationSourceCredentialReference" + }, + "type": { + "const": "oauth_client_credentials", + "type": "string" + } + }, + "required": [ + "type", + "ref", + "generation", + "client_id_env", + "client_secret_env" + ], + "type": "object" + } + ] + }, + "ConsultationSourceCredentialReference": { + "description": "Exact private-binding credential reference grammar.", + "pattern": "^[a-z][a-z0-9._-]{0,95}$", + "type": "string" + }, + "ConsultationStatePlaneConfig": { + "additionalProperties": false, + "description": "Required PostgreSQL state-plane activation settings.\n\nThe database URL itself cannot be embedded in YAML. Only the name of an\nenvironment variable may be configured. Debug output also redacts that\nreference and the optional trust-root path so diagnostics do not disclose\ndeployment secret topology.", + "properties": { + "audit_pseudonym_keyring_lock_key": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "chain_key_epoch_id": { + "type": "string" + }, + "database_url_env": { + "$ref": "#/$defs/ConsultationDatabaseUrlEnvironmentName" + }, + "root_certificate_path": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "serving_fence_lock_key": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "database_url_env", + "chain_key_epoch_id", + "serving_fence_lock_key", + "audit_pseudonym_keyring_lock_key" + ], + "type": "object" + }, + "ConsultationTypedArtifactReferenceConfig": { + "additionalProperties": false, + "description": "One hash-pinned public contract or reviewed integration pack.", + "properties": { + "hash": { + "description": "Domain-separated typed artifact hash consumed by the source-plan compiler.", + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + "path": { + "description": "Normalized bundle-root-relative path.", + "type": "string" + }, + "sha256": { + "description": "Raw file hash recorded by Registry Config Bundle v1.", + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "path", + "hash", + "sha256" + ], + "type": "object" + }, + "ConsultationWorkloadConfig": { + "additionalProperties": false, + "description": "Exact fixed OIDC binding for one authorized consultation workload.\n\nThere is deliberately no automatic client-claim selection. Deployments\nmust name exactly one verified claim, and later runtime compilation binds\nthis configuration to the issuer from `auth.oidc`.", + "properties": { + "audience": { + "type": "string" + }, + "client_claim_selector": { + "$ref": "#/$defs/ConsultationClientClaimSelectorConfig" + }, + "client_value": { + "type": "string" + }, + "principal_id": { + "type": "string" + } + }, + "required": [ + "audience", + "client_claim_selector", + "client_value", + "principal_id" + ], + "type": "object" + }, + "CorsConfig": { + "additionalProperties": false, + "description": "CORS allowlist; default-deny per Section 17 item 7.", + "properties": { + "allowed_origins": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "CredentialFingerprintSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/RuntimeEnvironmentNameSchema" + }, + "provider": { + "const": "env", + "type": "string" + } + }, + "required": [ + "provider", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "provider": { + "const": "file", + "type": "string" + } + }, + "required": [ + "provider", + "path" + ], + "type": "object" + } + ] + }, + "CsvFormatConfig": { + "additionalProperties": false, + "properties": { + "delimiter": { + "default": null, + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "header_row": { + "default": null, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "quote": { + "default": null, + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "DatasetConfig": { + "additionalProperties": false, + "description": "A single dataset declaration.", + "properties": { + "access_rights": { + "$ref": "#/$defs/AccessRights" + }, + "aggregates": { + "items": { + "$ref": "#/$defs/AggregateConfig" + }, + "type": "array" + }, + "applicable_legislation": { + "default": [], + "description": "DCAT-AP `dcatap:applicableLegislation` IRIs. This is evidence\npublished for standard consumers, not an application-specific\nauthorization or source-of-truth verdict.", + "items": { + "type": "string" + }, + "type": "array" + }, + "conforms_to": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "defaults": { + "$ref": "#/$defs/DatasetDefaultsConfig" + }, + "description": { + "type": "string" + }, + "entities": { + "items": { + "$ref": "#/$defs/EntityConfig" + }, + "type": "array" + }, + "id": { + "$ref": "#/$defs/DatasetId" + }, + "owner": { + "type": "string" + }, + "public_services": { + "description": "CPSV public services that produce this dataset. Registry Relay emits\nthem as standard `cpsv:PublicService` nodes; consumers decide how to\ninterpret that evidence.", + "items": { + "$ref": "#/$defs/PublicServiceConfig" + }, + "type": "array" + }, + "sensitivity": { + "$ref": "#/$defs/Sensitivity" + }, + "spatial_coverage": { + "default": null, + "description": "BRegDCAT-AP: `dct:spatial` IRI for this dataset. Overrides the\ncatalog-level `default_spatial_coverage` when set.", + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/AdmsStatus" + }, + { + "type": "null" + } + ], + "default": null, + "description": "BRegDCAT-AP: `adms:status` for this dataset. Defaults to\n`UnderDevelopment` when not set: it is the weakest ADMS lifecycle\nclaim and forces an explicit opt-in to anything stronger." + }, + "tables": { + "items": { + "$ref": "#/$defs/ResourceConfig" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "update_frequency": { + "$ref": "#/$defs/UpdateFrequency" + } + }, + "required": [ + "id", + "title", + "description", + "owner", + "sensitivity", + "access_rights", + "update_frequency" + ], + "type": "object" + }, + "DatasetDefaultsConfig": { + "additionalProperties": false, + "description": "Optional table defaults for reducing repetition within one dataset.", + "properties": { + "materialization": { + "anyOf": [ + { + "$ref": "#/$defs/MaterializationMode" + }, + { + "type": "null" + } + ] + }, + "refresh": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "DatasetId": { + "description": "Dataset identifier. Lower-snake, starts with a letter.", + "pattern": "^[a-z][a-z0-9_]*$", + "type": "string" + }, + "DeploymentConfig": { + "additionalProperties": false, + "description": "Operator-declared deployment posture.\n\nThe profile is an explicit assurance claim, never inferred from\nenvironment or network position. When `profile` is absent the deployment\nis undeclared and refuses startup. A profile value that is not one of the\nknown variants fails startup (fail closed on typos).", + "properties": { + "evidence": { + "$ref": "#/$defs/DeploymentEvidenceConfig", + "description": "Operator declarations of assurance evidence the runtime cannot observe\nfor itself (out-of-band ingress rate limiting, API-key rotation).\nAbsent declarations leave the corresponding gates active." + }, + "profile": { + "anyOf": [ + { + "$ref": "#/$defs/DeploymentProfileSchema" + }, + { + "type": "null" + } + ], + "default": null + }, + "waivers": { + "description": "Per-deployment waivers. Each names one finding id, a free-text reason,\nand a mandatory expiry date. Expired waivers stop suppressing their\nfinding and raise `deployment.waiver_expired`.", + "items": { + "$ref": "#/$defs/DeploymentWaiverConfig" + }, + "type": "array" + } + }, + "type": "object" + }, + "DeploymentEvidenceConfig": { + "additionalProperties": false, + "description": "Operator-asserted assurance evidence for conditions the runtime cannot\nobserve directly. Each flag defaults to `false`, meaning \"no evidence\ndeclared\", which keeps the corresponding gate active until the operator\nasserts the control is in place out of band.", + "properties": { + "api_key_rotation": { + "default": false, + "description": "Operator asserts an API-key rotation process is in place.", + "type": "boolean" + }, + "audit_ack_cursor_path": { + "default": null, + "description": "Optional path to a `registry.audit.ack_cursor.v1` file maintained by\nwhatever ships audit events off-host. When set, the runtime reads it to\nobserve shipping freshness and surfaces it as posture shipping health;\nabsent, shipping health stays `unverified` and only the declared\nshipping target is reported.", + "type": [ + "string", + "null" + ] + }, + "audit_ack_max_age_secs": { + "default": null, + "description": "Optional freshness window in seconds for the ack cursor's `acked_at`\ntimestamp. Defaults to `DEFAULT_AUDIT_ACK_MAX_AGE` (900) when unset. A\nwindow without `audit_ack_cursor_path` is rejected at load, since a\nfreshness window is meaningless without a cursor to observe.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "audit_offhost_shipping": { + "default": false, + "description": "Operator asserts audit records are shipped off-host (for example to a\nlog collector or SIEM) rather than relying solely on local retention.", + "type": "boolean" + }, + "ingress_rate_limit": { + "default": false, + "description": "Operator asserts ingress rate limiting is enforced (for example by a\ngateway or reverse proxy in front of the relay).", + "type": "boolean" + } + }, + "type": "object" + }, + "DeploymentProfileSchema": { + "enum": [ + "local", + "hosted_lab", + "production", + "evidence_grade" + ], + "type": "string" + }, + "DeploymentWaiverConfig": { + "additionalProperties": false, + "description": "One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is\nvalidated at load time. Reasons must not carry secrets.", + "properties": { + "expires": { + "type": "string" + }, + "finding": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "finding", + "reason", + "expires" + ], + "type": "object" + }, + "DisclosureControlConfig": { + "additionalProperties": false, + "description": "Disclosure control settings per aggregate. Defaults to\n`min_group_size: 5`, `suppression: omit`.", + "properties": { + "method": { + "default": [ + "k-anonymity" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "min_cell_size": { + "default": 5, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "min_group_size": { + "default": null, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "report_suppressed_rows": { + "default": false, + "type": "boolean" + }, + "suppression": { + "$ref": "#/$defs/Suppression" + } + }, + "type": "object" + }, + "EcosystemBindingSelectorConfig": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "version": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "EntityAccessConfig": { + "additionalProperties": false, + "properties": { + "aggregate_scope": { + "type": "string" + }, + "evidence_verification_scope": { + "default": "", + "type": "string" + }, + "metadata_scope": { + "type": "string" + }, + "read_scope": { + "type": "string" + } + }, + "required": [ + "metadata_scope", + "aggregate_scope", + "read_scope" + ], + "type": "object" + }, + "EntityApiConfig": { + "additionalProperties": false, + "properties": { + "allowed_expansions": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_filters": { + "items": { + "$ref": "#/$defs/AllowedFilter" + }, + "type": "array" + }, + "default_limit": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "governed_policy": { + "anyOf": [ + { + "$ref": "#/$defs/GovernedPolicyConfig" + }, + { + "type": "null" + } + ] + }, + "max_limit": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "require_purpose_header": { + "default": false, + "type": "boolean" + }, + "required_filter_bindings": { + "description": "Principal-derived bindings that both satisfy the required filter gate\nand are applied to the query.", + "items": { + "$ref": "#/$defs/RequiredFilterBindingConfig" + }, + "type": "array" + }, + "required_filters": { + "default": [], + "description": "Alternative fields that can satisfy the row-scope gate. A\nprincipal-bound equality filter on any listed field is sufficient, so\nlist multiple fields only when each is an acceptable boundary.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "default_limit", + "max_limit" + ], + "type": "object" + }, + "EntityConfig": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/$defs/EntityAccessConfig" + }, + "aggregates": { + "items": { + "$ref": "#/$defs/AggregateConfig" + }, + "type": "array" + }, + "api": { + "$ref": "#/$defs/EntityApiConfig" + }, + "attribute_release_profiles": { + "description": "Governed identity attribute-release profiles attached to this entity.\nEach profile resolves exactly one subject and returns only the\nconfigured, minimised claims. Empty by default (feature opt-in).", + "items": { + "$ref": "#/$defs/AttributeReleaseProfile" + }, + "type": "array" + }, + "concept_uri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "description": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "fields": { + "items": { + "$ref": "#/$defs/EntityFieldConfig" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "relationships": { + "items": { + "$ref": "#/$defs/EntityRelationshipConfig" + }, + "type": "array" + }, + "spatial": { + "anyOf": [ + { + "$ref": "#/$defs/EntitySpatialConfig" + }, + { + "type": "null" + } + ] + }, + "table": { + "$ref": "#/$defs/ResourceId" + }, + "title": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "table", + "access", + "api" + ], + "type": "object" + }, + "EntityFieldConfig": { + "additionalProperties": false, + "properties": { + "codelist": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "concept_uri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "from": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "language": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "sensitive": { + "default": false, + "type": "boolean" + }, + "unit": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "EntityRelationshipConfig": { + "additionalProperties": false, + "properties": { + "concept_uri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "foreign_key": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RelationshipKind" + }, + "name": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "name", + "kind", + "target", + "foreign_key" + ], + "type": "object" + }, + "EntitySpatialConfig": { + "additionalProperties": false, + "properties": { + "bbox_fields": { + "anyOf": [ + { + "$ref": "#/$defs/SpatialBboxFieldsConfig" + }, + { + "type": "null" + } + ] + }, + "collection_id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "datetime_field": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "description": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "geometry": { + "$ref": "#/$defs/SpatialGeometryConfig" + }, + "max_bbox_degrees": { + "default": 5.0, + "format": "double", + "type": "number" + }, + "max_geometry_vertices": { + "default": 10000, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "title": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "geometry" + ], + "type": "object" + }, + "FieldConfig": { + "additionalProperties": false, + "description": "One column in a resource schema. Physical type and optional\nsemantic annotations used by catalog and schema metadata.", + "properties": { + "codelist": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "concept_uri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "language": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "nullable": { + "default": false, + "type": "boolean" + }, + "sensitive": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/FieldType" + }, + "unit": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "FieldType": { + "description": "Physical type of a column. The set is fixed in V1; semantic types\nare carried via `concept_uri`.", + "enum": [ + "string", + "number", + "integer", + "boolean", + "date", + "timestamp" + ], + "type": "string" + }, + "FilterOp": { + "description": "Filter operator opted into per field.", + "enum": [ + "eq", + "in", + "gte", + "lte", + "between" + ], + "type": "string" + }, + "GovernedPolicyConfig": { + "additionalProperties": false, + "properties": { + "allowed_assurance": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "max_source_age_seconds": { + "default": null, + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "minimum_assurance": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "permitted_jurisdictions": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "permitted_purposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "redaction_fields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "require_consent": { + "default": false, + "type": "boolean" + }, + "require_legal_basis": { + "default": false, + "type": "boolean" + }, + "trusted_context": { + "$ref": "#/$defs/GovernedTrustedContextConfig" + } + }, + "type": "object" + }, + "GovernedTrustedContextConfig": { + "additionalProperties": false, + "properties": { + "asserted_assurance": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "consent_ref": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "jurisdiction": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "legal_basis_ref": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "source_observed_age_seconds": { + "default": null, + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "HumantimeDuration": { + "description": "One or more non-negative integer duration components separated by one ASCII space; supported units are ns, us, ms, s, m, h, d, and w", + "maxLength": 255, + "pattern": "^[0-9]{1,10}(?:ns|us|ms|s|m|h|d|w)(?: [0-9]{1,10}(?:ns|us|ms|s|m|h|d|w))*$", + "type": "string" + }, + "InstanceConfig": { + "additionalProperties": false, + "description": "Stable deployment identity surfaced in redacted operations posture.", + "properties": { + "environment": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "default": "registry-relay-local", + "type": "string" + }, + "jurisdiction": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "owner": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MaterializationMode": { + "description": "How a configured private table is registered for query planning.", + "enum": [ + "snapshot" + ], + "type": "string" + }, + "MetadataConfig": { + "additionalProperties": false, + "description": "Optional split metadata manifest loaded alongside the runtime config.", + "properties": { + "ecosystem_binding": { + "anyOf": [ + { + "$ref": "#/$defs/EcosystemBindingSelectorConfig" + }, + { + "type": "null" + } + ] + }, + "source": { + "$ref": "#/$defs/MetadataSourceConfig" + } + }, + "required": [ + "source" + ], + "type": "object" + }, + "MetadataSourceConfig": { + "additionalProperties": false, + "properties": { + "digest": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "OidcAlgorithm": { + "description": "JWS signature algorithms accepted by the OIDC verifier. Symmetric\nalgorithms (`HS*`) and `none` are intentionally absent: shared-secret\nJWTs are unsafe between a resource server and an IdP, and `none`\ndisables verification entirely.\n\nYAML values are the canonical JWA `alg` strings (`RS256`, `ES256`,\n`EdDSA`), case-sensitive.", + "enum": [ + "RS256", + "ES256", + "EdDSA" + ], + "type": "string" + }, + "OidcConfig": { + "additionalProperties": false, + "description": "OIDC / OAuth2 resource-server configuration. The relay validates\nincoming bearer JWTs against a configured external IdP. No tokens\nare minted here.", + "properties": { + "allow_dev_insecure_fetch_urls": { + "default": false, + "description": "Development-only escape hatch that permits loopback HTTP issuer,\ndiscovery, and JWKS URLs. Private non-loopback networks and cloud\nmetadata endpoints remain denied by the platform fetch policy.", + "type": "boolean" + }, + "allowed_algorithms": { + "description": "Signature algorithms accepted by the verifier. Defaults to\nRS256, ES256, EdDSA. HS\\* and `none` are intentionally absent\nfrom [`OidcAlgorithm`].", + "items": { + "$ref": "#/$defs/OidcAlgorithm" + }, + "type": "array" + }, + "allowed_clients": { + "default": [], + "description": "Optional allowlist of client identifiers, matched against the\ntoken's `azp` (preferred) or `client_id` claim. Empty list\nmeans any client is accepted.", + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_token_types": { + "default": [ + "JWT", + "at+jwt" + ], + "description": "Accepted `typ` JOSE header values. Defaults to `JWT` and\n`at+jwt` (RFC 9068). ID tokens (`id+jwt`) are not access tokens\nand are rejected by default. Tokens without `typ` are rejected by\nthe shared verifier.", + "items": { + "type": "string" + }, + "type": "array" + }, + "audiences": { + "description": "One or more accepted `aud` values. Tokens with no `aud`, or\nwhose `aud` does not intersect this list, are rejected.", + "items": { + "type": "string" + }, + "type": "array" + }, + "discovery_url": { + "default": null, + "description": "OIDC discovery document URL\n(`.well-known/openid-configuration`). The JWKS URL is resolved\nfrom `jwks_uri` in the discovered document.", + "type": [ + "string", + "null" + ] + }, + "issuer": { + "description": "Issuer URL. Compared verbatim against the JWT `iss` claim.", + "type": "string" + }, + "jwks_cache_ttl": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 600 + }, + "description": "JWKS cache TTL. Default 10 minutes. The provider also refreshes\non unknown `kid` (rate-limited) so this controls the steady-state\nrotation pickup latency, not the upper bound." + }, + "jwks_url": { + "default": null, + "description": "JWKS endpoint. Either this or `discovery_url` must be set.\n`discovery_url` takes precedence: when both are configured the\nvalidator rejects the document.", + "type": [ + "string", + "null" + ] + }, + "leeway": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 60 + }, + "description": "Clock skew tolerance applied to `exp` and (when present) `nbf`.\nDefault 60 seconds. Bounded at 5 minutes by validation." + }, + "scope_claim": { + "default": "scope", + "description": "JWT claim whose value carries scopes. Defaults to `scope`, the\nRFC 8693 / RFC 9068 space-separated form. Some IdPs use `scp`\nor `permissions`; the value may be a string, an array of strings,\nor an object keyed by scope name.", + "type": "string" + }, + "scope_map": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Optional rename map: `external_scope -> internal_scope`. Applied\nafter parsing the scope claim, before scope-based access checks\nrun. Useful for adapting IdP role names (`role:foo`) to the\nrelay's `:` shape.", + "type": "object" + }, + "scope_object_required_keys": { + "default": [], + "description": "Keys that must be present inside object-valued role claim values\nbefore the role key is treated as an active scope. Object-valued\nclaims grant no scopes when this list is empty.\nThis is useful for IdPs such as Zitadel where role values are\nkeyed by organization id.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "issuer", + "audiences" + ], + "type": "object" + }, + "ParquetFormatConfig": { + "additionalProperties": false, + "type": "object" + }, + "PostgresEnvironmentNameSchema": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "PostgresTableConfig": { + "additionalProperties": false, + "description": "Structured database table reference. Keeping schema/name separate\navoids parsing dotted identifiers and leaves quoting to connectors.", + "properties": { + "name": { + "type": "string" + }, + "schema": { + "type": "string" + } + }, + "required": [ + "schema", + "name" + ], + "type": "object" + }, + "PublicServiceConfig": { + "additionalProperties": false, + "properties": { + "description": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + }, + "RefreshConfig": { + "description": "Refresh policy. Tagged on `mode:`.", + "oneOf": [ + { + "additionalProperties": false, + "description": "Poll source mtime on `interval` and re-ingest on change.", + "properties": { + "interval": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 60 + } + }, + "mode": { + "const": "mtime", + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Unconditionally re-ingest on `interval`.", + "properties": { + "interval": { + "$ref": "#/$defs/HumantimeDuration" + }, + "mode": { + "const": "interval", + "type": "string" + } + }, + "required": [ + "mode", + "interval" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Re-ingest only on explicit admin call.", + "properties": { + "mode": { + "const": "manual", + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + ] + }, + "RelationshipKind": { + "enum": [ + "belongs_to", + "has_many", + "has_one" + ], + "type": "string" + }, + "ReleaseClaimConfig": { + "additionalProperties": false, + "description": "A single released claim. Exactly one of `source_field` or `expression`\nmust be set: a claim is either a direct source-field projection or a\nCEL-computed value.", + "properties": { + "expression": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseExpressionConfig" + }, + { + "type": "null" + } + ], + "description": "CEL-computed claim value. XOR with `source_field`." + }, + "format": { + "default": null, + "description": "Optional value format hint.", + "type": [ + "string", + "null" + ] + }, + "locale": { + "default": null, + "description": "Optional locale hint.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Released claim name (lower-snake).", + "type": "string" + }, + "required": { + "default": false, + "description": "Whether the claim must be present; a missing required claim denies.", + "type": "boolean" + }, + "sensitivity": { + "anyOf": [ + { + "$ref": "#/$defs/ClaimSensitivity" + }, + { + "type": "null" + } + ], + "description": "Optional privacy sensitivity label." + }, + "shareable": { + "default": true, + "description": "Whether the claim may be shared downstream. Defaults to true.", + "type": "boolean" + }, + "source_field": { + "default": null, + "description": "Source field projected into the claim. XOR with `expression`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ReleaseConditionsConfig": { + "additionalProperties": false, + "description": "CEL release-condition gate. When present, the predicate must hold before\nany claim is projected; failure fails closed (subject denied).", + "properties": { + "denied_code": { + "default": null, + "description": "Optional internal audit code for a release-condition denial.", + "type": [ + "string", + "null" + ] + }, + "expression": { + "$ref": "#/$defs/ReleaseExpressionConfig" + } + }, + "required": [ + "expression" + ], + "type": "object" + }, + "ReleaseExpressionConfig": { + "additionalProperties": false, + "description": "A single CEL expression evaluated over the subject's source projection.", + "properties": { + "cel": { + "type": "string" + } + }, + "required": [ + "cel" + ], + "type": "object" + }, + "ReleaseResponseConfig": { + "additionalProperties": false, + "description": "Response-envelope controls for an attribute-release profile.", + "properties": { + "include_source_metadata": { + "default": false, + "description": "Whether to include profile-sourced metadata in the response body.", + "type": "boolean" + }, + "max_age_seconds": { + "default": null, + "description": "Optional cache lifetime hint for the released bundle, in seconds.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "ReleaseSubjectConfig": { + "additionalProperties": false, + "description": "Subject-identification controls for an attribute-release profile.", + "properties": { + "cardinality": { + "$ref": "#/$defs/SubjectCardinality", + "description": "Expected subject cardinality. Defaults to exactly one." + }, + "id_type": { + "default": null, + "description": "Optional accepted identifier type label.", + "type": [ + "string", + "null" + ] + }, + "input": { + "description": "Request input that carries the subject identifier.", + "type": "string" + }, + "source_field": { + "description": "Source field used to match the subject. Must be an exposed entity field.", + "type": "string" + } + }, + "required": [ + "input", + "source_field" + ], + "type": "object" + }, + "RequiredFilterBindingConfig": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/RequiredFilterBindingSource" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "RequiredFilterBindingSource": { + "enum": [ + "principal_id" + ], + "type": "string" + }, + "ResourceAccessConfig": { + "additionalProperties": false, + "description": "Resource-level scope assignments. Private tables are not exposed as row\nresources in beta; row access is configured on public entities.", + "properties": { + "aggregate_scope": { + "type": "string" + }, + "metadata_scope": { + "type": "string" + } + }, + "required": [ + "metadata_scope", + "aggregate_scope" + ], + "type": "object" + }, + "ResourceApiConfig": { + "additionalProperties": false, + "description": "Resource-level API knobs: per-field filter allowlist, limit caps,\nand the `Data-Purpose` requirement.", + "properties": { + "allowed_filters": { + "items": { + "$ref": "#/$defs/AllowedFilter" + }, + "type": "array" + }, + "default_limit": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "max_limit": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "require_purpose_header": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "default_limit", + "max_limit" + ], + "type": "object" + }, + "ResourceConfig": { + "additionalProperties": false, + "description": "One private storage table under a dataset.\n\nThe public API should not expose these ids. Entity config maps one\nresource into one domain resource, with optional field renaming and\nrelationship declarations.", + "properties": { + "access": { + "$ref": "#/$defs/ResourceAccessConfig" + }, + "aggregates": { + "items": { + "$ref": "#/$defs/AggregateConfig" + }, + "type": "array" + }, + "api": { + "$ref": "#/$defs/ResourceApiConfig" + }, + "id": { + "$ref": "#/$defs/ResourceId" + }, + "materialization": { + "anyOf": [ + { + "$ref": "#/$defs/MaterializationMode" + }, + { + "type": "null" + } + ] + }, + "primary_key": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "refresh": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshConfig" + }, + { + "type": "null" + } + ] + }, + "schema": { + "$ref": "#/$defs/SchemaConfig" + }, + "source": { + "$ref": "#/$defs/SourceConfig" + } + }, + "required": [ + "id", + "source", + "schema" + ], + "type": "object" + }, + "ResourceFormatConfig": { + "additionalProperties": false, + "description": "Storage table format override. If omitted, ingest infers the format\nfrom the source file extension.", + "properties": { + "csv": { + "anyOf": [ + { + "$ref": "#/$defs/CsvFormatConfig" + }, + { + "type": "null" + } + ] + }, + "parquet": { + "anyOf": [ + { + "$ref": "#/$defs/ParquetFormatConfig" + }, + { + "type": "null" + } + ] + }, + "xlsx": { + "anyOf": [ + { + "$ref": "#/$defs/XlsxFormatConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ResourceId": { + "description": "Resource identifier within a dataset.", + "pattern": "^[a-z][a-z0-9_]*$", + "type": "string" + }, + "RotateConfig": { + "additionalProperties": false, + "description": "In-process rotation for the `file` audit sink.", + "properties": { + "max_files": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "max_size_mb": { + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "max_size_mb", + "max_files" + ], + "type": "object" + }, + "RuntimeEnvironmentNameSchema": { + "pattern": "^[^=\\x00]+$", + "type": "string" + }, + "SchemaConfig": { + "additionalProperties": false, + "description": "Declared resource schema. `strict` is the spec's `strict_schema`\nflag; on mismatch ingestion refuses to register the resource.", + "properties": { + "fields": { + "items": { + "$ref": "#/$defs/FieldConfig" + }, + "type": "array" + }, + "strict": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "fields" + ], + "type": "object" + }, + "Sensitivity": { + "description": "Sensitivity classification. Operator-defined values cover common\npersonal and public dataset classifications in V1.", + "enum": [ + "public", + "internal", + "personal", + "confidential", + "secret" + ], + "type": "string" + }, + "ServerConfig": { + "additionalProperties": false, + "description": "HTTP listener and adjacent server-wide knobs.", + "properties": { + "admin_bind": { + "anyOf": [ + { + "$ref": "#/$defs/SocketAddr" + }, + { + "type": "null" + } + ], + "default": null + }, + "bind": { + "$ref": "#/$defs/SocketAddr" + }, + "cache_dir": { + "default": "./cache", + "type": "string" + }, + "cors": { + "$ref": "#/$defs/CorsConfig" + }, + "http1_header_read_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 10 + } + }, + "max_connections": { + "default": 1024, + "format": "uint", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "max_source_file_bytes": { + "default": 268435456, + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "openapi_requires_auth": { + "default": true, + "type": "boolean" + }, + "request_body_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 10 + } + }, + "request_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 30 + } + }, + "trust_proxy": { + "$ref": "#/$defs/TrustProxyConfig" + }, + "xlsx_max_file_bytes": { + "default": 268435456, + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "bind" + ], + "type": "object" + }, + "SocketAddr": { + "description": "Canonical dotted-decimal IPv4 or bracketed IPv6 plus a decimal port from 0 through 65535", + "oneOf": [ + { + "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]):(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$", + "type": "string" + }, + { + "pattern": "^\\[(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\]:(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$", + "type": "string" + } + ] + }, + "SourceConfig": { + "description": "Source plugin selection. Tagged on `type:` so HTTP, S3, or additional\ndatabase variants can land additively later.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "format": { + "anyOf": [ + { + "$ref": "#/$defs/ResourceFormatConfig" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "const": "file", + "type": "string" + } + }, + "required": [ + "type", + "path" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "change_token_sql": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "connect_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 5 + } + }, + "connection_env": { + "$ref": "#/$defs/PostgresEnvironmentNameSchema" + }, + "query": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "query_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": { + "nanos": 0, + "secs": 30 + } + }, + "table": { + "anyOf": [ + { + "$ref": "#/$defs/PostgresTableConfig" + }, + { + "type": "null" + } + ] + }, + "type": { + "const": "postgres", + "type": "string" + } + }, + "required": [ + "type", + "connection_env" + ], + "type": "object" + } + ] + }, + "SpatialBboxFieldsConfig": { + "additionalProperties": false, + "properties": { + "max_x": { + "type": "string" + }, + "max_y": { + "type": "string" + }, + "min_x": { + "type": "string" + }, + "min_y": { + "type": "string" + } + }, + "required": [ + "min_x", + "min_y", + "max_x", + "max_y" + ], + "type": "object" + }, + "SpatialGeometryConfig": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "crs": { + "type": "string" + }, + "kind": { + "const": "point", + "type": "string" + }, + "latitude_field": { + "type": "string" + }, + "longitude_field": { + "type": "string" + } + }, + "required": [ + "kind", + "longitude_field", + "latitude_field", + "crs" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "crs": { + "type": "string" + }, + "field": { + "type": "string" + }, + "kind": { + "const": "geojson", + "type": "string" + } + }, + "required": [ + "kind", + "field", + "crs" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "crs": { + "type": "string" + }, + "field": { + "type": "string" + }, + "kind": { + "const": "wkt", + "type": "string" + } + }, + "required": [ + "kind", + "field", + "crs" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "crs": { + "type": "string" + }, + "field": { + "type": "string" + }, + "kind": { + "const": "wkb", + "type": "string" + } + }, + "required": [ + "kind", + "field", + "crs" + ], + "type": "object" + } + ] + }, + "SpdciDisabilityRegistryConfig": { + "additionalProperties": false, + "description": "Runtime binding from SP DCI Disability Registry sync APIs to one\nconfigured Registry Relay entity.", + "properties": { + "dataset": { + "$ref": "#/$defs/DatasetId" + }, + "disabled_positive_values": { + "default": [ + "approved", + "yes", + "true", + "disabled" + ], + "description": "Case-insensitive values interpreted as disabled.", + "items": { + "type": "string" + }, + "type": "array" + }, + "disabled_status_field": { + "default": "disability_status", + "description": "Entity field whose value determines the SP DCI disabled response.", + "type": "string" + }, + "entity": { + "type": "string" + }, + "query_field": { + "default": "id", + "description": "Entity field filtered when the SP DCI query key is present.", + "type": "string" + }, + "query_key": { + "default": "member.member_identifier", + "description": "Query key accepted from SP DCI `disabled_criteria.query`.", + "type": "string" + } + }, + "required": [ + "dataset", + "entity" + ], + "type": "object" + }, + "SpdciRegistryConfig": { + "additionalProperties": false, + "description": "Runtime binding from a DCI registry sync search API to one configured\nRegistry Relay entity.", + "properties": { + "dataset": { + "$ref": "#/$defs/DatasetId" + }, + "default_limit": { + "default": 100, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "entity": { + "type": "string" + }, + "expression_fields": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "DCI expression or predicate attribute to entity field mappings.", + "type": "object" + }, + "identifiers": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "DCI identifier type to entity field mappings for `idtype-value`.", + "type": "object" + }, + "record_type": { + "default": "spdci-extensions-dci:DisabledPerson", + "type": "string" + }, + "registry_type": { + "default": "ns:org:RegistryType:DR", + "type": "string" + }, + "response_fields": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "SP DCI output path to entity field mappings for direct response\nprojection. A CEL mapping takes precedence when both are set.", + "type": "object" + }, + "response_mapping_path": { + "default": null, + "description": "Optional local CEL mapping document used to shape response records.", + "type": [ + "string", + "null" + ] + }, + "response_schema_path": { + "default": null, + "description": "Optional local JSON Schema used to validate shaped response records.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "dataset", + "entity" + ], + "type": "object" + }, + "SpdciStandardsConfig": { + "additionalProperties": false, + "description": "Social Protection Digital Convergence Initiative (SP DCI) adapter\nconfiguration.", + "properties": { + "disability_registry": { + "anyOf": [ + { + "$ref": "#/$defs/SpdciDisabilityRegistryConfig" + }, + { + "type": "null" + } + ] + }, + "registries": { + "additionalProperties": { + "$ref": "#/$defs/SpdciRegistryConfig" + }, + "type": "object" + } + }, + "type": "object" + }, + "StandardsConfig": { + "additionalProperties": false, + "description": "External standards adapters layered over configured entities.", + "properties": { + "spdci": { + "anyOf": [ + { + "$ref": "#/$defs/SpdciStandardsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "SubjectCardinality": { + "description": "Expected number of subjects a release lookup may match.", + "enum": [ + "one", + "many" + ], + "type": "string" + }, + "Suppression": { + "description": "Disclosure suppression strategy.", + "oneOf": [ + { + "const": "omit", + "description": "Remove rows below the threshold from the response entirely.", + "type": "string" + }, + { + "const": "mask", + "description": "Keep the group key, null out the measures.", + "type": "string" + }, + { + "const": "null", + "description": "Standards-facing alias for `mask`: values are represented as JSON null.", + "type": "string" + } + ] + }, + "TrustProxyConfig": { + "additionalProperties": false, + "description": "`X-Forwarded-For` policy. Until the `ipnet` crate lands in deps we\nkeep CIDR specs as strings and validate format in\n[`validate::run`].", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "trusted_proxies": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "UpdateFrequency": { + "description": "Update cadence; mirrors DCAT-AP `dcterms:accrualPeriodicity`. The\nV1 set is the codes used by the example plus the common alternates.", + "enum": [ + "continuous", + "daily", + "weekly", + "termly", + "monthly", + "quarterly", + "annual", + "irregular", + "as_needed", + "unknown" + ], + "type": "string" + }, + "XlsxFormatConfig": { + "additionalProperties": false, + "properties": { + "data_range": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "header_row": { + "default": null, + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "sheet": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "$id": "https://id.registrystack.org/schemas/registry-relay/registry-relay.config.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Root configuration document. Parsed from YAML at startup.", + "properties": { + "audit": { + "$ref": "#/$defs/AuditConfig" + }, + "auth": { + "$ref": "#/$defs/AuthConfig" + }, + "catalog": { + "$ref": "#/$defs/CatalogConfig" + }, + "config_trust": { + "anyOf": [ + { + "$ref": "#/$defs/ConfigTrustConfig" + }, + { + "type": "null" + } + ] + }, + "consultation": { + "anyOf": [ + { + "$ref": "#/$defs/ConsultationConfig" + }, + { + "type": "null" + } + ], + "description": "Optional governed consultation runtime configuration.\n\nConsultation configuration is restart-only. In particular, audit\npseudonym material is never hot-swapped into an active runtime." + }, + "datasets": { + "items": { + "$ref": "#/$defs/DatasetConfig" + }, + "type": "array" + }, + "deployment": { + "$ref": "#/$defs/DeploymentConfig", + "description": "Operator-declared deployment profile, gate waivers, and assurance\nevidence. Omitting the profile leaves the deployment undeclared, which\nrefuses startup. Use `profile: local` as the explicit development opt-out." + }, + "instance": { + "$ref": "#/$defs/InstanceConfig" + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/$defs/MetadataConfig" + }, + { + "type": "null" + } + ] + }, + "server": { + "$ref": "#/$defs/ServerConfig" + }, + "standards": { + "$ref": "#/$defs/StandardsConfig", + "description": "Optional external standards adapters. The config model is parsed\nin every build so feature-disabled binaries can reject it with a\nstable taxonomy code." + }, + "vocabularies": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" + } + }, + "required": [ + "server", + "catalog", + "auth", + "audit", + "datasets" + ], + "title": "Registry Relay config", + "type": "object" +}