diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 1e2ef019..586c3e7c 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,89 @@ 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(); + 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 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_config.yaml.tmpl b/crates/registryctl/src/templates/notary_config.yaml.tmpl index 540de5c3..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 @@ -66,6 +70,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: person 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: 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}}