From 3444979245fe808841c4b130c24d0801e85e3f49 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 3 Jul 2026 14:51:21 +0700 Subject: [PATCH 1/4] fix(registryctl): permit tutorial purpose in generated notary policy The "Evaluate a claim with Registry Notary" tutorial failed at its central step: `registryctl notary smoke` reported 6 PASS then `FAIL notary evaluator can verify starter claim`, and the documented evaluate curl returned 403 pdp.purpose_not_permitted instead of 200 satisfied. Root cause: the generated benefits/local-relay notary config (notary_config.yaml.tmpl) declared a source binding with no matching.allowed_purposes. Source-binding PDP policies run with permit_unconstrained = false, so purpose_gate_is_declared always returns true; an empty purpose allow-list therefore denies every request with pdp.purpose_not_permitted before any evidence lookup. This also made the tutorial's 409 evidence.not_available teaching point (per-9999) unreachable. Fix: add matching.allowed_purposes with the tutorial purpose to the generated source binding, mirroring what the OpenCRVS/DCI sample template already does and matching the purpose the smoke check and docs send. Security review notes: - The change only permits one additional purpose (https://example.local/purpose/tutorial) in the LOCAL sample project that registryctl generates from `init relay --sample benefits` + `add notary`. It does not touch production defaults, shared PDP behavior, or any committed runtime config; the server keeps failing closed on empty allow-lists. - Purpose is the only gate added. No legal-basis, consent, jurisdiction, or assurance constraints are relaxed; the sample carries none of those, matching the tutorial's single-purpose evaluate request. Test: added local_relay_notary_config_permits_tutorial_purpose, which parses the generated notary config and asserts the person source binding permits TUTORIAL_PURPOSE. Signed-off-by: Jeremi Joslin --- crates/registryctl/src/lib.rs | 28 +++++++++++++++++++ .../src/templates/notary_config.yaml.tmpl | 7 +++++ 2 files changed, 35 insertions(+) diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 1e2ef019..745bf839 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -5407,6 +5407,34 @@ workflows: ); } + #[test] + fn local_relay_notary_config_permits_tutorial_purpose() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("my-first-api"); + init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + + let notary_config = fs::read_to_string(project.join("notary/config.yaml")).unwrap(); + let parsed_config: registry_notary_core::StandaloneRegistryNotaryConfig = + serde_yaml::from_str(¬ary_config).unwrap(); + parsed_config.validate().unwrap(); + + // The source-binding PDP policy fails closed: with no purpose allow-list the + // notary evaluate step denies every request with pdp.purpose_not_permitted + // before any evidence lookup. The generated project must permit the same + // tutorial purpose its smoke check and docs send. + let matching = &parsed_config.evidence.claims[0].source_bindings["person"].matching; + assert!( + matching + .allowed_purposes + .iter() + .any(|purpose| purpose == TUTORIAL_PURPOSE), + "generated local-relay notary source binding must permit the tutorial purpose; \ + got allowed_purposes = {:?}", + matching.allowed_purposes + ); + } + #[test] fn local_env_after_notary_add_appends_notary_and_source_tokens() { let temp = TempDir::new().unwrap(); diff --git a/crates/registryctl/src/templates/notary_config.yaml.tmpl b/crates/registryctl/src/templates/notary_config.yaml.tmpl index 540de5c3..90fb8237 100644 --- a/crates/registryctl/src/templates/notary_config.yaml.tmpl +++ b/crates/registryctl/src/templates/notary_config.yaml.tmpl @@ -66,6 +66,13 @@ evidence: op: eq cardinality: one fields: {} + matching: + # The source-binding PDP policy fails closed on purpose: with no + # allow-list every evaluate request is denied with + # pdp.purpose_not_permitted before any evidence lookup. Permit the + # tutorial purpose this sample's smoke check and docs send. + allowed_purposes: + - https://example.local/purpose/tutorial rule: type: exists source: person From 2d065dd895dcc34e2b611251802945d948ccc251 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 3 Jul 2026 14:55:08 +0700 Subject: [PATCH 2/4] fix(registryctl): always print docs URL for open commands `registryctl open` and `registryctl notary open` promised to "open or print" the local API docs URL, but printed nothing in headless sessions. On macOS `open ` returns exit 0 even over SSH with no display, so the conditional fallback (print only when the launch failed) never fired and the user was left with no URL. Always print the docs URL(s) first, then best-effort launch a browser. Extract relay_open_lines / notary_open_lines pure helpers so the surfaced URLs are unit testable without spawning a browser during tests. Tests: relay_open_always_reports_docs_url_for_headless_fallback and notary_open_always_reports_docs_url_for_headless_fallback. Signed-off-by: Jeremi Joslin --- crates/registryctl/src/lib.rs | 60 +++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 745bf839..478bff4b 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -758,13 +758,20 @@ pub fn open_project(project_dir: &Path) -> Result<()> { return notary_open_project(project_dir); } let docs_url = format!("{}{}", project.relay_base_url()?, RELAY_DOCS_PATH); - let open_result = Command::new("open").arg(&docs_url).status(); - if !matches!(open_result, Ok(status) if status.success()) { - println!("{docs_url}"); + // Always surface the URL: `open` reports success even in headless macOS + // sessions where nothing actually launches, so a conditional fallback would + // silently print nothing. Then best-effort open a browser for desktops. + for line in relay_open_lines(&docs_url) { + println!("{line}"); } + let _ = Command::new("open").arg(&docs_url).status(); Ok(()) } +fn relay_open_lines(docs_url: &str) -> Vec { + vec![docs_url.to_string()] +} + pub fn logs_project(project_dir: &Path) -> Result<()> { let project = Project::load(project_dir)?; run_compose_for_project(project_dir, &project, &["logs"])?; @@ -838,14 +845,23 @@ pub fn notary_open_project(project_dir: &Path) -> Result<()> { let project = Project::load(project_dir)?; let notary_base_url = project.notary_base_url()?; let docs_url = format!("{notary_base_url}{NOTARY_DOCS_PATH}"); - let open_result = Command::new("open").arg(&docs_url).status(); - if !matches!(open_result, Ok(status) if status.success()) { - println!("Notary API docs: {docs_url}"); - println!("OpenAPI JSON: {notary_base_url}{NOTARY_OPENAPI_PATH}"); + // Always surface the URLs: `open` reports success even in headless macOS + // sessions where nothing actually launches, so a conditional fallback would + // silently print nothing. Then best-effort open a browser for desktops. + for line in notary_open_lines(notary_base_url) { + println!("{line}"); } + let _ = Command::new("open").arg(&docs_url).status(); Ok(()) } +fn notary_open_lines(notary_base_url: &str) -> Vec { + vec![ + format!("Notary API docs: {notary_base_url}{NOTARY_DOCS_PATH}"), + format!("OpenAPI JSON: {notary_base_url}{NOTARY_OPENAPI_PATH}"), + ] +} + pub fn bruno_generate_project(project_dir: &Path, force: bool) -> Result<()> { let project = Project::load(project_dir)?; let secrets = LocalEnv::load(&project_dir.join(&project.local.secrets_env))?; @@ -5407,6 +5423,36 @@ workflows: ); } + #[test] + fn relay_open_always_reports_docs_url_for_headless_fallback() { + // On macOS `open ` returns success even over SSH with no display, + // so a conditional fallback never fires. The URL must always be surfaced. + let lines = relay_open_lines("http://127.0.0.1:4242/docs"); + assert!( + lines + .iter() + .any(|line| line.contains("http://127.0.0.1:4242/docs")), + "relay open must always print the docs URL for headless environments; got {lines:?}" + ); + } + + #[test] + fn notary_open_always_reports_docs_url_for_headless_fallback() { + let lines = notary_open_lines("http://127.0.0.1:4255"); + assert!( + lines + .iter() + .any(|line| line.contains("http://127.0.0.1:4255/docs")), + "notary open must always print the docs URL for headless environments; got {lines:?}" + ); + assert!( + lines + .iter() + .any(|line| line.contains("http://127.0.0.1:4255/openapi.json")), + "notary open must always print the OpenAPI URL for headless environments; got {lines:?}" + ); + } + #[test] fn local_relay_notary_config_permits_tutorial_purpose() { let temp = TempDir::new().unwrap(); From c36039d5162979abbe5c42fa8660829896eb8266 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 3 Jul 2026 15:11:34 +0700 Subject: [PATCH 3/4] fix(registryctl): permit tutorial purpose in standalone notary template registryctl notary smoke sends the tutorial purpose for standalone projects too, and the source-binding PDP policy fails closed, so the standalone template's empty allow-list denied every evaluation with pdp.purpose_not_permitted, same defect as the benefits template. Security review notes: permits exactly one purpose in the generated local project config; no production defaults or shared PDP behavior change, and the server still fails closed on empty allow-lists. Also gives the generated file an orientation header comment matching the relay template's voice. Signed-off-by: Jeremi Joslin --- crates/registryctl/src/lib.rs | 25 +++++++++++++++++++ .../notary_standalone_config.yaml.tmpl | 11 ++++++++ 2 files changed, 36 insertions(+) diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 478bff4b..586c3e7c 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -5481,6 +5481,31 @@ workflows: ); } + #[test] + fn standalone_notary_config_permits_tutorial_purpose() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("my-notary"); + init_standalone_notary_project(&project, default_notary_options()).unwrap(); + + let notary_config = fs::read_to_string(project.join("notary/config.yaml")).unwrap(); + let parsed_config: registry_notary_core::StandaloneRegistryNotaryConfig = + serde_yaml::from_str(¬ary_config).unwrap(); + parsed_config.validate().unwrap(); + + // `registryctl notary smoke` sends the tutorial purpose for standalone + // projects too, so the fail-closed source-binding policy must permit it. + let matching = &parsed_config.evidence.claims[0].source_bindings["person"].matching; + assert!( + matching + .allowed_purposes + .iter() + .any(|purpose| purpose == TUTORIAL_PURPOSE), + "generated standalone notary source binding must permit the tutorial purpose; \ + got allowed_purposes = {:?}", + matching.allowed_purposes + ); + } + #[test] fn local_env_after_notary_add_appends_notary_and_source_tokens() { let temp = TempDir::new().unwrap(); diff --git a/crates/registryctl/src/templates/notary_standalone_config.yaml.tmpl b/crates/registryctl/src/templates/notary_standalone_config.yaml.tmpl index afdf0780..9e7fb412 100644 --- a/crates/registryctl/src/templates/notary_standalone_config.yaml.tmpl +++ b/crates/registryctl/src/templates/notary_standalone_config.yaml.tmpl @@ -1,4 +1,8 @@ # Generated by registryctl. +# This file is the Notary contract for this project: evaluator API-key auth, +# the evidence source connection, and the starter claim with its purpose-gated +# source binding. Edit claims and bindings as your evidence needs change, then +# run `registryctl doctor` to validate. server: bind: 0.0.0.0:8080 openapi_requires_auth: false @@ -67,6 +71,13 @@ evidence: op: eq cardinality: one fields: {} + matching: + # Purposes this binding accepts. Requests carrying any other + # purpose are denied with pdp.purpose_not_permitted before any + # evidence is looked up; an empty list denies everything. + # Replace or extend this list with your own purpose URIs. + allowed_purposes: + - https://example.local/purpose/tutorial rule: type: exists source: {{source_binding}} From 293076bcd7141a0b8baf3c60377cadb08480d790 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 3 Jul 2026 15:11:34 +0700 Subject: [PATCH 4/4] docs(registryctl): speak to adopters in generated notary config comments The generated configs are read by tutorial followers, not maintainers. Reword the purpose allow-list comment to state the field's contract (deny-by-default, the error code, what to edit) instead of justifying the change, add orientation headers matching the relay template, and warn in the OpenCRVS/DCI template that matching context constraints must stay in sync with the evaluator key's authorization_details. Signed-off-by: Jeremi Joslin --- .../src/templates/notary_config.yaml.tmpl | 12 ++++++++---- .../templates/notary_opencrvs_dci_config.yaml.tmpl | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/registryctl/src/templates/notary_config.yaml.tmpl b/crates/registryctl/src/templates/notary_config.yaml.tmpl index 90fb8237..e0d1a78f 100644 --- a/crates/registryctl/src/templates/notary_config.yaml.tmpl +++ b/crates/registryctl/src/templates/notary_config.yaml.tmpl @@ -1,4 +1,8 @@ # Generated by registryctl. +# This file is the Notary contract for the local sample: evaluator API-key +# auth, the Relay evidence source connection, and the starter claim with its +# purpose-gated source binding. Edit claims and bindings as your evidence +# needs change, then run `registryctl doctor` to validate. server: bind: 0.0.0.0:8080 openapi_requires_auth: false @@ -67,10 +71,10 @@ evidence: cardinality: one fields: {} matching: - # The source-binding PDP policy fails closed on purpose: with no - # allow-list every evaluate request is denied with - # pdp.purpose_not_permitted before any evidence lookup. Permit the - # tutorial purpose this sample's smoke check and docs send. + # Purposes this binding accepts. Requests carrying any other + # purpose are denied with pdp.purpose_not_permitted before any + # evidence is looked up; an empty list denies everything. + # Replace or extend this list with your own purpose URIs. allowed_purposes: - https://example.local/purpose/tutorial rule: diff --git a/crates/registryctl/src/templates/notary_opencrvs_dci_config.yaml.tmpl b/crates/registryctl/src/templates/notary_opencrvs_dci_config.yaml.tmpl index ef64c007..febfb977 100644 --- a/crates/registryctl/src/templates/notary_opencrvs_dci_config.yaml.tmpl +++ b/crates/registryctl/src/templates/notary_opencrvs_dci_config.yaml.tmpl @@ -1,4 +1,9 @@ # Generated by registryctl. +# This file is the Notary contract for the OpenCRVS/DCI demo: evaluator +# API-key auth with demo authorization details, the DCI source connection, +# and a civil-registry claim gated by purpose and context constraints. Edit +# claims and bindings as your evidence needs change, then run +# `registryctl doctor` to validate. server: bind: 0.0.0.0:8080 openapi_requires_auth: false @@ -89,6 +94,10 @@ evidence: cardinality: one fields: {} matching: + # Requests must carry an allowed purpose and satisfy every context + # constraint below. The demo values mirror the evaluator key's + # authorization_details above; keep the two in sync when you edit + # either, or evaluations are denied before any evidence lookup. policy_id: registryctl.opencrvs-dci.birth-record.lookup.v1 method: configured_lookup allowed_purposes: