diff --git a/Cargo.lock b/Cargo.lock index c3209c88f..09cfbc015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5707,11 +5707,13 @@ dependencies = [ "base64", "clap", "crc32fast", + "ed25519-dalek", "getrandom 0.4.3", "hex", "include_dir", "ipnet", "jsonschema 0.18.3", + "rcgen", "regex", "registry-config-report", "registry-notary-core", diff --git a/crates/registryctl/Cargo.toml b/crates/registryctl/Cargo.toml index cfd647cf9..b3e0c541e 100644 --- a/crates/registryctl/Cargo.toml +++ b/crates/registryctl/Cargo.toml @@ -17,10 +17,12 @@ anyhow = "1" base64 = "0.22" clap = { version = "4.5", features = ["derive"] } crc32fast = "1.4" +ed25519-dalek.workspace = true getrandom = "0.4" hex.workspace = true include_dir = "0.7" ipnet.workspace = true +rcgen.workspace = true registry-config-report = { workspace = true } registry-notary-core.workspace = true registry-notary-server = { workspace = true, features = ["registry-notary-cel"] } diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index a0fabddea..0da8be6aa 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -11,6 +11,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, bail, Context, Result}; use base64::Engine as _; use clap::ValueEnum; +use ed25519_dalek::SigningKey as Ed25519SigningKey; +use rcgen::{generate_simple_self_signed, CertifiedKey}; use registry_config_report::{ ConfigDiagnostic, ConfigDiagnosticReport, ConfigSourceKind, ConfigSourceRef, DiagnosticSeverity, DiagnosticSummary, RegistryctlProductReport, RegistryctlProjectRef, @@ -55,8 +57,10 @@ const RELAY_IMAGE_REPOSITORY: &str = "ghcr.io/registrystack/registry-relay"; const NOTARY_IMAGE_REPOSITORY: &str = "ghcr.io/registrystack/registry-notary"; const LINUX_AMD64_PLATFORM: &str = "linux/amd64"; const RELAY_BASE_URL: &str = "http://127.0.0.1:4242"; +const NOTARY_BASE_URL: &str = "http://127.0.0.1:4255"; const RELAY_DOCS_PATH: &str = "/docs"; const TUTORIAL_PURPOSE: &str = "https://example.local/purpose/tutorial"; +const TUTORIAL_IDENTITY_PURPOSE: &str = "https://example.local/purpose/identity-verification"; const BRUNO_COLLECTION_DIR: &str = "bruno/registry-api"; const BRUNO_GENERATED_MANIFEST: &str = "bruno/registry-api/.registryctl-generated"; const REGISTRY_STACK_RUNTIME_UID_ENV: &str = "REGISTRY_STACK_RUNTIME_UID"; @@ -116,6 +120,24 @@ pub struct InitReport { pub source: InitSource, pub artifacts: InitArtifacts, } +const NOTARY_PROJECT_DIR: &str = "notary/project"; +#[cfg(test)] +const NOTARY_CONFIG_DIR: &str = "notary/project/.registry-stack/build/local/private/notary/config"; +const NOTARY_CONFIG_PATH: &str = + "notary/project/.registry-stack/build/local/private/notary/config/notary.yaml"; +#[cfg(test)] +const CONSULTATION_RELAY_CONFIG_DIR: &str = + "notary/project/.registry-stack/build/local/private/relay/config"; +const CONSULTATION_RELAY_CONFIG_PATH: &str = + "notary/project/.registry-stack/build/local/private/relay/config/relay.yaml"; +const NOTARY_CLAIM_FILE: &str = "notary/project/registry-stack.yaml"; +const NOTARY_RELAY_TOKEN_PATH: &str = "secrets/notary-relay.jwt"; +const NOTARY_RELAY_WORKLOAD_JWK_ENV: &str = "REGISTRY_NOTARY_RELAY_WORKLOAD_JWK"; +const NOTARY_RELAY_WORKLOAD_KID: &str = "registry-notary-relay-workload"; +const CONSULTATION_POSTGRES_CERT_PATH: &str = "secrets/consultation-postgres.crt"; +const CONSULTATION_POSTGRES_KEY_PATH: &str = "secrets/consultation-postgres.key"; +const CONSULTATION_RELAY_STATE_DIR: &str = "state/relay-consultation"; +const CONSULTATION_RELAY_CACHE_PATH: &str = "state/relay-consultation/cache"; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] @@ -141,6 +163,10 @@ impl RegistryctlImageLock { fn relay_image(&self) -> &str { &self.images.registry_relay } + + fn notary_image(&self) -> &str { + &self.images.registry_notary + } } pub fn registryctl_image_lock_filename() -> String { @@ -1204,7 +1230,11 @@ pub fn start_project(project_dir: &Path) -> Result<()> { } fn start_project_with_timeout(project_dir: &Path, timeout: Duration) -> Result<()> { - let project = Project::load(project_dir)?; + let mut project = Project::load(project_dir)?; + if project.notary.is_some() { + prepare_notary_runtime(project_dir)?; + project = Project::load(project_dir)?; + } validate_project_fingerprints(project_dir, &project)?; run_compose_for_project(project_dir, &project, &["up", "-d"])?; if project.relay.is_some() { @@ -1213,6 +1243,12 @@ fn start_project_with_timeout(project_dir: &Path, timeout: Duration) -> Result<( println!("Relay API: {relay_base_url}"); println!("API docs: {relay_base_url}{RELAY_DOCS_PATH}"); } + if project.notary.is_some() { + let notary_base_url = project.notary_base_url()?; + wait_for_ready("Notary", notary_base_url, timeout)?; + println!("Notary API: {notary_base_url}"); + println!("Notary docs: {notary_base_url}{RELAY_DOCS_PATH}"); + } Ok(()) } @@ -1239,6 +1275,13 @@ pub fn status_project(project_dir: &Path) -> Result<()> { println!("Relay API: {relay_base_url}"); println!("API docs: {relay_base_url}{RELAY_DOCS_PATH}"); } + if project.notary.is_some() { + let notary_base_url = project.notary_base_url()?; + print_probe_status("notary healthz", &format!("{notary_base_url}/healthz")); + print_probe_status("notary ready", &format!("{notary_base_url}/ready")); + println!("Notary API: {notary_base_url}"); + println!("Notary docs: {notary_base_url}{RELAY_DOCS_PATH}"); + } Ok(()) } @@ -1774,6 +1817,543 @@ impl SecretRedactor { } } +#[derive(Debug, Serialize)] +pub struct AddNotaryReport { + status: &'static str, + project: String, + notary_url: &'static str, + claim_file: &'static str, +} + +/// Adds the local tutorial Notary journey to a generated spreadsheet project. +/// +/// The authored files remain the source of truth. `registryctl start` rebuilds +/// the reviewed Relay and Notary inputs so edits to the claim take effect after +/// a restart. +pub fn add_notary_to_project( + project_dir: &Path, + image_lock: &RegistryctlImageLock, +) -> Result { + let mut project = Project::load(project_dir)?; + if project.relay.is_none() { + bail!("add notary requires a generated Relay spreadsheet project"); + } + if project.notary.is_some() { + bail!("this project already has a Notary add-on"); + } + let workbook = project_dir.join("data/benefits_casework.xlsx"); + if !workbook.is_file() { + bail!( + "add notary requires the benefits workbook at {}", + workbook.display() + ); + } + let notary_dir = project_dir.join("notary"); + for relative in [ + "notary", + NOTARY_RELAY_TOKEN_PATH, + CONSULTATION_POSTGRES_CERT_PATH, + CONSULTATION_POSTGRES_KEY_PATH, + CONSULTATION_RELAY_STATE_DIR, + ] { + let path = project_dir.join(relative); + match fs::symlink_metadata(&path) { + Ok(_) => bail!( + "Notary destination already exists and was not modified: {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to stat {}", path.display())); + } + } + } + let secrets_path = project_dir.join(&project.local.secrets_env); + let compose_path = project_dir.join("compose.yaml"); + let manifest_path = project_dir.join("registryctl.yaml"); + let original_secrets = fs::read_to_string(&secrets_path) + .with_context(|| format!("failed to read {}", secrets_path.display()))?; + let original_compose = fs::read_to_string(&compose_path) + .with_context(|| format!("failed to read {}", compose_path.display()))?; + let original_manifest = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + + let result = (|| { + write_notary_addon_files(project_dir)?; + write_local_postgres_tls(project_dir)?; + add_notary_local_secrets(project_dir)?; + create_notary_state_dirs(project_dir)?; + prepare_notary_runtime(project_dir)?; + merge_notary_compose(project_dir, image_lock)?; + + project.project.products.push("registry-notary".to_string()); + project.runtime.notary_image = Some(image_lock.notary_image().to_string()); + project.runtime.notary_base_url = Some(NOTARY_BASE_URL.to_string()); + project.notary = Some(ProjectNotary { + project: PathBuf::from(NOTARY_PROJECT_DIR), + config: PathBuf::from(NOTARY_CONFIG_PATH), + consultation_relay_config: PathBuf::from(CONSULTATION_RELAY_CONFIG_PATH), + claim_file: PathBuf::from(NOTARY_CLAIM_FILE), + workload_token: PathBuf::from(NOTARY_RELAY_TOKEN_PATH), + }); + let manifest = serde_yaml::to_string(&project) + .context("failed to render registryctl manifest with Notary")?; + write_text(project_dir.join("registryctl.yaml"), &manifest)?; + Ok(()) + })(); + + if let Err(error) = result { + let mut rollback_errors = Vec::new(); + if let Err(rollback) = fs::remove_dir_all(¬ary_dir) { + if rollback.kind() != std::io::ErrorKind::NotFound { + rollback_errors.push(format!("remove {}: {rollback}", notary_dir.display())); + } + } + let consultation_state_dir = project_dir.join(CONSULTATION_RELAY_STATE_DIR); + if let Err(rollback) = fs::remove_dir_all(&consultation_state_dir) { + if rollback.kind() != std::io::ErrorKind::NotFound { + rollback_errors.push(format!( + "remove {}: {rollback}", + consultation_state_dir.display() + )); + } + } + if let Err(rollback) = write_private_text(&secrets_path, &original_secrets) { + rollback_errors.push(format!("restore {}: {rollback:#}", secrets_path.display())); + } + for generated_sidecar_path in [ + NOTARY_RELAY_TOKEN_PATH, + CONSULTATION_POSTGRES_CERT_PATH, + CONSULTATION_POSTGRES_KEY_PATH, + ] { + let path = project_dir.join(generated_sidecar_path); + if let Err(rollback) = fs::remove_file(&path) { + if rollback.kind() != std::io::ErrorKind::NotFound { + rollback_errors.push(format!("remove {}: {rollback}", path.display())); + } + } + } + if let Err(rollback) = write_text(compose_path, &original_compose) { + rollback_errors.push(format!("restore Compose file: {rollback:#}")); + } + if let Err(rollback) = write_text(manifest_path, &original_manifest) { + rollback_errors.push(format!("restore project manifest: {rollback:#}")); + } + if !rollback_errors.is_empty() { + bail!( + "failed to add Notary: {error:#}; rollback also failed: {}", + rollback_errors.join("; ") + ); + } + return Err(error); + } + + Ok(AddNotaryReport { + status: "added", + project: project.project.name, + notary_url: NOTARY_BASE_URL, + claim_file: NOTARY_CLAIM_FILE, + }) +} + +fn write_notary_addon_files(project_dir: &Path) -> Result<()> { + let files = [ + ( + "notary/project/registry-stack.yaml", + include_str!("templates/notary_addon/registry-stack.yaml"), + ), + ( + "notary/project/entities/person.yaml", + include_str!("templates/notary_addon/entities/person.yaml"), + ), + ( + "notary/project/integrations/person-demographics/integration.yaml", + include_str!( + "templates/notary_addon/integrations/person-demographics/integration.yaml" + ), + ), + ( + "notary/project/integrations/person-demographics/fixtures/match.yaml", + include_str!( + "templates/notary_addon/integrations/person-demographics/fixtures/match.yaml" + ), + ), + ( + "notary/project/integrations/person-demographics/fixtures/pending.yaml", + include_str!( + "templates/notary_addon/integrations/person-demographics/fixtures/pending.yaml" + ), + ), + ( + "notary/project/integrations/person-demographics/fixtures/no-match.yaml", + include_str!( + "templates/notary_addon/integrations/person-demographics/fixtures/no-match.yaml" + ), + ), + ( + "notary/project/integrations/person-demographics/fixtures/ambiguous.yaml", + include_str!( + "templates/notary_addon/integrations/person-demographics/fixtures/ambiguous.yaml" + ), + ), + ( + "notary/project/environments/local.yaml", + include_str!("templates/notary_addon/environments/local.yaml"), + ), + ( + "notary/postgres-init.sql", + include_str!("templates/notary_addon/postgres-init.sql"), + ), + ]; + for (relative, contents) in files { + let path = project_dir.join(relative); + let parent = path + .parent() + .ok_or_else(|| anyhow!("generated Notary path has no parent"))?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + write_text(path, contents)?; + } + write_text( + project_dir.join("notary/project/.gitignore"), + ".registry-stack/\n", + )?; + Ok(()) +} + +fn add_notary_local_secrets(project_dir: &Path) -> Result<()> { + let evaluator = Credential::generate("tutorial-evaluator")?; + let workload_jwk = generate_ed25519_jwk(NOTARY_RELAY_WORKLOAD_KID)?; + write_local_workload_jwks(project_dir, &workload_jwk)?; + let env_path = project_dir.join("secrets/local.env"); + let current = fs::read_to_string(&env_path) + .with_context(|| format!("failed to read {}", env_path.display()))?; + let values = vec![ + ("TUTORIAL_EVALUATOR_RAW".to_string(), evaluator.raw), + ( + "TUTORIAL_EVALUATOR_HASH".to_string(), + evaluator.fingerprint, + ), + ( + "REGISTRY_NOTARY_AUDIT_HASH_SECRET".to_string(), + random_token(48)?, + ), + ( + "REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1".to_string(), + random_token(48)?, + ), + ( + NOTARY_RELAY_WORKLOAD_JWK_ENV.to_string(), + workload_jwk, + ), + ( + "REGISTRY_RELAY_CONSULTATION_DATABASE_URL".to_string(), + "postgresql://relay_state_runtime@registry-consultation-db:5432/registry_relay?sslmode=require".to_string(), + ), + ( + "REGISTRY_RELAY_STATE_MIGRATION_URL".to_string(), + "postgresql://postgres@registry-consultation-db:5432/registry_relay?sslmode=require".to_string(), + ), + ( + "REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL".to_string(), + "postgresql://relay_state_maintenance@registry-consultation-db:5432/registry_relay?sslmode=require".to_string(), + ), + ( + "REGISTRY_RELAY_STATE_KEYRING_READER_URL".to_string(), + "postgresql://relay_state_reader@registry-consultation-db:5432/registry_relay?sslmode=require".to_string(), + ), + ]; + write_private_text(&env_path, &upsert_env_values(¤t, &values))?; + Ok(()) +} + +fn write_local_workload_jwks(project_dir: &Path, private_jwk: &str) -> Result<()> { + let mut public_jwk: serde_json::Value = + serde_json::from_str(private_jwk).context("generated workload JWK is invalid")?; + public_jwk + .as_object_mut() + .ok_or_else(|| anyhow!("generated workload JWK must be an object"))? + .remove("d"); + let document = serde_json::to_string_pretty(&serde_json::json!({ "keys": [public_jwk] })) + .context("failed to render local workload JWKS")?; + write_text( + project_dir.join("notary/jwks.json"), + &format!("{document}\n"), + ) +} + +fn write_local_postgres_tls(project_dir: &Path) -> Result<()> { + let CertifiedKey { cert, key_pair } = + generate_simple_self_signed(vec!["registry-consultation-db".to_string()]) + .context("failed to generate local consultation database TLS identity")?; + let certificate_pem = pem_block("CERTIFICATE", cert.der().as_ref()); + let private_key_pem = Zeroizing::new(pem_block("PRIVATE KEY", &key_pair.serialize_der())); + write_text( + project_dir.join(CONSULTATION_POSTGRES_CERT_PATH), + &certificate_pem, + )?; + write_private_text( + &project_dir.join(CONSULTATION_POSTGRES_KEY_PATH), + &private_key_pem, + ) +} + +fn pem_block(label: &str, der: &[u8]) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(der); + let body = encoded + .as_bytes() + .chunks(64) + .map(|line| std::str::from_utf8(line).expect("base64 output is UTF-8")) + .collect::>() + .join("\n"); + format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n") +} + +fn generate_ed25519_jwk(kid: &str) -> Result { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).map_err(|error| anyhow!("random generation failed: {error}"))?; + let signing_key = Ed25519SigningKey::from_bytes(&seed); + let x = signing_key.verifying_key().to_bytes(); + serde_json::to_string(&serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "d": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(seed), + "x": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x), + "alg": "EdDSA", + "kid": kid, + })) + .context("failed to render workload JWK") +} + +fn prepare_notary_runtime(project_dir: &Path) -> Result<()> { + #[cfg(unix)] + let runtime_identity = Some(compose_runtime_identity_values(project_dir)?); + #[cfg(not(unix))] + let runtime_identity = None; + project_authoring::build_registry_project_for_local_tutorial( + &ProjectBuildOptions { + project_directory: project_dir.join(NOTARY_PROJECT_DIR), + environment: "local".to_string(), + against: None, + anchor: None, + }, + runtime_identity, + )?; + refresh_notary_relay_token(project_dir, runtime_identity) +} + +fn refresh_notary_relay_token( + project_dir: &Path, + runtime_identity: Option, +) -> Result<()> { + let project = Project::load(project_dir).ok(); + let secrets_path = project + .as_ref() + .map(|project| project_dir.join(&project.local.secrets_env)) + .unwrap_or_else(|| project_dir.join("secrets/local.env")); + let secrets = LocalEnv::load(&secrets_path)?; + let private_jwk = PrivateJwk::parse(secrets.required(NOTARY_RELAY_WORKLOAD_JWK_ENV)?) + .context("local Notary workload JWK is invalid")?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_secs(); + let header = serde_json::json!({ + "alg": "EdDSA", + "typ": "at+jwt", + "kid": NOTARY_RELAY_WORKLOAD_KID, + }); + let claims = serde_json::json!({ + "iss": "http://127.0.0.1:8081", + "sub": "registry-notary", + "client_id": "registry-notary", + "azp": "registry-notary", + "aud": "registry-relay", + "scope": "registry:consult:registration-verification", + "iat": now, + "nbf": now.saturating_sub(5), + "exp": now.saturating_add(3600), + "jti": random_token(16)?, + }); + let encoded_header = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?); + let encoded_claims = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let signing_input = format!("{encoded_header}.{encoded_claims}"); + let signature = sign_payload(signing_input.as_bytes(), &private_jwk) + .context("failed to sign local Relay workload token")?; + let token = format!( + "{signing_input}.{}\n", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature) + ); + write_private_runtime_text( + &project_dir.join(NOTARY_RELAY_TOKEN_PATH), + &token, + runtime_identity, + ) +} + +fn merge_notary_compose(project_dir: &Path, image_lock: &RegistryctlImageLock) -> Result<()> { + let path = project_dir.join("compose.yaml"); + let contents = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let mut compose: serde_yaml::Value = serde_yaml::from_str(&contents) + .with_context(|| format!("failed to parse {}", path.display()))?; + let fragment = include_str!("templates/notary_addon/compose-fragment.yaml.tmpl") + .replace("{{relay_image}}", image_lock.relay_image()) + .replace("{{notary_image}}", image_lock.notary_image()); + let fragment: serde_yaml::Value = + serde_yaml::from_str(&fragment).context("failed to parse Notary Compose fragment")?; + merge_yaml_mapping(&mut compose, &fragment, "services")?; + merge_yaml_mapping(&mut compose, &fragment, "volumes")?; + merge_yaml_mapping(&mut compose, &fragment, "networks")?; + let rendered = serde_yaml::to_string(&compose).context("failed to render Compose file")?; + write_text(path, &format!("# Generated by registryctl.\n{rendered}")) +} + +fn merge_yaml_mapping( + target: &mut serde_yaml::Value, + source: &serde_yaml::Value, + key: &str, +) -> Result<()> { + let target_root = target + .as_mapping_mut() + .ok_or_else(|| anyhow!("Compose document must be a mapping"))?; + let yaml_key = serde_yaml::Value::String(key.to_owned()); + if !target_root.contains_key(&yaml_key) { + target_root.insert( + yaml_key.clone(), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + let target_mapping = target_root + .get_mut(&yaml_key) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| anyhow!("Compose {key} must be a mapping"))?; + let source_mapping = source[key] + .as_mapping() + .ok_or_else(|| anyhow!("Notary Compose {key} must be a mapping"))?; + for (entry_key, value) in source_mapping { + if target_mapping.contains_key(entry_key) { + bail!("Compose {key} already contains a generated Notary entry"); + } + target_mapping.insert(entry_key.clone(), value.clone()); + } + Ok(()) +} + +#[cfg(unix)] +fn write_private_text(path: &Path, contents: &str) -> Result<()> { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("failed to write {}", path.display()))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + let mut permissions = file.metadata()?.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_private_text(path: &Path, contents: &str) -> Result<()> { + write_text(path.to_path_buf(), contents) +} + +#[cfg(unix)] +fn write_private_runtime_text( + path: &Path, + contents: &str, + runtime_identity: Option, +) -> Result<()> { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let parent = path + .parent() + .ok_or_else(|| anyhow!("private runtime input has no parent"))?; + let name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("private runtime input name is invalid"))?; + let mut staged = None; + for _ in 0..8 { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).context("failed to create private runtime input identity")?; + let staged_path = parent.join(format!( + ".{name}.tmp-{}-{}", + std::process::id(), + hex::encode(random) + )); + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staged_path); + match file { + Ok(file) => { + staged = Some((staged_path, file)); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error).with_context(|| format!("failed to stage {}", path.display())); + } + } + } + let (staged_path, mut file) = + staged.ok_or_else(|| anyhow!("failed to allocate private runtime input staging file"))?; + let staged_result = (|| { + file.write_all(contents.as_bytes()) + .with_context(|| format!("failed to write {}", staged_path.display()))?; + let metadata = file.metadata()?; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + if let Some(identity) = runtime_identity { + if metadata.uid() != identity.uid || metadata.gid() != identity.gid { + rustix::fs::fchown( + &file, + Some(rustix::fs::Uid::from_raw(identity.uid)), + Some(rustix::fs::Gid::from_raw(identity.gid)), + ) + .with_context(|| { + format!( + "failed to assign staged Notary runtime input to {}:{}", + identity.uid, identity.gid + ) + })?; + } + } + file.sync_all() + .with_context(|| format!("failed to sync {}", staged_path.display())) + })(); + drop(file); + if let Err(error) = staged_result { + let _ = fs::remove_file(&staged_path); + return Err(error); + } + if let Err(error) = fs::rename(&staged_path, path) { + let _ = fs::remove_file(&staged_path); + return Err(error).with_context(|| format!("failed to publish {}", path.display())); + } + Ok(()) +} + +#[cfg(not(unix))] +fn write_private_runtime_text( + path: &Path, + contents: &str, + _runtime_identity: Option, +) -> Result<()> { + write_private_text(path, contents) +} + fn init_benefits_project(dir: &Path, image_lock: &RegistryctlImageLock) -> Result { if dir.exists() { let mut entries = @@ -1840,6 +2420,17 @@ fn create_relay_state_dirs(dir: &Path) -> Result<()> { ) } +fn create_notary_state_dirs(dir: &Path) -> Result<()> { + create_state_dirs( + dir, + &[ + "state", + CONSULTATION_RELAY_STATE_DIR, + CONSULTATION_RELAY_CACHE_PATH, + ], + ) +} + fn create_state_dirs(dir: &Path, paths: &[&str]) -> Result<()> { #[cfg(unix)] let identity = compose_runtime_identity_values(dir)?; @@ -1855,11 +2446,15 @@ fn create_state_dirs(dir: &Path, paths: &[&str]) -> Result<()> { #[cfg(unix)] #[derive(Clone, Copy)] -struct RuntimeIdentity { +pub(crate) struct RuntimeIdentity { uid: u32, gid: u32, } +#[cfg(not(unix))] +#[derive(Clone, Copy)] +pub(crate) struct RuntimeIdentity; + #[cfg(unix)] fn compose_runtime_identity_values(dir: &Path) -> Result { use std::os::unix::fs::MetadataExt; @@ -2168,7 +2763,10 @@ fn bruno_relay_files(relay_base_url: &str, _secrets: &LocalEnv) -> Vec Vec Vec Vec Result { fn bruno_env(project: &Project, secrets: &LocalEnv, example: bool) -> Result { let mut values = Vec::new(); values.push(("purpose", TUTORIAL_PURPOSE.to_string())); + values.push(("identity_purpose", TUTORIAL_IDENTITY_PURPOSE.to_string())); if project.relay.is_some() { values.push(("relay_base_url", project.relay_base_url()?.to_string())); values.push(( @@ -2377,6 +2999,10 @@ fn bruno_env(project: &Project, secrets: &LocalEnv, example: bool) -> Result String { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Project { // Not read anywhere today beyond load-time validation (see `deserialize_schema_version`); @@ -2412,6 +3038,8 @@ struct Project { project: ProjectMeta, #[serde(default)] relay: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + notary: Option, runtime: ProjectRuntime, local: ProjectLocal, } @@ -2419,7 +3047,7 @@ struct Project { /// The `project:` metadata block `registryctl_manifest` writes into every generated /// `registryctl.yaml` (see `ProjectSection`); not consumed elsewhere today, but modeled here /// so `deny_unknown_fields` doesn't reject registryctl's own generated files. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ProjectMeta { #[allow(dead_code)] @@ -2440,7 +3068,7 @@ impl Project { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ProjectRelay { config: PathBuf, @@ -2450,6 +3078,16 @@ struct ProjectRelay { data: Vec, } +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProjectNotary { + project: PathBuf, + config: PathBuf, + consultation_relay_config: PathBuf, + claim_file: PathBuf, + workload_token: PathBuf, +} + /// Validates `schema_version` against `PROJECT_SCHEMA_VERSION`, the only version /// `registryctl_manifest` generates today, so a future/incompatible schema file fails project /// load instead of half-parsing. @@ -2468,7 +3106,7 @@ where Ok(schema_version) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ProjectRuntime { // Not read anywhere today (the compose engine/file are hardcoded elsewhere); modeled so @@ -2481,9 +3119,13 @@ struct ProjectRuntime { relay_image: Option, #[serde(default)] relay_base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + notary_image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + notary_base_url: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ProjectLocal { secrets_env: PathBuf, @@ -2500,6 +3142,16 @@ impl Project { .as_deref() .ok_or_else(|| anyhow!("project runtime is missing relay_base_url")) } + + fn notary_base_url(&self) -> Result<&str> { + if self.notary.is_none() { + bail!("project does not have a Notary section"); + } + self.runtime + .notary_base_url + .as_deref() + .ok_or_else(|| anyhow!("project runtime is missing notary_base_url")) + } } #[derive(Debug)] @@ -2635,11 +3287,17 @@ fn compose_platform_override( } fn project_uses_amd64_only_release_image(project: &Project) -> bool { - project + let relay_is_amd64_only = project .runtime .relay_image .as_deref() - .is_some_and(|image| image.starts_with(&format!("{RELAY_IMAGE_REPOSITORY}@sha256:"))) + .is_some_and(|image| image.starts_with(&format!("{RELAY_IMAGE_REPOSITORY}@sha256:"))); + let notary_is_amd64_only = project + .runtime + .notary_image + .as_deref() + .is_some_and(|image| image.starts_with(&format!("{NOTARY_IMAGE_REPOSITORY}@sha256:"))); + relay_is_amd64_only || notary_is_amd64_only } fn is_linux_arm64_platform(platform: &str) -> bool { @@ -2657,36 +3315,45 @@ fn compose_command_args(compose_file: &str, args: &[&str]) -> Vec { } fn validate_project_fingerprints(project_dir: &Path, project: &Project) -> Result<()> { - let Some(relay) = &project.relay else { - return Ok(()); - }; - let config_path = project_dir.join(&relay.config); - let config = fs::read_to_string(&config_path) + let secrets = LocalEnv::load(&project_dir.join(&project.local.secrets_env))?; + if let Some(relay) = &project.relay { + validate_config_api_key_fingerprints(&project_dir.join(&relay.config), "Relay", &secrets)?; + } + if let Some(notary) = &project.notary { + validate_config_api_key_fingerprints( + &project_dir.join(¬ary.config), + "Notary", + &secrets, + )?; + } + Ok(()) +} + +fn validate_config_api_key_fingerprints( + config_path: &Path, + product: &str, + secrets: &LocalEnv, +) -> Result<()> { + let config = fs::read_to_string(config_path) .with_context(|| format!("failed to read {}", config_path.display()))?; let config: serde_yaml::Value = serde_yaml::from_str(&config) .with_context(|| format!("failed to parse {}", config_path.display()))?; - let secrets = LocalEnv::load(&project_dir.join(&project.local.secrets_env))?; let api_keys = config["auth"]["api_keys"] .as_sequence() - .ok_or_else(|| anyhow!("relay config auth.api_keys must be a list"))?; - + .ok_or_else(|| anyhow!("{product} config auth.api_keys must be a list"))?; for api_key in api_keys { let id = api_key["id"] .as_str() - .ok_or_else(|| anyhow!("relay config api key entry is missing id"))?; - let hash_env = api_key["fingerprint"]["name"] - .as_str() - .ok_or_else(|| anyhow!("relay config api key {id} is missing fingerprint env name"))?; - + .ok_or_else(|| anyhow!("{product} config api key entry is missing id"))?; + let hash_env = api_key["fingerprint"]["name"].as_str().ok_or_else(|| { + anyhow!("{product} config api key {id} is missing fingerprint env name") + })?; let fingerprint = secrets.required(hash_env)?; - let raw_env = raw_env_name_for(id)?; - let raw_key = secrets.required(raw_env)?; - let expected_fingerprint = fingerprint_api_key(raw_key); - if fingerprint != expected_fingerprint { + let raw_key = secrets.required(raw_env_name_for(id)?)?; + if fingerprint != fingerprint_api_key(raw_key) { bail!("local raw key and fingerprint do not match for {id}"); } } - Ok(()) } @@ -2695,6 +3362,8 @@ fn raw_env_name_for(id: &str) -> Result<&'static str> { "metadata_reader" => Ok("METADATA_READER_RAW"), "row_reader" => Ok("ROW_READER_RAW"), "aggregate_reader" => Ok("AGGREGATE_READER_RAW"), + "identity_reader" => Ok("IDENTITY_READER_RAW"), + "tutorial-evaluator" => Ok("TUTORIAL_EVALUATOR_RAW"), _ => bail!("unknown generated api key id {id}"), } } @@ -2726,6 +3395,7 @@ struct LocalCredentials { metadata_reader: Credential, row_reader: Credential, aggregate_reader: Credential, + identity_reader: Credential, audit_hash_secret: String, } @@ -2735,6 +3405,7 @@ impl LocalCredentials { metadata_reader: Credential::generate("metadata_reader")?, row_reader: Credential::generate("row_reader")?, aggregate_reader: Credential::generate("aggregate_reader")?, + identity_reader: Credential::generate("identity_reader")?, audit_hash_secret: random_token(48)?, }) } @@ -2748,6 +3419,8 @@ ROW_READER_RAW={row_raw} ROW_READER_HASH={row_hash} AGGREGATE_READER_RAW={aggregate_raw} AGGREGATE_READER_HASH={aggregate_hash} +IDENTITY_READER_RAW={identity_raw} +IDENTITY_READER_HASH={identity_hash} REGISTRY_RELAY_AUDIT_HASH_SECRET={audit_hash_secret} ", metadata_raw = self.metadata_reader.raw, @@ -2756,6 +3429,8 @@ REGISTRY_RELAY_AUDIT_HASH_SECRET={audit_hash_secret} row_hash = self.row_reader.fingerprint, aggregate_raw = self.aggregate_reader.raw, aggregate_hash = self.aggregate_reader.fingerprint, + identity_raw = self.identity_reader.raw, + identity_hash = self.identity_reader.fingerprint, audit_hash_secret = self.audit_hash_secret, ) } @@ -2876,6 +3551,7 @@ fn relay_config(credentials: &LocalCredentials) -> String { .replace("{{metadata_id}}", credentials.metadata_reader.id) .replace("{{row_id}}", credentials.row_reader.id) .replace("{{aggregate_id}}", credentials.aggregate_reader.id) + .replace("{{identity_id}}", credentials.identity_reader.id) } #[derive(Debug, Deserialize, Serialize)] @@ -2959,6 +3635,44 @@ fn run_smoke_checks(base_url: &str, secrets: &LocalEnv) -> SmokeReport { ), ], ); + record_smoke_check( + &mut checks, + base_url, + "row reader cannot read restricted identity fields", + "/v1/datasets/benefits_casework/entities/person_identity/records?id=per-2001", + 403, + &[ + bearer_header(secrets.value("ROW_READER_RAW")), + ( + "Data-Purpose".to_string(), + TUTORIAL_IDENTITY_PURPOSE.to_string(), + ), + ], + ); + record_smoke_check( + &mut checks, + base_url, + "identity reader with unpermitted Data-Purpose returns 403", + "/v1/datasets/benefits_casework/entities/person_identity/records?id=per-2001", + 403, + &[ + bearer_header(secrets.value("IDENTITY_READER_RAW")), + ("Data-Purpose".to_string(), TUTORIAL_PURPOSE.to_string()), + ], + ); + record_row_data_smoke_check( + &mut checks, + base_url, + "identity reader can read one restricted identity record", + "/v1/datasets/benefits_casework/entities/person_identity/records?id=per-2001", + &[ + bearer_header(secrets.value("IDENTITY_READER_RAW")), + ( + "Data-Purpose".to_string(), + TUTORIAL_IDENTITY_PURPOSE.to_string(), + ), + ], + ); record_smoke_check( &mut checks, base_url, @@ -3710,7 +4424,7 @@ mod tests { assert!(config_text.contains("# The raw bearer keys live in secrets/local.env.")); assert!(config_text.contains("# Tables describe the source workbook.")); assert!(config_text.contains("# Aggregates expose predeclared grouped statistics.")); - assert!(config_text.contains("# Entities are the public API surface.")); + assert!(config_text.contains("# Entities are API projections.")); let config: Value = serde_yaml::from_str(&config_text).unwrap(); let manifest: Value = serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) @@ -3744,15 +4458,314 @@ mod tests { 2 ); + let entities = config["datasets"][0]["entities"].as_sequence().unwrap(); + let entity = |name: &str| { + entities + .iter() + .find(|entity| entity["name"] == name) + .unwrap() + }; + let person = entity("person"); + let person_fields = person["fields"] + .as_sequence() + .unwrap() + .iter() + .map(|field| field["name"].as_str().unwrap()) + .collect::>(); + assert_eq!( + person_fields, + [ + "id", + "household_id", + "date_of_birth", + "relationship_to_head", + "registration_status" + ] + ); + assert_eq!( + person["api"]["governed_policy"]["permitted_purposes"][0], + TUTORIAL_PURPOSE + ); + + let person_identity = entity("person_identity"); + assert_eq!( + person_identity["access"]["read_scope"], + "benefits_casework:identity_release" + ); + assert_eq!(person_identity["api"]["max_limit"], 1); + assert_eq!( + person_identity["api"]["governed_policy"]["permitted_purposes"][0], + TUTORIAL_IDENTITY_PURPOSE + ); + assert_eq!( + entity("household_contact")["access"]["read_scope"], + "benefits_casework:identity_release" + ); + let readme = fs::read_to_string(project.join("README.md")).unwrap(); assert!(readme.contains("registryctl doctor --profile local --format json")); assert!(readme.contains("redacts local secret values")); assert!(readme.contains("Back up that file before upgrades")); + assert!(readme.contains("Notary evaluation state is in memory")); + assert!(readme.contains("may contain cached source rows")); + assert!(!readme.contains("preserve its configured PostgreSQL database")); assert!(readme.contains("https://docs.registrystack.org/operate/backup-and-restore/")); assert!(readme .contains("https://docs.registrystack.org/operate/single-node-compose-behind-proxy/")); } + #[test] + fn add_notary_builds_an_editable_live_tutorial_addon() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("my-first-api"); + let image_lock = test_image_lock(); + init_spreadsheet_api(&project, Sample::Benefits, &image_lock).unwrap(); + + let report = add_notary_to_project(&project, &image_lock).unwrap(); + + assert_eq!(report.status, "added"); + for path in [ + NOTARY_CLAIM_FILE, + NOTARY_CONFIG_PATH, + CONSULTATION_RELAY_CONFIG_PATH, + NOTARY_RELAY_TOKEN_PATH, + CONSULTATION_POSTGRES_CERT_PATH, + CONSULTATION_POSTGRES_KEY_PATH, + "notary/postgres-init.sql", + "notary/jwks.json", + ] { + assert!(project.join(path).is_file(), "{path} should exist"); + } + let manifest: Value = + serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) + .unwrap(); + assert_eq!(manifest["runtime"]["notary_base_url"], NOTARY_BASE_URL); + assert_eq!(manifest["notary"]["claim_file"], NOTARY_CLAIM_FILE); + assert!(project.join(CONSULTATION_RELAY_CACHE_PATH).is_dir()); + assert_private_state_dirs( + &project, + &[ + CONSULTATION_RELAY_STATE_DIR, + CONSULTATION_RELAY_CACHE_PATH, + NOTARY_CONFIG_DIR, + CONSULTATION_RELAY_CONFIG_DIR, + ], + ); + assert_private_file(&project, NOTARY_RELAY_TOKEN_PATH); + assert_private_file(&project, CONSULTATION_POSTGRES_KEY_PATH); + assert_private_file(&project, NOTARY_CONFIG_PATH); + assert_private_file(&project, CONSULTATION_RELAY_CONFIG_PATH); + assert_notary_runtime_input_owners_match_project(&project); + let compose_text = fs::read_to_string(project.join("compose.yaml")).unwrap(); + let compose: Value = serde_yaml::from_str(&compose_text).unwrap(); + let services = &compose["services"]; + let runtime_user = + "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}"; + for service in [ + "registry-relay-consultation-bootstrap", + "registry-relay-consultation", + "registry-notary", + ] { + assert_eq!(services[service]["user"], runtime_user); + } + for service in ["registry-consultation-db", "registry-notary-jwks"] { + assert!( + services[service].get("user").is_none(), + "{service} must keep its image-provided runtime identity" + ); + } + let consultation_mounts = services["registry-relay-consultation"]["volumes"] + .as_sequence() + .unwrap(); + assert!(consultation_mounts.iter().any(|mount| { + mount == "./state/relay-consultation/cache:/var/lib/registry-relay/cache" + })); + assert!(!compose["volumes"] + .as_mapping() + .unwrap() + .contains_key("registry-consultation-cache")); + assert_eq!( + services["registry-notary"]["network_mode"], + "service:registry-notary-jwks" + ); + assert!(consultation_mounts.iter().any(|mount| { + mount + == "./notary/project/.registry-stack/build/local/private/relay/config:/etc/registry-relay:ro" + })); + assert!(services["registry-notary"]["volumes"] + .as_sequence() + .unwrap() + .iter() + .any(|mount| { + mount + == "./notary/project/.registry-stack/build/local/private/notary/config:/etc/registry-notary:ro" + })); + assert!(!compose_text.contains("config/notary.yaml:/etc/registry-notary/notary.yaml")); + let postgres_init = fs::read_to_string(project.join("notary/postgres-init.sql")).unwrap(); + assert!(!postgres_init.contains("GRANT relay_state_owner")); + assert!( + postgres_init.contains("GRANT CREATE ON DATABASE registry_relay TO relay_state_owner") + ); + assert_eq!(services["registry-notary-jwks"]["ports"][0], "4255:8081"); + assert!(services["registry-consultation-db"]["entrypoint"][2] + .as_str() + .unwrap() + .contains("ssl_cert_file=/var/lib/postgresql/tls/server.crt")); + assert_eq!( + compose["networks"]["registry-notary-internal"]["internal"], + true + ); + assert!(compose["networks"].get("registry-notary-public").is_some()); + assert_eq!(services["registry-notary"]["image"], TEST_NOTARY_IMAGE); + let claim_source = fs::read_to_string(project.join(NOTARY_CLAIM_FILE)).unwrap(); + assert!(claim_source.contains("request.target.attributes.given_name")); + assert!(claim_source.contains("request.target.attributes.date_of_birth")); + assert!(claim_source.contains("person-registration-accepted")); + assert!(claim_source.contains("enrollment.registration_status == \"active\"")); + assert!(!claim_source.contains("age_on")); + let integration = fs::read_to_string( + project.join("notary/project/integrations/person-demographics/integration.yaml"), + ) + .unwrap(); + assert!(integration.contains("outputs: [registration_status]")); + assert!(!integration.contains("outputs: [date_of_birth]")); + assert!(!integration.contains("outputs: [national_id]")); + let environment = + fs::read_to_string(project.join("notary/project/environments/local.yaml")).unwrap(); + assert!(environment.contains("worker_memory_bytes: 1073741824")); + let secrets = LocalEnv::load(&project.join("secrets/local.env")).unwrap(); + assert!(!secrets.value("TUTORIAL_EVALUATOR_RAW").is_empty()); + assert_eq!( + secrets.required("TUTORIAL_EVALUATOR_HASH").unwrap(), + fingerprint_api_key(secrets.required("TUTORIAL_EVALUATOR_RAW").unwrap()) + ); + let token = fs::read_to_string(project.join(NOTARY_RELAY_TOKEN_PATH)).unwrap(); + assert_eq!(token.trim().split('.').count(), 3); + let claims = token.trim().split('.').nth(1).unwrap(); + let claims: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(claims) + .unwrap(), + ) + .unwrap(); + assert_eq!( + claims["scope"], + "registry:consult:registration-verification" + ); + let claim_path = project.join(NOTARY_CLAIM_FILE); + let claim = fs::read_to_string(&claim_path).unwrap().replace( + "enrollment.registration_status == \"active\"", + "(enrollment.registration_status == \"active\" || enrollment.registration_status == \"pending\")", + ); + fs::write(&claim_path, claim).unwrap(); + let fixture_path = + project.join("notary/project/integrations/person-demographics/fixtures/pending.yaml"); + let fixture = fs::read_to_string(&fixture_path).unwrap().replace( + "claims: { person-registration-accepted: false }", + "claims: { person-registration-accepted: true }", + ); + fs::write(&fixture_path, fixture).unwrap(); + prepare_notary_runtime(&project).unwrap(); + assert_notary_runtime_input_owners_match_project(&project); + let notary_config_text = fs::read_to_string(project.join(NOTARY_CONFIG_PATH)).unwrap(); + assert!(notary_config_text.contains("person-registration-accepted")); + assert!(notary_config_text.contains("pending")); + let notary_config: Value = serde_yaml::from_str(¬ary_config_text).unwrap(); + assert_eq!(notary_config["state"]["storage"], "in_memory"); + assert!(notary_config["evidence"]["credential_profiles"] + .as_mapping() + .is_some_and(serde_yaml::Mapping::is_empty)); + let claims = notary_config["evidence"]["claims"].as_sequence().unwrap(); + assert!(!claims.is_empty()); + assert!(claims + .iter() + .all(|claim| claim["evidence_mode"]["type"] == "registry_backed")); + assert!(claims.iter().all(|claim| claim["credential_profiles"] + .as_sequence() + .is_some_and(Vec::is_empty))); + let signing_keys = notary_config["evidence"]["signing_keys"] + .as_mapping() + .unwrap(); + assert_eq!(signing_keys.len(), 1); + assert!(signing_keys.contains_key("relay-workload")); + + let error = add_notary_to_project(&project, &image_lock).unwrap_err(); + assert!(format!("{error:#}").contains("already has a Notary")); + } + + #[test] + fn add_notary_rolls_back_generated_project_files_on_failure() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("my-first-api"); + let image_lock = test_image_lock(); + init_spreadsheet_api(&project, Sample::Benefits, &image_lock).unwrap(); + let compose_path = project.join("compose.yaml"); + let secrets_path = project.join("secrets/local.env"); + let manifest_path = project.join("registryctl.yaml"); + let conflicting_compose = fs::read_to_string(&compose_path) + .unwrap() + .replace("services:\n", "services:\n registry-notary: {}\n"); + fs::write(&compose_path, &conflicting_compose).unwrap(); + let original_secrets = fs::read_to_string(&secrets_path).unwrap(); + let original_manifest = fs::read_to_string(&manifest_path).unwrap(); + + let error = add_notary_to_project(&project, &image_lock).unwrap_err(); + + assert!(format!("{error:#}").contains("already contains a generated Notary entry")); + assert!(!project.join("notary").exists()); + for path in [ + NOTARY_RELAY_TOKEN_PATH, + CONSULTATION_POSTGRES_CERT_PATH, + CONSULTATION_POSTGRES_KEY_PATH, + ] { + assert!(!project.join(path).exists()); + } + assert!(!project.join(CONSULTATION_RELAY_STATE_DIR).exists()); + assert_eq!( + fs::read_to_string(compose_path).unwrap(), + conflicting_compose + ); + assert_eq!(fs::read_to_string(secrets_path).unwrap(), original_secrets); + assert_eq!( + fs::read_to_string(manifest_path).unwrap(), + original_manifest + ); + } + + #[test] + fn add_notary_refuses_a_preexisting_sidecar_without_modifying_it() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("my-first-api"); + let image_lock = test_image_lock(); + init_spreadsheet_api(&project, Sample::Benefits, &image_lock).unwrap(); + let token_path = project.join(NOTARY_RELAY_TOKEN_PATH); + fs::write(&token_path, "operator-owned\n").unwrap(); + + let error = add_notary_to_project(&project, &image_lock).unwrap_err(); + + assert!(format!("{error:#}").contains("destination already exists")); + assert_eq!(fs::read_to_string(token_path).unwrap(), "operator-owned\n"); + assert!(!project.join("notary").exists()); + } + + #[test] + fn add_notary_refuses_preexisting_consultation_state_without_modifying_it() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("my-first-api"); + let image_lock = test_image_lock(); + init_spreadsheet_api(&project, Sample::Benefits, &image_lock).unwrap(); + let state_dir = project.join(CONSULTATION_RELAY_STATE_DIR); + fs::create_dir_all(&state_dir).unwrap(); + let marker = state_dir.join("operator-owned"); + fs::write(&marker, "keep\n").unwrap(); + + let error = add_notary_to_project(&project, &image_lock).unwrap_err(); + + assert!(format!("{error:#}").contains("destination already exists")); + assert_eq!(fs::read_to_string(marker).unwrap(), "keep\n"); + assert!(!project.join("notary").exists()); + } + #[test] fn bruno_files_for_relay_project_are_generated_and_secret_scoped() { let temp = TempDir::new().unwrap(); @@ -3776,20 +4789,29 @@ mod tests { project.join("bruno/registry-api/Relay/Query applications aggregate.bru"), ) .unwrap(); + let identity_request = fs::read_to_string( + project.join("bruno/registry-api/Relay/Read restricted identity.bru"), + ) + .unwrap(); let openapi_request = fs::read_to_string(project.join("bruno/registry-api/Relay/OpenAPI.bru")).unwrap(); assert!(local_bru.contains(&env_value(&env, "METADATA_READER_RAW"))); assert!(local_bru.contains(&env_value(&env, "ROW_READER_RAW"))); assert!(local_bru.contains(&env_value(&env, "AGGREGATE_READER_RAW"))); + assert!(local_bru.contains(&env_value(&env, "IDENTITY_READER_RAW"))); assert!(example_bru.contains("replace-with-metadata_reader_raw")); assert!(example_bru.contains("replace-with-aggregate_reader_raw")); + assert!(example_bru.contains("replace-with-identity_reader_raw")); assert!(!request.contains(&env_value(&env, "METADATA_READER_RAW"))); assert!(!request.contains(&env_value(&env, "ROW_READER_RAW"))); assert!(!aggregate_request.contains(&env_value(&env, "AGGREGATE_READER_RAW"))); assert!(request.contains("{{relay_row_key}}")); assert!(aggregate_request.contains("{{relay_aggregate_key}}")); + assert!(aggregate_request.contains("Data-Purpose")); assert!(application_aggregate_request.contains("Data-Purpose")); + assert!(identity_request.contains("{{relay_identity_key}}")); + assert!(identity_request.contains("{{identity_purpose}}")); assert!(!openapi_request.contains("Authorization")); assert!(!openapi_request.contains("{{relay_metadata_key}}")); } @@ -4057,6 +5079,7 @@ mod tests { ("metadata_reader", "METADATA_READER_HASH"), ("row_reader", "ROW_READER_HASH"), ("aggregate_reader", "AGGREGATE_READER_HASH"), + ("identity_reader", "IDENTITY_READER_HASH"), ] { let fingerprint = env_value(&env, env_name); assert!( @@ -4086,6 +5109,7 @@ mod tests { ("METADATA_READER_HASH", "metadata_reader"), ("ROW_READER_HASH", "row_reader"), ("AGGREGATE_READER_HASH", "aggregate_reader"), + ("IDENTITY_READER_HASH", "identity_reader"), ] { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); @@ -4114,6 +5138,7 @@ mod tests { "METADATA_READER_HASH", "ROW_READER_HASH", "AGGREGATE_READER_HASH", + "IDENTITY_READER_HASH", ] { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); @@ -4180,6 +5205,14 @@ mod tests { assert!(lossy.contains("Applications")); assert!(lossy.contains("hh-1001")); assert!(lossy.contains("app-3001")); + assert!(lossy.contains("date_of_birth")); + assert!(lossy.contains("given_name")); + assert!(lossy.contains("national_id")); + assert!(lossy.contains("address_line")); + assert!(!lossy.contains("age_band")); + assert!(!lossy.contains("eligibility_status")); + assert!(!lossy.contains("is_primary_applicant")); + assert!(!lossy.contains("consent_reference")); } #[test] @@ -4242,6 +5275,10 @@ mod tests { "metadata-secret".to_string(), ), ("ROW_READER_RAW".to_string(), "row-secret".to_string()), + ( + "IDENTITY_READER_RAW".to_string(), + "identity-secret".to_string(), + ), ]), }; let report = run_smoke_checks("http://127.0.0.1:1", &secrets); @@ -4250,8 +5287,9 @@ mod tests { assert!(!json.contains("metadata-secret")); assert!(!json.contains("row-secret")); + assert!(!json.contains("identity-secret")); assert!(!report.passed); - assert_eq!(parsed.checks.len(), 8); + assert_eq!(parsed.checks.len(), 11); } #[test] @@ -4629,6 +5667,21 @@ mod tests { #[cfg(not(unix))] fn assert_private_state_dir(_project: &Path, _path: &str) {} + #[cfg(unix)] + fn assert_private_file(project: &Path, path: &str) { + use std::os::unix::fs::PermissionsExt; + + let actual_mode = fs::metadata(project.join(path)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(actual_mode, 0o600, "{path} should be private"); + } + + #[cfg(not(unix))] + fn assert_private_file(_project: &Path, _path: &str) {} + #[cfg(unix)] #[test] fn runtime_identity_uses_default_nonroot_for_root_owner() { @@ -4664,6 +5717,46 @@ mod tests { } } + #[cfg(unix)] + fn assert_notary_runtime_input_owners_match_project(project: &Path) { + use std::os::unix::fs::MetadataExt; + + let project_metadata = fs::metadata(project).unwrap(); + let identity = runtime_identity_for_owner(project_metadata.uid(), project_metadata.gid()); + for relative in [NOTARY_CONFIG_DIR, CONSULTATION_RELAY_CONFIG_DIR] { + assert_runtime_input_tree_owner(&project.join(relative), identity); + } + let token = project.join(NOTARY_RELAY_TOKEN_PATH); + assert_runtime_input_owner(&token, identity); + } + + #[cfg(not(unix))] + fn assert_notary_runtime_input_owners_match_project(_project: &Path) {} + + #[cfg(unix)] + fn assert_runtime_input_tree_owner(path: &Path, expected: RuntimeIdentity) { + let metadata = assert_runtime_input_owner(path, expected); + if metadata.is_dir() { + for entry in fs::read_dir(path).unwrap() { + assert_runtime_input_tree_owner(&entry.unwrap().path(), expected); + } + } + } + + #[cfg(unix)] + fn assert_runtime_input_owner(path: &Path, expected: RuntimeIdentity) -> fs::Metadata { + use std::os::unix::fs::MetadataExt; + + let metadata = fs::symlink_metadata(path).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (expected.uid, expected.gid), + "{} should be owned by the selected runtime identity", + path.display() + ); + metadata + } + fn fake_product_report(product: &str, status: &str, diagnostics: Vec) -> String { let error_count = diagnostics .iter() diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index bbc317783..47d2ab58c 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -61,6 +61,15 @@ fn main() -> Result<()> { OutputFormat::Json => print_json(&report)?, } } + Commands::Add { command } => match command { + AddCommand::Notary => { + let image_lock = registryctl::load_registryctl_image_lock()?; + print_json(®istryctl::add_notary_to_project( + &std::env::current_dir()?, + &image_lock, + )?)?; + } + }, Commands::Test { project_dir, environment, @@ -819,6 +828,11 @@ enum Commands { #[command(subcommand)] command: Option>, }, + /// Add another local Registry Stack product to the current project. + Add { + #[command(subcommand)] + command: AddCommand, + }, /// Run every project integration fixture offline. Test { /// Project workspace root. @@ -924,6 +938,12 @@ enum Commands { }, } +#[derive(Debug, Subcommand)] +enum AddCommand { + /// Add a local Notary and private consultation Relay over the benefits workbook. + Notary, +} + impl Commands { fn should_check_for_updates(&self) -> bool { !matches!( @@ -1433,6 +1453,18 @@ deployment: assert!(matches!(cli.command, Commands::Restart)); } + #[test] + fn add_notary_cli_parses() { + let cli = Cli::try_parse_from(["registryctl", "add", "notary"]).unwrap(); + + assert!(matches!( + cli.command, + Commands::Add { + command: AddCommand::Notary + } + )); + } + #[test] fn legacy_notary_authoring_commands_are_removed() { assert!(Cli::try_parse_from(["registryctl", "init", "notary", "project"]).is_err()); diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 26480dd32..d415f9c3c 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -753,6 +753,28 @@ pub fn check_registry_project(options: &ProjectCheckOptions) -> Result Result { + build_registry_project_inner(options, false, None) +} + +/// Build the closed local tutorial runtime in one atomic publication. +/// +/// These overrides are fixed in reviewed `registryctl` code, are not authored +/// extension input, and are revalidated with the production product models +/// before publication. Applying them before `write_compiled_project` prevents +/// containers from observing the compiler's PostgreSQL defaults between the +/// generated build and the tutorial's in-memory runtime configuration. +pub(crate) fn build_registry_project_for_local_tutorial( + options: &ProjectBuildOptions, + runtime_identity: Option, +) -> Result { + build_registry_project_inner(options, true, runtime_identity) +} + +fn build_registry_project_inner( + options: &ProjectBuildOptions, + local_tutorial_runtime: bool, + runtime_identity: Option, +) -> Result { validate_baseline_pair(options.against.as_deref(), options.anchor.as_deref())?; let loaded = load_registry_project( &options.project_directory, @@ -764,7 +786,10 @@ pub fn build_registry_project(options: &ProjectBuildOptions) -> Result Result Result Result<()> { + let notary = compiled + .notary_private + .get_mut(Path::new("config/notary.yaml")) + .ok_or_else(|| anyhow!("generated local Notary config is absent"))?; + let mut notary_config: serde_yaml::Value = serde_yaml::from_slice(notary) + .context("generated local Notary config did not parse")?; + notary_config["server"]["bind"] = + serde_yaml::Value::String("0.0.0.0:8081".to_string()); + notary_config["state"] = serde_yaml::from_str("storage: in_memory\n")?; + notary_config["evidence"]["signing_keys"] = serde_yaml::from_str(&format!( + "relay-workload:\n provider: local_jwk_env\n private_jwk_env: {}\n alg: EdDSA\n kid: {}\n status: active\n", + super::NOTARY_RELAY_WORKLOAD_JWK_ENV, + super::NOTARY_RELAY_WORKLOAD_KID, + ))?; + *notary = serde_yaml::to_string(¬ary_config) + .context("failed to render local Notary config")? + .into_bytes() + .into_boxed_slice(); + + let relay = compiled + .relay_private + .get_mut(Path::new("config/relay.yaml")) + .ok_or_else(|| anyhow!("generated local consultation Relay config is absent"))?; + let mut relay_config: serde_yaml::Value = serde_yaml::from_slice(relay) + .context("generated local consultation Relay config did not parse")?; + relay_config["server"]["bind"] = + serde_yaml::Value::String("0.0.0.0:8082".to_string()); + relay_config["auth"]["oidc"]["allow_dev_insecure_fetch_urls"] = + serde_yaml::Value::Bool(true); + relay_config["consultation"]["state_plane"]["root_certificate_path"] = + serde_yaml::Value::String("/run/registry-tls/state-plane-ca.pem".to_string()); + *relay = serde_yaml::to_string(&relay_config) + .context("failed to render local consultation Relay config")? + .into_bytes() + .into_boxed_slice(); + Ok(()) +} + fn require_passing_fixtures(fixtures: &[FixtureReport]) -> Result<()> { let failing = fixtures .iter() diff --git a/crates/registryctl/src/project_authoring/output.rs b/crates/registryctl/src/project_authoring/output.rs index 13d58c660..f751bd450 100644 --- a/crates/registryctl/src/project_authoring/output.rs +++ b/crates/registryctl/src/project_authoring/output.rs @@ -265,7 +265,12 @@ fn generated_evidence( .collect() } -fn write_compiled_project(root: &Path, output: &Path, compiled: &CompiledProject) -> Result<()> { +fn write_compiled_project( + root: &Path, + output: &Path, + compiled: &CompiledProject, + runtime_identity: Option, +) -> Result<()> { let expected_parent = root.join(BUILD_ROOT); let parent = output .parent() @@ -313,6 +318,15 @@ fn write_compiled_project(root: &Path, output: &Path, compiled: &CompiledProject &approval_state_bytes, )?; } + if let Some(identity) = runtime_identity { + // The temporary build root is freshly created owner-only state and is + // not published until the rename below. Privileged ownership changes + // are confined to the two config trees mounted into containers, so a + // failure leaves the prior published build untouched. + for relative in ["private/relay/config", "private/notary/config"] { + assign_unpublished_runtime_input_owner(&temporary.join(relative), identity)?; + } + } let backup = expected_parent.join(format!(".{name}.previous-{}", std::process::id())); if backup.exists() { @@ -337,6 +351,52 @@ fn write_compiled_project(root: &Path, output: &Path, compiled: &CompiledProject Ok(()) } +#[cfg(unix)] +fn assign_unpublished_runtime_input_owner( + path: &Path, + identity: crate::RuntimeIdentity, +) -> Result<()> { + use std::os::unix::fs::{lchown, MetadataExt}; + + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect unpublished runtime input {}", path.display()))?; + if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) { + bail!( + "unpublished runtime input contains an unsupported file type: {}", + path.display() + ); + } + if metadata.is_dir() { + for entry in fs::read_dir(path) + .with_context(|| format!("failed to read unpublished runtime input {}", path.display()))? + { + let child = entry + .with_context(|| { + format!("failed to read an entry under unpublished runtime input {}", path.display()) + })? + .path(); + assign_unpublished_runtime_input_owner(&child, identity)?; + } + } + if metadata.uid() != identity.uid || metadata.gid() != identity.gid { + lchown(path, Some(identity.uid), Some(identity.gid)).with_context(|| { + format!( + "failed to assign unpublished runtime input {} to {}:{}; the prior generated build remains active", + path.display(), identity.uid, identity.gid + ) + })?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn assign_unpublished_runtime_input_owner( + _path: &Path, + _identity: crate::RuntimeIdentity, +) -> Result<()> { + Ok(()) +} + fn write_file_map(root: &Path, files: &BTreeMap>) -> Result<()> { for (relative, bytes) in files { validate_relative_authored_path(relative)?; diff --git a/crates/registryctl/src/sample.rs b/crates/registryctl/src/sample.rs index da0291a94..26193a865 100644 --- a/crates/registryctl/src/sample.rs +++ b/crates/registryctl/src/sample.rs @@ -105,11 +105,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("given_name"), Cell::Text("family_name"), Cell::Text("date_of_birth"), - Cell::Text("age_band"), Cell::Text("relationship_to_head"), Cell::Text("registration_status"), - Cell::Text("eligibility_status"), - Cell::Text("is_primary_applicant"), Cell::Text("national_id"), ], &[ @@ -118,11 +115,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Fae"), Cell::Text("Elm"), Cell::Text("1989-05-14"), - Cell::Text("35-49"), Cell::Text("head"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(true), Cell::Text("FAKE-856648"), ], &[ @@ -131,11 +125,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Jo"), Cell::Text("Elm"), Cell::Text("2019-02-03"), - Cell::Text("5-17"), Cell::Text("child"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(false), Cell::Text("FAKE-806707"), ], &[ @@ -144,11 +135,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Kai"), Cell::Text("Elm"), Cell::Text("1954-09-21"), - Cell::Text("65+"), Cell::Text("parent"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(false), Cell::Text("FAKE-219346"), ], &[ @@ -157,11 +145,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Mina"), Cell::Text("Elm"), Cell::Text("1991-11-10"), - Cell::Text("35-49"), Cell::Text("spouse"), Cell::Text("active"), - Cell::Text("pending_review"), - Cell::Bool(false), Cell::Text("FAKE-331902"), ], &[ @@ -170,11 +155,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Dee"), Cell::Text("Iron"), Cell::Text("1984-01-28"), - Cell::Text("35-49"), Cell::Text("head"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(true), Cell::Text("FAKE-748201"), ], &[ @@ -183,11 +165,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Ari"), Cell::Text("Iron"), Cell::Text("2016-07-18"), - Cell::Text("5-17"), Cell::Text("child"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(false), Cell::Text("FAKE-671240"), ], &[ @@ -196,11 +175,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Nia"), Cell::Text("Stone"), Cell::Text("1998-03-05"), - Cell::Text("18-34"), Cell::Text("head"), Cell::Text("pending"), - Cell::Text("pending_review"), - Cell::Bool(true), Cell::Text("FAKE-503118"), ], &[ @@ -209,11 +185,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Sol"), Cell::Text("Stone"), Cell::Text("2022-12-12"), - Cell::Text("0-4"), Cell::Text("child"), Cell::Text("pending"), - Cell::Text("pending_review"), - Cell::Bool(false), Cell::Text("FAKE-663910"), ], &[ @@ -222,11 +195,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Ren"), Cell::Text("Stone"), Cell::Text("1970-06-30"), - Cell::Text("50-64"), Cell::Text("parent"), Cell::Text("active"), - Cell::Text("ineligible"), - Cell::Bool(false), Cell::Text("FAKE-447120"), ], &[ @@ -235,11 +205,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Ivo"), Cell::Text("Reed"), Cell::Text("1957-04-02"), - Cell::Text("65+"), Cell::Text("head"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(true), Cell::Text("FAKE-990231"), ], &[ @@ -248,11 +215,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Uma"), Cell::Text("Vale"), Cell::Text("1993-08-16"), - Cell::Text("18-34"), Cell::Text("head"), Cell::Text("closed"), - Cell::Text("ineligible"), - Cell::Bool(true), Cell::Text("FAKE-125904"), ], &[ @@ -261,11 +225,8 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("Lina"), Cell::Text("Moss"), Cell::Text("1982-10-25"), - Cell::Text("35-49"), Cell::Text("head"), Cell::Text("active"), - Cell::Text("eligible"), - Cell::Bool(true), Cell::Text("FAKE-775120"), ], ]); @@ -284,7 +245,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("review_due_on"), Cell::Text("identity_verified"), Cell::Text("residence_verified"), - Cell::Text("consent_reference"), ], &[ Cell::Text("app-3001"), @@ -300,7 +260,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2026-01-20"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9001"), ], &[ Cell::Text("app-3002"), @@ -316,7 +275,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2025-08-10"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9002"), ], &[ Cell::Text("app-3003"), @@ -332,7 +290,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2024-06-30"), Cell::Bool(true), Cell::Bool(false), - Cell::Text("consent-9003"), ], &[ Cell::Text("app-3004"), @@ -348,7 +305,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2025-09-15"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9004"), ], &[ Cell::Text("app-3005"), @@ -364,7 +320,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2023-07-25"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9005"), ], &[ Cell::Text("app-3006"), @@ -380,7 +335,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2024-08-12"), Cell::Bool(false), Cell::Bool(true), - Cell::Text("consent-9006"), ], &[ Cell::Text("app-3007"), @@ -396,7 +350,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2026-06-01"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9007"), ], &[ Cell::Text("app-3008"), @@ -412,7 +365,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2026-06-15"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9008"), ], &[ Cell::Text("app-3009"), @@ -428,7 +380,6 @@ pub fn write_benefits_workbook(path: &Path) -> Result<()> { Cell::Text("2026-06-18"), Cell::Bool(true), Cell::Bool(true), - Cell::Text("consent-9009"), ], ]); diff --git a/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl b/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl new file mode 100644 index 000000000..97208139e --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl @@ -0,0 +1,113 @@ +services: + registry-consultation-db: + image: postgres@sha256:7a396fd264a2067788b6551122b50f162bf6136312c7fc9d74381cb92c648382 + environment: + POSTGRES_DB: registry_relay + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_USER: postgres + entrypoint: + - /bin/sh + - -ec + - | + mkdir -p /var/lib/postgresql/tls + cp /run/registry-tls/server.crt /var/lib/postgresql/tls/server.crt + cp /run/registry-tls/server.key /var/lib/postgresql/tls/server.key + chown postgres:postgres /var/lib/postgresql/tls/server.crt /var/lib/postgresql/tls/server.key + chmod 0644 /var/lib/postgresql/tls/server.crt + chmod 0600 /var/lib/postgresql/tls/server.key + exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/var/lib/postgresql/tls/server.crt -c ssl_key_file=/var/lib/postgresql/tls/server.key + healthcheck: + test: [CMD-SHELL, pg_isready -U postgres -d registry_relay] + interval: 1s + timeout: 5s + retries: 30 + volumes: + - ./notary/postgres-init.sql:/docker-entrypoint-initdb.d/10-registry-relay.sql:ro + - ./secrets/consultation-postgres.crt:/run/registry-tls/server.crt:ro + - ./secrets/consultation-postgres.key:/run/registry-tls/server.key:ro + - registry-consultation-db:/var/lib/postgresql/data + networks: [registry-notary-internal] + + registry-relay-consultation-bootstrap: + image: {{relay_image}} + user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" + env_file: + - ./secrets/local.env + command: + - consultation + - bootstrap-state + - --config + - /etc/registry-relay/relay.yaml + - --migration-database-url-env + - REGISTRY_RELAY_STATE_MIGRATION_URL + - --owner-role + - relay_state_owner + - --keyring-maintenance-database-url-env + - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL + - --keyring-reader-database-url-env + - REGISTRY_RELAY_STATE_KEYRING_READER_URL + - --active-key-id + - epoch-1 + - --active-write-deadline-unix-ms + - "4102444800000" + - --audit-event-retention-ms + - "604800000" + volumes: + - ./notary/project/.registry-stack/build/local/private/relay/config:/etc/registry-relay:ro + - ./secrets/consultation-postgres.crt:/run/registry-tls/state-plane-ca.pem:ro + depends_on: + registry-consultation-db: + condition: service_healthy + networks: [registry-notary-internal] + + registry-notary-jwks: + image: busybox@sha256:6be969a074d06073a62ad8ccfd2ad6ec2790cae25d519d8df255ba24819d96f0 + command: [httpd, -f, -p, "8083", -h, /srv/jwks] + ports: + - "4255:8081" + volumes: + - ./notary/jwks.json:/srv/jwks/jwks.json:ro + networks: [registry-notary-public, registry-notary-internal] + + registry-notary: + image: {{notary_image}} + user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" + env_file: + - ./secrets/local.env + command: [--config, /etc/registry-notary/notary.yaml] + network_mode: service:registry-notary-jwks + volumes: + - ./notary/project/.registry-stack/build/local/private/notary/config:/etc/registry-notary:ro + - ./secrets/notary-relay.jwt:/run/secrets/notary-relay.jwt:ro + depends_on: + registry-relay-consultation: + condition: service_healthy + + registry-relay-consultation: + image: {{relay_image}} + user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" + env_file: + - ./secrets/local.env + command: [--config, /etc/registry-relay/relay.yaml] + network_mode: service:registry-notary-jwks + volumes: + - ./notary/project/.registry-stack/build/local/private/relay/config:/etc/registry-relay:ro + - ./data/benefits_casework.xlsx:/var/lib/registry/benefits_casework.xlsx:ro + - ./secrets/consultation-postgres.crt:/run/registry-tls/state-plane-ca.pem:ro + - ./state/relay-consultation/cache:/var/lib/registry-relay/cache + depends_on: + registry-relay-consultation-bootstrap: + condition: service_completed_successfully + healthcheck: + test: [CMD, /usr/local/bin/registry-relay, healthcheck, --url, http://127.0.0.1:8082/ready] + interval: 1s + timeout: 5s + retries: 30 + +volumes: + registry-consultation-db: {} + +networks: + registry-notary-public: {} + registry-notary-internal: + internal: true diff --git a/crates/registryctl/src/templates/notary_addon/entities/person.yaml b/crates/registryctl/src/templates/notary_addon/entities/person.yaml new file mode 100644 index 000000000..7c358b25d --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/entities/person.yaml @@ -0,0 +1,32 @@ +version: 1 +id: person +revision: 1 +primary_key: person_id +schema: + type: object + additionalProperties: false + required: + - person_id + - household_id + - given_name + - family_name + - date_of_birth + - relationship_to_head + - registration_status + - national_id + properties: + person_id: { type: string, maxLength: 64 } + household_id: { type: string, maxLength: 64 } + given_name: { type: string, maxLength: 80 } + family_name: { type: string, maxLength: 80 } + # SnapshotExact keys currently use byte-exact UTF-8 comparison. The request + # input below still validates this value as an ISO date. + date_of_birth: { type: string, maxLength: 10 } + relationship_to_head: { type: string, maxLength: 32 } + registration_status: { type: string, maxLength: 32 } + national_id: { type: string, maxLength: 64 } +materialization: + max_records: 10000 + max_bytes: 16MiB + refresh: manual + retain_generations: 2 diff --git a/crates/registryctl/src/templates/notary_addon/environments/local.yaml b/crates/registryctl/src/templates/notary_addon/environments/local.yaml new file mode 100644 index 000000000..114204dc8 --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/environments/local.yaml @@ -0,0 +1,40 @@ +version: 1 +entities: + person: + provider: + type: xlsx + path: /var/lib/registry/benefits_casework.xlsx + sheet: Persons + header_row: 1 + columns: + person_id: person_id + household_id: household_id + given_name: given_name + family_name: family_name + date_of_birth: date_of_birth + relationship_to_head: relationship_to_head + registration_status: registration_status + national_id: national_id + source_revision: tutorial-benefits-workbook-v1 + generation: tutorial-local-v1 +callers: + tutorial-evaluator: + api_key_fingerprint: { secret: TUTORIAL_EVALUATOR_HASH } + scopes: ["benefits_casework:evidence"] +relay: + origin: http://127.0.0.1:8082 + issuer: http://127.0.0.1:8081 + jwks_url: http://127.0.0.1:8083/jwks.json + audience: registry-relay + allowed_clients: [registry-notary] +notary_relay: + base_url: http://127.0.0.1:8082 + workload_client_id: registry-notary + token_file: /run/secrets/notary-relay.jwt +notary_cel: + # Accommodates the amd64 emulation overhead used by this cross-platform tutorial. + worker_memory_bytes: 1073741824 +deployment: + profile: local + relay: { service: registry-relay-consultation } + notary: { service: registry-notary } diff --git a/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/ambiguous.yaml b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/ambiguous.yaml new file mode 100644 index 000000000..b65a2d0fd --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/ambiguous.yaml @@ -0,0 +1,14 @@ +name: ambiguous-person +classification: synthetic +input: { given_name: Sam, family_name: Example, date_of_birth: "2019-02-03" } +interactions: + - expect: { method: GET, path: /snapshot } + respond: + status: 200 + body: + - { registration_status: active } + - { registration_status: inactive } +expect: + outcome: ambiguous + outputs: {} + claims: {} diff --git a/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/match.yaml b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/match.yaml new file mode 100644 index 000000000..1a75a9e8c --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/match.yaml @@ -0,0 +1,10 @@ +name: jo-elm-match +classification: synthetic +input: { given_name: Jo, family_name: Elm, date_of_birth: "2019-02-03" } +interactions: + - expect: { method: GET, path: /snapshot } + respond: { status: 200, body: { registration_status: active } } +expect: + outcome: match + outputs: { registration_status: active } + claims: { person-registration-accepted: true } diff --git a/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/no-match.yaml b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/no-match.yaml new file mode 100644 index 000000000..389a6752e --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/no-match.yaml @@ -0,0 +1,10 @@ +name: unknown-person +classification: synthetic +input: { given_name: Nobody, family_name: Here, date_of_birth: "2019-02-03" } +interactions: + - expect: { method: GET, path: /snapshot } + respond: { status: 200, body: [] } +expect: + outcome: no_match + outputs: {} + claims: { person-registration-accepted: false } diff --git a/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/pending.yaml b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/pending.yaml new file mode 100644 index 000000000..c9ef3f839 --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/fixtures/pending.yaml @@ -0,0 +1,10 @@ +name: nia-stone-pending +classification: synthetic +input: { given_name: Nia, family_name: Stone, date_of_birth: "1998-03-05" } +interactions: + - expect: { method: GET, path: /snapshot } + respond: { status: 200, body: { registration_status: pending } } +expect: + outcome: match + outputs: { registration_status: pending } + claims: { person-registration-accepted: false } diff --git a/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/integration.yaml b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/integration.yaml new file mode 100644 index 000000000..c0cfb8c88 --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/integrations/person-demographics/integration.yaml @@ -0,0 +1,20 @@ +version: 1 +id: tutorial-person-demographics +revision: 1 +input: + given_name: { role: selector, type: string, maxLength: 80 } + family_name: { role: selector, type: string, maxLength: 80 } + date_of_birth: { role: selector, type: string, format: date, maxLength: 10 } +capability: + snapshot: + entity: person + exact: + given_name: { input: given_name } + family_name: { input: family_name } + date_of_birth: { input: date_of_birth } + freshness: 24h +outputs: [registration_status] +not_applicable: + subject_mismatch: + rationale: The exact snapshot selector applies name and date of birth before returning registration status; the minimized projection contains no selector-comparable identifier. + request_fixture: jo-elm-match diff --git a/crates/registryctl/src/templates/notary_addon/postgres-init.sql b/crates/registryctl/src/templates/notary_addon/postgres-init.sql new file mode 100644 index 000000000..2605a60d3 --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/postgres-init.sql @@ -0,0 +1,8 @@ +CREATE ROLE relay_state_owner NOLOGIN; +CREATE ROLE relay_state_runtime LOGIN; +CREATE ROLE relay_state_maintenance LOGIN; +CREATE ROLE relay_state_reader LOGIN; +GRANT CREATE ON DATABASE registry_relay TO relay_state_owner; +GRANT CONNECT ON DATABASE registry_relay TO relay_state_runtime; +GRANT CONNECT ON DATABASE registry_relay TO relay_state_maintenance; +GRANT CONNECT ON DATABASE registry_relay TO relay_state_reader; diff --git a/crates/registryctl/src/templates/notary_addon/registry-stack.yaml b/crates/registryctl/src/templates/notary_addon/registry-stack.yaml new file mode 100644 index 000000000..b1fd80d14 --- /dev/null +++ b/crates/registryctl/src/templates/notary_addon/registry-stack.yaml @@ -0,0 +1,25 @@ +version: 1 +registry: { id: tutorial-benefits-registry } +integrations: + person-demographics: { file: integrations/person-demographics/integration.yaml } +entities: + person: { file: entities/person.yaml } +services: + registration-verification: + kind: evidence + version: 1 + purpose: https://example.local/purpose/tutorial + legal_basis: public-service-delivery + consent: not_required + access: { scopes: ["benefits_casework:evidence"] } + consultations: + enrollment: + integration: person-demographics + input: + given_name: request.target.attributes.given_name + family_name: request.target.attributes.family_name + date_of_birth: request.target.attributes.date_of_birth + claims: + person-registration-accepted: + cel: enrollment.matched && enrollment.registration_status == "active" + disclosure: predicate diff --git a/crates/registryctl/src/templates/project_readme.md b/crates/registryctl/src/templates/project_readme.md index f8b9ac5c4..704473a0c 100644 --- a/crates/registryctl/src/templates/project_readme.md +++ b/crates/registryctl/src/templates/project_readme.md @@ -33,11 +33,26 @@ curl -sS -G \ http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person/records ``` +The operational `person` projection includes the canonical date of birth, but +not names or national identifiers. Use the separately scoped identity key to +read those fields and the household address for one subject: + +```sh +curl -sS \ + -H "Authorization: Bearer $IDENTITY_READER_RAW" \ + -H "Data-Purpose: https://example.local/purpose/identity-verification" \ + 'http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person_identity/records/per-2001?expand=household_contact' +``` + +This local identity key can read every record in the restricted projections. +The one-record response cap is not row-level authorization. In a deployment, +grant the identity scope only to a role permitted to read those fields. + ## Inspect ```sh registryctl open -sed -n '1,180p' relay/config.yaml +sed -n '1,520p' relay/config.yaml registryctl doctor --profile local --format json ``` @@ -47,11 +62,14 @@ Back up that file before upgrades or host moves. It contains the keys that keep audit hashes and generated API credentials stable. The generated Compose file keeps writable Relay state under `state/relay/`. -If you add Notary, preserve its configured PostgreSQL database because the -database holds replay, nonce, evaluation, quota, preauthorization, and optional -credential-status state. Use `registryctl stop` or `docker compose down` for -container replacement. Do not remove database volumes unless you are following -the documented restore or clean-cutover procedure. +If you add Notary, local Notary evaluation state is in memory and resets when the +Notary container is replaced. The `registry-consultation-db` PostgreSQL volume +belongs to the private consultation Relay, not Notary. The host directory +`state/relay-consultation/cache/` may contain cached source rows, so protect it +like the source workbook and include it in any deliberate cleanup or backup +decision. Use `registryctl stop` or `docker compose down` for container +replacement. Remove the consultation database volume or host cache only when +you intend to reset that local consultation state. Docker Compose reads `.env` to run Relay and Notary as the project owner on Unix hosts, keeping private state directories writable without widening their permissions. diff --git a/crates/registryctl/src/templates/relay_config.yaml.tmpl b/crates/registryctl/src/templates/relay_config.yaml.tmpl index 80d4739cb..ff7e718b5 100644 --- a/crates/registryctl/src/templates/relay_config.yaml.tmpl +++ b/crates/registryctl/src/templates/relay_config.yaml.tmpl @@ -1,6 +1,6 @@ # Generated by registryctl. # This file is the Relay contract for the local sample: service metadata, -# API-key auth, source spreadsheet tables, aggregates, and public entities. +# API-key auth, source spreadsheet tables, aggregates, and API projections. # Edit tables, entities, and aggregates when your workbook or API shape changes. deployment: @@ -47,6 +47,14 @@ auth: - benefits_casework:metadata - benefits_casework:aggregate + - id: {{identity_id}} + fingerprint: + provider: env + name: IDENTITY_READER_HASH + scopes: + - benefits_casework:metadata + - benefits_casework:identity_release + # Audit entries are emitted to container stdout as JSON Lines. The hash secret # lets Relay pseudonymize sensitive audit values without storing the secret here. audit: @@ -82,7 +90,8 @@ datasets: schema: # strict catches workbook drift by rejecting unexpected source # columns. Mark source fields sensitive when they contain personal - # data. The sample maps only the public-safe subset into entities below. + # data. The sample maps sensitive values into separate, restricted + # identity and contact projections below. strict: true fields: - name: household_id @@ -140,21 +149,12 @@ datasets: type: date nullable: false sensitive: true - - name: age_band - type: string - nullable: false - name: relationship_to_head type: string nullable: false - name: registration_status type: string nullable: false - - name: eligibility_status - type: string - nullable: false - - name: is_primary_applicant - type: boolean - nullable: false - name: national_id type: string nullable: true @@ -210,10 +210,6 @@ datasets: - name: residence_verified type: boolean nullable: false - - name: consent_reference - type: string - nullable: false - sensitive: true # Aggregates expose predeclared grouped statistics. aggregate_only_execution # allows aggregate readers to run these definitions without granting row @@ -272,9 +268,10 @@ datasets: min_group_size: 2 suppression: omit - # Entities are the public API surface. They choose which table fields are - # returned, define relationships for expand=..., attach access scopes, and - # restrict filters so collection reads stay intentional. + # Entities are API projections. They choose which table fields are returned, + # define relationships for expand=..., attach access scopes, and restrict + # filters so collection reads stay intentional. Operational projections use + # the rows scope. Identity and contact projections use a separate scope. entities: - name: household title: Household @@ -311,6 +308,10 @@ datasets: api: default_limit: 100 max_limit: 1000 + require_purpose_header: true + governed_policy: + permitted_purposes: + - https://example.local/purpose/tutorial allowed_filters: - field: id ops: [eq, in] @@ -331,16 +332,12 @@ datasets: from: person_id - name: household_id from: household_id - - name: age_band - from: age_band + - name: date_of_birth + from: date_of_birth - name: relationship_to_head from: relationship_to_head - name: registration_status from: registration_status - - name: eligibility_status - from: eligibility_status - - name: is_primary_applicant - from: is_primary_applicant relationships: - name: household kind: belongs_to @@ -358,13 +355,14 @@ datasets: default_limit: 100 max_limit: 1000 require_purpose_header: true + governed_policy: + permitted_purposes: + - https://example.local/purpose/tutorial allowed_filters: - field: id ops: [eq, in] - field: household_id ops: [eq, in] - - field: eligibility_status - ops: [eq, in] - field: registration_status ops: [eq, in] allowed_expansions: [household, applications] @@ -417,6 +415,9 @@ datasets: default_limit: 100 max_limit: 1000 require_purpose_header: true + governed_policy: + permitted_purposes: + - https://example.local/purpose/tutorial allowed_filters: - field: id ops: [eq, in] @@ -435,3 +436,63 @@ datasets: - field: review_due_on ops: [gte, lte, between] allowed_expansions: [household, applicant] + + - name: person_identity + title: Person identity + description: Restricted identity fields for synthetic people + table: persons_table + fields: + - name: id + from: person_id + - name: household_id + from: household_id + - name: given_name + from: given_name + - name: family_name + from: family_name + - name: national_id + from: national_id + relationships: + - name: household_contact + kind: belongs_to + target: household_contact + foreign_key: household_id + access: + metadata_scope: benefits_casework:metadata + aggregate_scope: benefits_casework:aggregate + read_scope: benefits_casework:identity_release + api: + default_limit: 1 + max_limit: 1 + require_purpose_header: true + governed_policy: + permitted_purposes: + - https://example.local/purpose/identity-verification + allowed_filters: + - field: id + ops: [eq] + allowed_expansions: [household_contact] + + - name: household_contact + title: Household contact + description: Restricted addresses for synthetic households + table: households_table + fields: + - name: id + from: household_id + - name: address_line + from: address_line + access: + metadata_scope: benefits_casework:metadata + aggregate_scope: benefits_casework:aggregate + read_scope: benefits_casework:identity_release + api: + default_limit: 1 + max_limit: 1 + require_purpose_header: true + governed_policy: + permitted_purposes: + - https://example.local/purpose/identity-verification + allowed_filters: + - field: id + ops: [eq] diff --git a/docs/site/scripts/check-registryctl-tutorials.sh b/docs/site/scripts/check-registryctl-tutorials.sh index 64cfb5fd7..ec24acc6f 100644 --- a/docs/site/scripts/check-registryctl-tutorials.sh +++ b/docs/site/scripts/check-registryctl-tutorials.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Execute the deployable Relay adopter tutorial against checked-out source. +# Execute the deployable Relay and Notary adopter tutorials against checked-out source. # This is a source-under-test CI gate: it builds registryctl and the release # product image shapes from the current checkout. It deliberately does not run # the published installer or release assets; the fresh-reader release proof is @@ -14,6 +14,7 @@ SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$SITE_ROOT/../.." && pwd)" HELPER="$SITE_ROOT/scripts/registryctl-tutorial.mjs" RELAY_TUTORIAL="$SITE_ROOT/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx" +NOTARY_TUTORIAL="$SITE_ROOT/src/content/docs/tutorials/verify-claim-registry-api.mdx" BUILDER_IMAGE="rust:1.95-bookworm@sha256:4c2fd73ef19c5ef9d54bee03b06b2839a392604fbfcd578ed948b71b37c1d7fb" LINUX_TARGET="$REPO_ROOT/target/registryctl-tutorial-linux-amd64" CARGO_HOME_DIR="$REPO_ROOT/target/registryctl-tutorial-cargo-home" @@ -190,11 +191,18 @@ run_block() { case "$expected" in success) if ((status != 0)); then - if [[ -f "$PWD/compose.yaml" ]]; then - local diagnostic - diagnostic="$WORK_ROOT/command-$(printf '%03d' "$COMMAND_COUNTER")-compose.log" - docker compose -p "$COMPOSE_PROJECT_NAME" -f "$PWD/compose.yaml" \ - logs --no-color --tail 100 >"$diagnostic" 2>&1 || true + if [[ -f "$PWD/compose.yaml" ]]; then + local diagnostic + diagnostic="$WORK_ROOT/command-$(printf '%03d' "$COMMAND_COUNTER")-compose.log" + { + docker compose -p "$COMPOSE_PROJECT_NAME" -f "$PWD/compose.yaml" ps --all + for service in $(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$PWD/compose.yaml" \ + ps --all --services); do + printf '\n[%s]\n' "$service" + docker compose -p "$COMPOSE_PROJECT_NAME" -f "$PWD/compose.yaml" \ + logs --no-color --tail 40 "$service" + done + } >"$diagnostic" 2>&1 || true printf '\n--- sanitized Compose log tail ---\n' >&2 sanitize_output "$diagnostic" >&2 fi @@ -275,6 +283,10 @@ assert_json_subset() { node "$HELPER" assert-json-subset "$1" "$2" } +assert_json_fence_subset() { + node "$HELPER" assert-json-fence-subset "$1" "$2" "$3" "$4" +} + build_source_under_test export DOCKER_DEFAULT_PLATFORM=linux/amd64 @@ -286,7 +298,7 @@ run_relay_tutorial() { mkdir -p "$tutorial_root" node "$HELPER" assert-layout "$RELAY_TUTORIAL" \ - '["Install registryctl","Create the sample project","Start the local stack","Run the smoke check","Load local demo keys","Make one denied request","Make one allowed request","Read one protected record","Read one protected record","Inspect the generated contract","Inspect the generated contract","Run an aggregate","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Stop the stack"]' + '["Install registryctl","Create the sample project","Start the local stack","Run the smoke check","Load local demo keys","Make one denied request","Make one allowed request","Read one protected record","Read one protected record","Read restricted identity fields","Read restricted identity fields","Inspect the generated contract","Inspect the generated contract","Run an aggregate","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Stop the stack"]' node "$HELPER" extract-shell "$RELAY_TUTORIAL" "$blocks" expected_install=$'curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.10.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.10.0 bash\nregistryctl --version' @@ -313,7 +325,7 @@ run_relay_tutorial() { assert_fence_lines "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Run the smoke check' text 1 [[ -s output/smoke-results.json ]] || { printf 'Relay smoke report is missing\n' >&2; exit 1; } run_block 'Relay 5: Load local demo keys' "$blocks/05.sh" success - [[ -n "${METADATA_READER_RAW:-}" && -n "${ROW_READER_RAW:-}" && -n "${AGGREGATE_READER_RAW:-}" ]] || { + [[ -n "${METADATA_READER_RAW:-}" && -n "${ROW_READER_RAW:-}" && -n "${AGGREGATE_READER_RAW:-}" && -n "${IDENTITY_READER_RAW:-}" ]] || { printf 'required Relay tutorial credentials were not loaded\n' >&2 exit 1 } @@ -324,34 +336,111 @@ run_relay_tutorial() { assert_contains "$LAST_OUTPUT" benefits_casework run_block 'Relay 8: Read one protected record' "$blocks/08.sh" success assert_http "$LAST_OUTPUT" 200 - assert_contains "$LAST_OUTPUT" per-2001 hh-1001 + assert_contains "$LAST_OUTPUT" per-2001 hh-1001 date_of_birth + assert_not_contains "$LAST_OUTPUT" age_band eligibility_status is_primary_applicant + assert_json_fence_subset "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Read one protected record' 1 run_block 'Relay 9: Refuse metadata-only row read' "$blocks/09.sh" success assert_problem "$LAST_OUTPUT" 403 auth.scope_denied - run_block 'Relay 10: Inspect the generated contract' "$blocks/10.sh" success - run_block 'Relay 11: Open the runtime API reference' "$blocks/11.sh" success + run_block 'Relay 10: Refuse operational key on identity projection' "$blocks/10.sh" success + assert_problem "$LAST_OUTPUT" 403 auth.scope_denied + assert_json_fence_subset "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Read restricted identity fields' 1 + run_block 'Relay 11: Read restricted identity projection' "$blocks/11.sh" success + assert_http "$LAST_OUTPUT" 200 + assert_contains "$LAST_OUTPUT" Fae Elm FAKE-856648 '595 River Rd, Southvale' + assert_json_fence_subset "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Read restricted identity fields' 2 + run_block 'Relay 12: Inspect the generated contract' "$blocks/12.sh" success + run_block 'Relay 13: Open the runtime API reference' "$blocks/13.sh" success assert_fence_lines "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Inspect the generated contract' text 1 - run_block 'Relay 12: Run an aggregate' "$blocks/12.sh" success + run_block 'Relay 14: Run an aggregate' "$blocks/14.sh" success assert_http "$LAST_OUTPUT" 200 - assert_json_subset "$LAST_OUTPUT" '{"disclosure_control":{"min_cell_size":2,"suppression":"omit"},"observations":[{"district":"north","household_count":2},{"district":"south","household_count":2}]}' + assert_json_fence_subset "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Run an aggregate' 1 node "$HELPER" set-relay-min-group-size relay/config.yaml benefits_casework by_district 3 - run_block 'Relay 13: Restart with a stronger disclosure floor' "$blocks/13.sh" success - run_block 'Relay 14: Verify all aggregate groups are suppressed' "$blocks/14.sh" success + run_block 'Relay 15: Restart with a stronger disclosure floor' "$blocks/15.sh" success + run_block 'Relay 16: Verify all aggregate groups are suppressed' "$blocks/16.sh" success assert_http "$LAST_OUTPUT" 200 - assert_json_subset "$LAST_OUTPUT" '{"disclosure_control":{"min_cell_size":3,"suppression":"omit"},"observations":[]}' + assert_json_fence_subset "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Change the disclosure rule' 1 node "$HELPER" set-relay-min-group-size relay/config.yaml benefits_casework by_district 1 - run_block 'Relay 15: Reject a disclosure floor below the invariant' "$blocks/15.sh" failure + run_block 'Relay 17: Reject a disclosure floor below the invariant' "$blocks/17.sh" failure assert_contains "$LAST_OUTPUT" 'Relay did not become healthy and ready before timeout' - run_block 'Relay 16: Explain the rejected configuration' "$blocks/16.sh" success + run_block 'Relay 18: Explain the rejected configuration' "$blocks/18.sh" success assert_contains "$LAST_OUTPUT" config.validation_error 'min_cell_size >= 2' node "$HELPER" set-relay-min-group-size relay/config.yaml benefits_casework by_district 2 - run_block 'Relay 17: Restore the valid disclosure floor' "$blocks/17.sh" success + run_block 'Relay 19: Restore the valid disclosure floor' "$blocks/19.sh" success assert_fence_lines "$LAST_OUTPUT" "$RELAY_TUTORIAL" 'Start the local stack' text 1 - run_block 'Relay 18: Stop the stack' "$blocks/18.sh" success + run_block 'Relay 20: Stop the stack' "$blocks/20.sh" success cleanup_stack "$tutorial_root/my-first-api" "$project_name" node "$HELPER" assert-ports-free 4242 4255 } run_relay_tutorial + +run_notary_tutorial() { + local blocks="$WORK_ROOT/notary-blocks" + local tutorial_root="$WORK_ROOT/relay-reader" + local project_dir="$tutorial_root/my-first-api" + local project_name="registryctl-notary-$RUN_ID" + local edited_claim="$WORK_ROOT/registry-stack-accept-pending.yaml" + + node "$HELPER" assert-layout "$NOTARY_TUTORIAL" \ + '["Add Notary to the project","Inspect the claim","Start Relay and Notary","Load the evaluator key","Evaluate an accepted active registration","Reject a pending registration","Try a non-matching date of birth","Edit the claim rule","Evaluate the edited rule","Stop the stack"]' + node "$HELPER" extract-shell "$NOTARY_TUTORIAL" "$blocks" + + export COMPOSE_PROJECT_NAME="$project_name" + PROJECT_DIRS+=("$project_dir") + PROJECT_NAMES+=("$project_name") + CURRENT_SECRET_FILE="$project_dir/secrets/local.env" + cd "$project_dir" + + run_block 'Notary 1: Add Notary to the project' "$blocks/01.sh" success + assert_json_fence_subset "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Add Notary to the project' 1 + assert_contains "$LAST_OUTPUT" http://127.0.0.1:4255 notary/project/registry-stack.yaml + run_block 'Notary 2: Inspect the claim' "$blocks/02.sh" success + assert_fence_lines "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Inspect the claim' yaml 1 + assert_contains "$LAST_OUTPUT" request.target.attributes.given_name request.target.attributes.date_of_birth person-registration-accepted + run_block 'Notary 3: Start Relay and Notary' "$blocks/03.sh" success + assert_fence_lines "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Start Relay and Notary' text 1 + run_block 'Notary 4: Load the evaluator key' "$blocks/04.sh" success + [[ -n "${TUTORIAL_EVALUATOR_RAW:-}" ]] || { + printf 'Notary tutorial evaluator credential was not loaded\n' >&2 + exit 1 + } + run_block 'Notary 5: Evaluate an accepted active registration' "$blocks/05.sh" success + assert_http "$LAST_OUTPUT" 200 + assert_json_fence_subset "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Evaluate an accepted active registration' 1 + assert_not_contains "$LAST_OUTPUT" Jo Elm 2019-02-03 '"active"' + run_block 'Notary 6: Reject a pending registration' "$blocks/06.sh" success + assert_http "$LAST_OUTPUT" 200 + assert_json_fence_subset "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Reject a pending registration' 1 + assert_not_contains "$LAST_OUTPUT" Nia Stone 1998-03-05 '"pending"' + run_block 'Notary 7: Try a non-matching date of birth' "$blocks/07.sh" success + assert_http "$LAST_OUTPUT" 200 + assert_json_fence_subset "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Try a non-matching date of birth' 1 + assert_not_contains "$LAST_OUTPUT" Jo Elm 2019-02-04 '"active"' + + node "$HELPER" replace-once \ + notary/project/registry-stack.yaml \ + 'enrollment.registration_status == "active"' \ + '(enrollment.registration_status == "active" || enrollment.registration_status == "pending")' \ + "$edited_claim" + mv "$edited_claim" notary/project/registry-stack.yaml + node "$HELPER" replace-once \ + notary/project/integrations/person-demographics/fixtures/pending.yaml \ + 'claims: { person-registration-accepted: false }' \ + 'claims: { person-registration-accepted: true }' \ + "$edited_claim" + mv "$edited_claim" notary/project/integrations/person-demographics/fixtures/pending.yaml + run_block 'Notary 8: Restart with the edited claim rule' "$blocks/08.sh" success + assert_contains "$LAST_OUTPUT" 'Relay API:' 'Notary API:' + run_block 'Notary 9: Evaluate the edited rule' "$blocks/09.sh" success + assert_http "$LAST_OUTPUT" 200 + assert_json_fence_subset "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Evaluate the edited rule' 1 + assert_not_contains "$LAST_OUTPUT" Nia Stone 1998-03-05 '"pending"' + run_block 'Notary 10: Stop the stack' "$blocks/10.sh" success + cleanup_stack "$project_dir" "$project_name" + node "$HELPER" assert-ports-free 4242 4255 +} + +run_notary_tutorial diff --git a/docs/site/scripts/check-tutorial.sh b/docs/site/scripts/check-tutorial.sh index a35238be7..0fc13448d 100755 --- a/docs/site/scripts/check-tutorial.sh +++ b/docs/site/scripts/check-tutorial.sh @@ -165,8 +165,8 @@ done # remove a documented command. REGISTRYCTL_TUTORIALS=( "author-registry-project:23" - "publish-spreadsheet-secured-registry-api:39" - "verify-claim-registry-api:21" + "publish-spreadsheet-secured-registry-api:49" + "verify-claim-registry-api:79" ) count_sh_command_lines() { diff --git a/docs/site/scripts/registryctl-tutorial.mjs b/docs/site/scripts/registryctl-tutorial.mjs index 731a0dbd7..a842ac844 100644 --- a/docs/site/scripts/registryctl-tutorial.mjs +++ b/docs/site/scripts/registryctl-tutorial.mjs @@ -149,8 +149,35 @@ export function parseJsonOutput(output) { if (value[index] === '{' || value[index] === '[') starts.push(index); } for (const start of starts) { + const opening = value[start]; + const closing = opening === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + let escaped = false; + let end = null; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + } else if (character === opening) { + depth += 1; + } else if (character === closing) { + depth -= 1; + if (depth === 0) { + end = index + 1; + break; + } + } + } + if (end === null) continue; try { - return JSON.parse(value.slice(start).trim()); + return JSON.parse(value.slice(start, end)); } catch { // Try the next JSON-looking boundary. HTTP headers can precede the body. } @@ -211,12 +238,20 @@ export function rebindProjectImages(projectDirectory, relayImage, notaryImage) { const manifestPath = resolve(projectDirectory, 'registryctl.yaml'); const compose = readYamlDocument(composePath); const composeValue = compose.toJS(); - const hasRelay = composeValue?.services?.['registry-relay'] !== undefined; - const hasNotary = composeValue?.services?.['registry-notary'] !== undefined; + const relayServices = [ + 'registry-relay', + 'registry-relay-consultation', + 'registry-relay-consultation-bootstrap', + ].filter((service) => composeValue?.services?.[service] !== undefined); + const notaryServices = ['registry-notary'].filter( + (service) => composeValue?.services?.[service] !== undefined, + ); + const hasRelay = relayServices.length > 0; + const hasNotary = notaryServices.length > 0; invariant(hasRelay || hasNotary, `${composePath} has no Registry Stack product services`); - if (hasRelay) compose.setIn(['services', 'registry-relay', 'image'], relayImage); - if (hasNotary) compose.setIn(['services', 'registry-notary', 'image'], notaryImage); + for (const service of relayServices) compose.setIn(['services', service, 'image'], relayImage); + for (const service of notaryServices) compose.setIn(['services', service, 'image'], notaryImage); writeYamlDocument(composePath, compose); const manifest = readYamlDocument(manifestPath); @@ -356,6 +391,15 @@ async function main([command, ...args]) { assertJsonSubset(read(args[0]), JSON.parse(args[1])); return; } + case 'assert-json-fence-subset': { + invariant( + args.length === 4, + 'usage: assert-json-fence-subset ', + ); + const expected = findFence(read(args[1]), args[2], 'json', Number(args[3])).content; + assertJsonSubset(read(args[0]), JSON.parse(expected)); + return; + } case 'rebind-project': { invariant( args.length === 3, diff --git a/docs/site/scripts/registryctl-tutorial.test.mjs b/docs/site/scripts/registryctl-tutorial.test.mjs index e76cafd63..84601aa7c 100644 --- a/docs/site/scripts/registryctl-tutorial.test.mjs +++ b/docs/site/scripts/registryctl-tutorial.test.mjs @@ -67,6 +67,7 @@ content-type: application/json\r \r {"observations":[{"district":"south","count":2},{"district":"north","count":2}]} ${HTTP_STATUS_PREFIX}200 +source-under-test images rebound `; assertHttpStatus(output, 200); assertJsonSubset(output, { @@ -84,7 +85,7 @@ test('rebinds generated project images without changing ports', () => { try { writeFileSync( join(directory, 'compose.yaml'), - 'services:\n registry-relay:\n image: relay:old\n ports: ["4242:8080"]\n registry-notary:\n image: notary:old\n', + 'services:\n registry-relay:\n image: relay:old\n ports: ["4242:8080"]\n registry-relay-consultation:\n image: relay:old\n registry-relay-consultation-bootstrap:\n image: relay:old\n registry-notary:\n image: notary:old\n', ); writeFileSync( join(directory, 'registryctl.yaml'), @@ -96,6 +97,8 @@ test('rebinds generated project images without changing ports', () => { const compose = parse(readFileSync(join(directory, 'compose.yaml'), 'utf8')); const manifest = parse(readFileSync(join(directory, 'registryctl.yaml'), 'utf8')); assert.equal(compose.services['registry-relay'].image, 'relay:source'); + assert.equal(compose.services['registry-relay-consultation'].image, 'relay:source'); + assert.equal(compose.services['registry-relay-consultation-bootstrap'].image, 'relay:source'); assert.equal(compose.services['registry-notary'].image, 'notary:source'); assert.deepEqual(compose.services['registry-relay'].ports, ['4242:8080']); assert.equal(manifest.runtime.relay_image, 'relay:source'); diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index b5d722b22..829a24ee7 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -255,6 +255,20 @@ Notary-only, and combined deployments. Registry Notary does not have a direct-so generator. Registry-backed Notary claims consume compiler-pinned Relay consultations, while a Notary-only project contains source-free or self-attested evaluation-only claims. +### Next-release local Notary add-on + +The `release/1.0` source line adds `registryctl add notary`. This command is not in the published +`v0.10.0` CLI. From a generated `benefits` spreadsheet project, it creates an editable authored +project under `notary/project/`, adds a private consultation Relay and local Notary services, and +generates local-only evaluator and workload credentials. It refuses an existing Notary directory +or a second invocation instead of overwriting authored work. + +`registryctl start` and `registryctl restart` validate and rebuild this add-on from the authored +project before starting it. Registryctl validates the fixed local runtime overrides with the +production product models and publishes one complete generated bundle, so services do not observe +a partially updated contract. The command is a tutorial convenience for the generated benefits +workbook, not a custom-registry import command. + ## Lifecycle Lifecycle commands run against the project in the current working directory. @@ -263,6 +277,7 @@ Lifecycle commands run against the project in the current working directory. | --- | --- | | `start` | Start the local project with Docker Compose and wait for readiness. | | `stop` | Stop the local project. | +| `restart` | Stop the local project, rebuild an added Notary project when present, then start and wait for readiness. | | `status` | Print local runtime status, including health and readiness probes. | ## Inspect diff --git a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx index 0d47d5322..5de8a0261 100644 --- a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx @@ -6,7 +6,7 @@ owner: registry-docs source_repos: - registry-stack - registry-relay -last_reviewed: "2026-06-28" +last_reviewed: "2026-07-17" doc_type: tutorial locale: en standards_referenced: @@ -18,11 +18,11 @@ import QuickstartMeta from '../../../components/QuickstartMeta.astro'; Registry Relay turns a tabular source you already hold, such as a spreadsheet or a database table, into a protected, read-only API without copying the data out. Use this tutorial to start a protected registry API on your machine. -You will create a local project from a tiny benefits workbook, make one denied request, make one -allowed request, and inspect the contract Registry Stack generated. +You will create a local project from a tiny benefits workbook, compare operational and restricted +identity reads, and inspect the contract Registry Stack generated. -Do not add production endpoints, credentials, or subject records to this exercise. +This tutorial uses synthetic data and local demo credentials. Do not use the generated keys or +database settings in production. -{/* Evidence: crates/registryctl/assets/project-starters/bounded-http and - crates/registryctl/src/project_authoring/fixtures.rs. */} +:::note[Release status] +This tutorial requires `registryctl`, immutable Relay and Notary images, and the image lock from +the same Registry Stack 1.0 or later release. It is not runnable with the published `v0.10.0` +CLI or with an ad hoc source-built CLI alone. The source-image rebinding used by the repository's +tutorial gate is an internal CI method, not a supported installation route. +::: -## Copy the combined starter +## Add Notary to the project -Create a local Registry Stack project: +From the `my-first-api` directory left by the first tutorial, add the local Notary journey: ```sh -registryctl init --from http --project-dir registry-claim-project +registryctl add notary ``` -The result confirms the initialized project and next command: +The command reports the authored claim file and local Notary URL: + +```json +{ + "status": "added", + "project": "my-first-api", + "notary_url": "http://127.0.0.1:4255", + "claim_file": "notary/project/registry-stack.yaml" +} +``` + +The add-on creates an editable Registry Stack project under `notary/project/`. It also adds a +private consultation Relay that reads the same workbook. Relay still owns registry access. Notary +receives only the minimized consultation result needed to evaluate the claim. + +## Inspect the claim + +Read the evidence service before starting it: + +```sh +sed -n '7,28p' notary/project/registry-stack.yaml +``` + +The important part is deliberately small: + +```yaml + registration-verification: + kind: evidence + version: 1 + purpose: https://example.local/purpose/tutorial + legal_basis: public-service-delivery + consent: not_required + access: { scopes: ["benefits_casework:evidence"] } + consultations: + enrollment: + integration: person-demographics + input: + given_name: request.target.attributes.given_name + family_name: request.target.attributes.family_name + date_of_birth: request.target.attributes.date_of_birth + claims: + person-registration-accepted: + cel: enrollment.matched && enrollment.registration_status == "active" + disclosure: predicate +``` + +The caller supplies a name and date of birth already obtained through its intake process. Relay +uses all three values for an exact lookup and returns `registration_status` only to Notary. The +selector fields cannot also be projected as outputs, and the public result does not repeat the +name, date of birth, or status. This keeps matching inputs separate from the registry fact being +proved. + +This is evidence that the registry contains exactly one matching record whose status satisfies the +current acceptance policy. It is not proof that the caller is that person. Authentication, +identity assurance, and authority to act for the person remain separate controls. + +Names and dates of birth can collide or be recorded differently. Requiring exactly one match +rejects ambiguity, but a production service still needs a jurisdiction-appropriate identity +assurance and matching design. + +## Start Relay and Notary + +Start the combined local project: + +```sh +registryctl start +``` + +After the containers become ready, the command prints both APIs: ```text -Initialized Registry Stack project "fictional-citizen-registry". - Directory: registry-claim-project - Starter: http (Registry Stack 0.10.0) - Starter content: matches bundled digest - Editor support: VS Code and Zed (registry-claim-project/.registry-stack-editor/manifest.json) +Relay API: http://127.0.0.1:4242 +API docs: http://127.0.0.1:4242/docs +Notary API: http://127.0.0.1:4255 +Notary docs: http://127.0.0.1:4255/docs +``` + +## Load the evaluator key -Next: - cd registry-claim-project - registryctl test --project-dir . +Load the generated local credentials into your current shell: + +```sh +set -a +. ./secrets/local.env +set +a +test -n "$TUTORIAL_EVALUATOR_RAW" ``` -The authored `registry-stack.yaml` defines one evidence service. Its consultation maps a bounded -request input to the Relay integration, and its claims derive only from the consultation outcome -and typed output. +The raw key stays in the ignored `secrets/local.env` file. Do not paste it into the request body or +commit it. -## Evaluate the matching fixture +## Evaluate an accepted active registration -Run only the active-person fixture and include its safe synthetic trace: +Evaluate an active registration from the workbook: ```sh -registryctl test \ - --project-dir registry-claim-project \ - --integration person-record \ - --fixture active-person \ - --trace +curl -sS -X POST \ + -H "x-api-key: $TUTORIAL_EVALUATOR_RAW" \ + -H "Data-Purpose: https://example.local/purpose/tutorial" \ + -H "Content-Type: application/json" \ + -H "Accept: application/vnd.registry-notary.claim-result+json" \ + --data '{ + "target": { + "type": "person", + "attributes": { + "given_name": "Jo", + "family_name": "Elm", + "date_of_birth": "2019-02-03" + } + }, + "claims": ["person-registration-accepted"], + "disclosure": "predicate", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "https://example.local/purpose/tutorial" + }' \ + http://127.0.0.1:4255/v1/evaluations ``` -The JSON report identifies the exercised integration, fixture, consultation outcome, output, and -claims. The important fields are: +The response also includes dynamic evaluation, target-reference, timestamp, and provenance fields. +The stable fields to look for are: ```json { - "status": "passed", - "fixtures": [ + "results": [ { - "integration": "person-record", - "fixture": "active-person", - "outputs": ["active"], - "claims": ["person-active", "person-record-exists"], - "outcome": "match", - "passed": true + "claim_id": "person-registration-accepted", + "value": true, + "satisfied": true, + "disclosure": "predicate", + "format": "application/vnd.registry-notary.claim-result+json" } ] } ``` -The fixture runner executes the Relay acquisition contract in memory, then evaluates the Notary -claims from the typed consultation result. It does not permit a fixture to choose a destination, -credential, capability, worker command, or decoder. +`true` is the claim result. The response does not contain `Jo`, `Elm`, `2019-02-03`, or the source +value `active`. -## Verify no-match semantics +## Reject a pending registration -Run the no-match fixture: +Save a request for a person whose registry record is pending. You will repeat it after changing the +policy: ```sh -registryctl test \ - --project-dir registry-claim-project \ - --integration person-record \ - --fixture no-person +cat > notary/pending-registration-request.json <<'JSON' +{ + "target": { + "type": "person", + "attributes": { + "given_name": "Nia", + "family_name": "Stone", + "date_of_birth": "1998-03-05" + } + }, + "claims": ["person-registration-accepted"], + "disclosure": "predicate", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "https://example.local/purpose/tutorial" +} +JSON + +curl -sS -X POST \ + -H "x-api-key: $TUTORIAL_EVALUATOR_RAW" \ + -H "Data-Purpose: https://example.local/purpose/tutorial" \ + -H "Content-Type: application/json" \ + -H "Accept: application/vnd.registry-notary.claim-result+json" \ + --data @notary/pending-registration-request.json \ + http://127.0.0.1:4255/v1/evaluations +``` + +The person matches exactly, but `pending` does not satisfy the initial policy: + +```json +{ + "results": [ + { + "claim_id": "person-registration-accepted", + "value": false, + "satisfied": false, + "disclosure": "predicate" + } + ] +} ``` -The report contains `"outcome": "no_match"` and `"status": "passed"`. The fixture contract expects -the presence claim to be `false` and the direct output claim to be null. No source error or raw -record becomes a Notary claim. +This is a policy rejection, not a failed lookup. The public response still does not reveal the +source status. + +## Try a non-matching date of birth -Run every fixture to include the ambiguity case and derived security checks: +Change only the request date for this call: ```sh -registryctl test --project-dir registry-claim-project +curl -sS -X POST \ + -H "x-api-key: $TUTORIAL_EVALUATOR_RAW" \ + -H "Data-Purpose: https://example.local/purpose/tutorial" \ + -H "Content-Type: application/json" \ + -H "Accept: application/vnd.registry-notary.claim-result+json" \ + --data '{ + "target": { + "type": "person", + "attributes": { + "given_name": "Jo", + "family_name": "Elm", + "date_of_birth": "2019-02-04" + } + }, + "claims": ["person-registration-accepted"], + "disclosure": "predicate", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "https://example.local/purpose/tutorial" + }' \ + http://127.0.0.1:4255/v1/evaluations ``` -The report must again contain: +The lookup finds no exact record, so the predicate is false: -```text -"status": "passed" +```json +{ + "results": [ + { + "claim_id": "person-registration-accepted", + "value": false, + "satisfied": false, + "disclosure": "predicate" + } + ] +} ``` -## Inspect the authored policy +No-match is a claim result, not a source error. Ambiguous matches are different: the private Relay +rejects them rather than choosing a row. -Open `registry-claim-project/registry-stack.yaml`. The evidence service owns: +## Edit the claim rule -- the purpose and legal basis; -- caller scopes; -- the consultation input mapping; -- claims and disclosure modes; and -- the credential profile claim list. +Open `notary/project/registry-stack.yaml` in your editor. Find this line: -The integration owns the fixed source request and minimized typed output. Product and version -metadata document interoperability evidence. They do not select the HTTP or script capability. +```yaml + cel: enrollment.matched && enrollment.registration_status == "active" +``` -Change the service `purpose`, then rerun the fixture test. Purpose policy is authored once and -compiled into both the Relay consultation profile and the Notary claim contract. Do not patch the -generated Notary YAML. +Change the expression to accept either status and save the file: -## Validate the cross-product contract +```yaml + cel: enrollment.matched && (enrollment.registration_status == "active" || enrollment.registration_status == "pending") +``` -Compile the project through both product validators and print the redacted plan: +The pending fixture is an executable example of the original policy. Open +`notary/project/integrations/person-demographics/fixtures/pending.yaml` and change its expected claim +from: -```sh -registryctl check \ - --project-dir registry-claim-project \ - --environment local \ - --explain +```yaml + claims: { person-registration-accepted: false } ``` -The human-readable report marks the project `valid`. Confirm that Notary names the same consultation -profile, contract hash, purpose, closed inputs, outcome union, and typed outputs that Relay exposes. -A mismatch fails validation rather than selecting a fallback source path. Add `--format json` only -for machine processing. +to: -## Build unsigned product inputs +```yaml + claims: { person-registration-accepted: true } +``` -Build the reviewed project: +Now restart: ```sh -registryctl build \ - --project-dir registry-claim-project \ - --environment local +registryctl restart ``` -The report contains `"status": "built"`. Inspect the generated inputs: +`registryctl restart` validates and rebuilds the generated Relay and Notary inputs from the +authored files before starting the services. The fixtures are executable examples of the claim, so +restart refuses to deploy when an expected result no longer agrees with the edited rule. Edit the +authored files under `notary/project/`, not generated files under `.registry-stack/build/`. + +## Evaluate the edited rule + +Repeat the saved request: ```sh -sed -n '1,220p' \ - registry-claim-project/.registry-stack/build/local/private/notary/config/notary.yaml +curl -sS -X POST \ + -H "x-api-key: $TUTORIAL_EVALUATOR_RAW" \ + -H "Data-Purpose: https://example.local/purpose/tutorial" \ + -H "Content-Type: application/json" \ + -H "Accept: application/vnd.registry-notary.claim-result+json" \ + --data @notary/pending-registration-request.json \ + http://127.0.0.1:4255/v1/evaluations ``` -The Notary config has one logical Relay connection and one compiler-pinned consultation. It has no -source destination, source credential, product adapter, or script runtime. Those remain Relay -responsibilities. +The same pending source record is now accepted by the expanded policy: + +```json +{ + "results": [ + { + "claim_id": "person-registration-accepted", + "value": true, + "satisfied": true, + "disclosure": "predicate" + } + ] +} +``` -The generated Relay and Notary directories are separate unsigned inputs to each product's Config -Bundle workflow. They are not a signed project-level deployment bundle and should not be committed. +You changed claim semantics in the authored project, rebuilt the governed contract, and observed +the new result through the live API. You did not edit the workbook or expose its row. -## Clean up +## Stop the stack -Remove the generated project when you no longer need it: +When you are done: ```sh -rm -rf registry-claim-project +registryctl stop ``` +This keeps the workbook, authored Notary project, and local request file. To return to the original +claim later, restore the equality expression and change the pending fixture expectation back to +`false` before starting the project. + +## What you built + +You extended the first tutorial's protected registry with a live Notary evaluation path. An +authorized caller supplied name and date of birth instead of a national identifier. Relay required +one exact record and released only registration status to Notary. Notary returned one predicate, +with no source fields in the public result. You saw a matched pending registration rejected by the +initial policy, then expanded the policy and watched the same request produce a different governed +answer. + ## Next -- [Author an HTTP Registry Stack project](../author-registry-project/) to adapt the starter while - retaining its fixture evidence. -- [Configure a script source adapter](../configure-project-script-adapter/) when one bounded - HTTP request cannot express the reviewed source recipe. +- [Author an HTTP Registry Stack project](../author-registry-project/) to start adapting a custom + registry while retaining synthetic success, no-match, and ambiguity evidence. - [Registry Notary operator configuration reference](../../products/registry-notary/operator-config-reference/) for the generated runtime contract and deployment gates. + +## Troubleshooting + +| Symptom | Cause | Resolution | +| --- | --- | --- | +| `registryctl add notary` is unknown | The CLI predates Registry Stack 1.0 or does not match the installed release image lock. | Install `registryctl`, the immutable Relay and Notary images, and the image lock from the same Registry Stack 1.0 or later release. Do not substitute an ad hoc source build or a lock from another release. | +| `registryctl add notary` says the project already has a Notary add-on | The command has already completed for this project. | Continue with `registryctl start`; do not run the add command twice. | +| Notary returns `401` | The evaluator key was not loaded or is from another generated project. | Source `secrets/local.env` again in the current shell. | +| Notary returns `403` | The key lacks the evidence scope or the purpose does not match the authored service. | Use `TUTORIAL_EVALUATOR_RAW` and the tutorial purpose shown above. | +| `registryctl restart` reports a claim mismatch | The edited rule no longer agrees with the fixture's expected result. | Update the expected claim in `notary/project/integrations/person-demographics/fixtures/pending.yaml`, then restart again. | +| The edited rule does not take effect | A generated file was edited, or the running services were not rebuilt. | Edit the authored claim and matching fixture under `notary/project/`, then run `registryctl restart`. |