diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 7610a609..5fba7961 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -98,6 +98,34 @@ fn build_openapi_document() -> Value { } } }, + "/admin/v1/posture": { + "get": { + "summary": "Read redacted runtime posture", + "description": "Returns redacted runtime posture for the requested tier. The response body is a registry.ops.posture.v1 document describing instance, configuration, notary, deployment, and audit posture.", + "operationId": "adminPosture", + "security": [ + { "apiKeyAuth": [] }, + { "bearerAuth": [] } + ], + "parameters": [ + { + "name": "tier", + "in": "query", + "required": false, + "description": "Redaction tier for the posture document. Defaults to default.", + "schema": { "type": "string", "enum": ["default", "restricted"] } + } + ], + "responses": { + "200": { "description": "Redacted posture for the requested tier" }, + "400": { "description": "Posture tier is invalid" }, + "401": { "description": "Missing or invalid credential" }, + "403": { "description": "Caller lacks registry_notary:ops_read scope" }, + "500": { "description": "Posture could not be filtered for the requested tier" }, + "503": { "description": "Posture state is unavailable" } + } + } + }, "/admin/v1/config/verify": { "post": { "summary": "Validate a candidate runtime config", @@ -149,7 +177,8 @@ fn build_openapi_document() -> Value { "409": { "description": "Candidate config requires restart and was not applied" }, "400": { "description": "Candidate config is invalid" }, "401": { "description": "Missing or invalid credential" }, - "403": { "description": "Caller lacks registry_notary:admin scope" } + "403": { "description": "Caller lacks registry_notary:admin scope" }, + "503": { "description": "Config apply is unavailable" } } } }, @@ -1275,6 +1304,88 @@ fn add_response_examples(document: &mut Value) { ), ); } + set_problem_response( + document, + "/admin/v1/config/apply", + "post", + "503", + "Config apply is unavailable", + problem_example( + 503, + "config.apply_unavailable", + "Config apply unavailable", + "config_trust.antirollback_state_path is not configured", + ), + ); + set_json_response( + document, + "/admin/v1/posture", + "get", + "200", + "Redacted posture for the requested tier", + serde_json::from_str(registry_platform_ops::NOTARY_POSTURE_EXAMPLE_V1) + .expect("notary posture example is valid JSON"), + ); + set_problem_response( + document, + "/admin/v1/posture", + "get", + "400", + "Posture tier is invalid", + admin_error_example( + 400, + "registry.admin.posture.invalid_tier", + "Admin posture tier invalid", + "posture tier must be default or restricted", + ), + ); + set_problem_response( + document, + "/admin/v1/posture", + "get", + "401", + "Missing or invalid credential", + missing_credential_example(), + ); + set_problem_response( + document, + "/admin/v1/posture", + "get", + "403", + "Caller lacks registry_notary:ops_read scope", + problem_example( + 403, + "auth.scope_denied", + "Scope denied", + "missing required scope", + ), + ); + set_problem_response( + document, + "/admin/v1/posture", + "get", + "500", + "Posture could not be filtered for the requested tier", + problem_example( + 500, + "posture.filter_failed", + "Admin posture unavailable", + "admin posture could not be filtered for the requested tier", + ), + ); + set_problem_response( + document, + "/admin/v1/posture", + "get", + "503", + "Posture state is unavailable", + admin_error_example( + 503, + "posture.unavailable", + "Admin posture unavailable", + "posture state is unavailable", + ), + ); set_json_response( document, "/openapi.json", @@ -3850,6 +3961,7 @@ mod tests { "/admin/v1/config/verify", "/admin/v1/config/dry-run", "/admin/v1/config/apply", + "/admin/v1/posture", "/openapi.json", "/.well-known/evidence-service", "/.well-known/evidence/jwks.json", @@ -3877,6 +3989,35 @@ mod tests { } } + #[test] + fn documents_admin_posture_query_and_response_contract() { + let doc = openapi_document(); + let posture = &doc["paths"]["/admin/v1/posture"]["get"]; + assert!(posture.is_object(), "admin posture GET is documented"); + // Authenticated read: posture must not be exposed as a public route. + assert_eq!( + posture["security"], + json!([{ "apiKeyAuth": [] }, { "bearerAuth": [] }]) + ); + // The tier query parameter is constrained to the runtime-accepted values. + let tier = &posture["parameters"][0]; + assert_eq!(tier["name"], json!("tier")); + assert_eq!(tier["in"], json!("query")); + assert_eq!(tier["schema"]["enum"], json!(["default", "restricted"])); + // Every response the handler can return is documented. + for status in ["200", "400", "401", "403", "500", "503"] { + assert!( + posture["responses"][status].is_object(), + "posture documents the {status} response" + ); + } + // The 200 body is documented with the real posture document shape. + assert_eq!( + posture["responses"]["200"]["content"]["application/json"]["example"]["schema"], + json!("registry.ops.posture.v1") + ); + } + #[test] fn document_info_tracks_crate_metadata() { let doc = serde_json::to_value(openapi_document()).expect("document serializes"); @@ -4036,6 +4177,7 @@ mod tests { ("/healthz", "get", "200"), ("/ready", "get", "200"), ("/admin/v1/capabilities", "get", "200"), + ("/admin/v1/posture", "get", "200"), ("/admin/v1/config/verify", "post", "200"), ("/admin/v1/config/dry-run", "post", "200"), ("/admin/v1/config/apply", "post", "200"), @@ -4154,6 +4296,12 @@ mod tests { ("/admin/v1/config/apply", "post", "400"), ("/admin/v1/config/apply", "post", "401"), ("/admin/v1/config/apply", "post", "403"), + ("/admin/v1/config/apply", "post", "503"), + ("/admin/v1/posture", "get", "400"), + ("/admin/v1/posture", "get", "401"), + ("/admin/v1/posture", "get", "403"), + ("/admin/v1/posture", "get", "500"), + ("/admin/v1/posture", "get", "503"), ("/.well-known/evidence-service", "get", "401"), ("/v1/claims", "get", "401"), ("/v1/claims/{claim_id}", "get", "401"), diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 8dd6f966..b9ee7e11 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -2358,6 +2358,23 @@ } }, "description": "Candidate config requires restart and was not applied" + }, + "503": { + "content": { + "application/problem+json": { + "example": { + "code": "config.apply_unavailable", + "detail": "config_trust.antirollback_state_path is not configured", + "status": 503, + "title": "Config apply unavailable", + "type": "https://id.registrystack.org/problems/registry-notary/config/apply_unavailable" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Config apply is unavailable" } }, "security": [ @@ -2700,6 +2717,227 @@ "summary": "Update credential lifecycle status" } }, + "/admin/v1/posture": { + "get": { + "description": "Returns redacted runtime posture for the requested tier. The response body is a registry.ops.posture.v1 document describing instance, configuration, notary, deployment, and audit posture.", + "operationId": "adminPosture", + "parameters": [ + { + "description": "Redaction tier for the posture document. Defaults to default.", + "in": "query", + "name": "tier", + "required": false, + "schema": { + "enum": [ + "default", + "restricted" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "build": { + "package": "registry-notary", + "version": "0.1.0" + }, + "component": "registry-notary", + "configuration": { + "dynamic_reload_supported": false, + "last_apply_at": null, + "last_apply_result": null, + "last_bundle_id": null, + "last_bundle_sequence": null, + "last_config_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "restart_required": false, + "source": "local_file" + }, + "instance": { + "environment": "production", + "id": "civil-notary-prod", + "jurisdiction": "example-country", + "owner": "Ministry of Interior" + }, + "notary": { + "claim_count": 6, + "credential_profile_count": 2, + "credential_status": { + "enabled": true, + "storage": "redis" + }, + "federation": { + "allowed_profile_count": 3, + "allowed_purpose_count": 4, + "enabled": true, + "peer_count": 2, + "supported_protocol_versions": [ + "registry-notary-federation/v0.1" + ] + }, + "oid4vci": { + "credential_configuration_count": 1, + "enabled": true + }, + "replay": { + "ready": true, + "storage": "redis" + }, + "self_attestation": { + "allowed_claim_count": 1, + "allowed_purpose_count": 1, + "credential_profile_count": 1, + "enabled": true, + "rate_limit_mode": "in_process", + "wallet_origin_count": 1 + }, + "source_connection_counts": { + "http": 2, + "postgres": 1 + } + }, + "observed_at": "2026-06-04T10:00:00Z", + "posture": { + "audit": { + "checkpoint_status": "available", + "configured": true, + "latest_sequence": 88, + "latest_tail_hash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "sink_type": "file", + "verification_status": "verified", + "verified_at": "2026-06-04T09:59:30Z" + }, + "findings": [], + "warnings": [] + }, + "runtime": { + "admin_enabled": true, + "auth_mode": "oidc", + "readiness": "ready" + }, + "schema": "registry.ops.posture.v1", + "standards_artifacts": { + "evidence_service_discovery": { + "media_type": "application/json", + "observed_status": "available", + "sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "jwks": { + "media_type": "application/jwk-set+json", + "observed_status": "available", + "sha256": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + "tier": "default" + } + } + }, + "description": "Redacted posture for the requested tier" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "code": "registry.admin.posture.invalid_tier", + "detail": "posture tier must be default or restricted", + "message": "posture tier must be default or restricted", + "schema": "registry.admin.error.v1", + "status": 400, + "title": "Admin posture tier invalid", + "type": "https://id.registrystack.org/problems/registry-notary/registry/admin/posture/invalid_tier" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Posture tier is invalid" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "code": "auth.missing_credential", + "detail": "missing authentication credential", + "status": 401, + "title": "Missing credential", + "type": "https://id.registrystack.org/problems/registry-notary/auth/missing_credential" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Missing or invalid credential" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "code": "auth.scope_denied", + "detail": "missing required scope", + "status": 403, + "title": "Scope denied", + "type": "https://id.registrystack.org/problems/registry-notary/auth/scope_denied" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Caller lacks registry_notary:ops_read scope" + }, + "500": { + "content": { + "application/problem+json": { + "example": { + "code": "posture.filter_failed", + "detail": "admin posture could not be filtered for the requested tier", + "status": 500, + "title": "Admin posture unavailable", + "type": "https://id.registrystack.org/problems/registry-notary/posture/filter_failed" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Posture could not be filtered for the requested tier" + }, + "503": { + "content": { + "application/problem+json": { + "example": { + "code": "posture.unavailable", + "detail": "posture state is unavailable", + "message": "posture state is unavailable", + "schema": "registry.admin.error.v1", + "status": 503, + "title": "Admin posture unavailable", + "type": "https://id.registrystack.org/problems/registry-notary/posture/unavailable" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Posture state is unavailable" + } + }, + "security": [ + { + "apiKeyAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Read redacted runtime posture" + } + }, "/admin/v1/reload": { "post": { "description": "Standalone mode does not support runtime configuration reload. Operators should call /admin/v1/capabilities before invoking product-specific reload operations.",