From 9af0ffc248b3b364bbfa6a6040d14742b6201e37 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 09:58:58 +0700 Subject: [PATCH 01/11] test(render): give each serve fixture a distinct port Port discovery releases the socket before the child binds it, so parallel fixtures could reach each other's server. Keep issued ports distinct and report a child that exits before it becomes healthy. Retry on a fresh port, at most three times, when the server's bind is refused. Signed-off-by: Jeremi Joslin --- crates/registry-render/tests/serve.rs | 116 ++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 16 deletions(-) diff --git a/crates/registry-render/tests/serve.rs b/crates/registry-render/tests/serve.rs index ac67ff30dc..36f6dbcb34 100644 --- a/crates/registry-render/tests/serve.rs +++ b/crates/registry-render/tests/serve.rs @@ -3,10 +3,13 @@ //! through the platform audit writer. These tests are the DoD's serve-mode //! acceptance coverage. -use std::io::{BufRead, BufReader, Read, Write}; +use std::collections::BTreeSet; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdout, Command, Stdio}; +use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio}; +use std::sync::Mutex; use std::time::{Duration, Instant}; const API_KEY: &str = "test-key-0123456789abcdef0123456789abcdef"; @@ -52,11 +55,18 @@ fn physical_tempdir() -> (tempfile::TempDir, PathBuf) { } fn free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind for port discovery") - .local_addr() - .expect("local addr") - .port() + // Port discovery releases the socket before the child binds it. Keep every + // issued port distinct so parallel fixtures cannot reach each other's server. + static ISSUED_PORTS: Mutex> = Mutex::new(BTreeSet::new()); + let mut issued = ISSUED_PORTS.lock().expect("issued test ports"); + for _ in 0..1024 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind for port discovery"); + let port = listener.local_addr().expect("local addr").port(); + if issued.insert(port) { + return port; + } + } + panic!("could not allocate a distinct fixture port"); } fn copy_dir(from: &Path, to: &Path) { @@ -113,25 +123,72 @@ fn start_server(runtime_path: &Path) -> Server { } fn start_server_at(runtime_path: &Path, current_dir: Option<&Path>) -> Server { - spawn_server(runtime_path, current_dir, Stdio::null()) + spawn_server(runtime_path, current_dir, Stdio::null) } /// Start a server whose standard output the test reads: the audit stream /// of a `stdout` destination. fn start_server_with_stdout(runtime_path: &Path) -> (Server, ChildStdout) { - let mut server = spawn_server(runtime_path, None, Stdio::piped()); + let mut server = spawn_server(runtime_path, None, Stdio::piped); let stdout = server.child.stdout.take().expect("piped stdout"); (server, stdout) } -fn spawn_server(runtime_path: &Path, current_dir: Option<&Path>, stdout: Stdio) -> Server { +fn spawn_server(runtime_path: &Path, current_dir: Option<&Path>, stdout: fn() -> Stdio) -> Server { + // Another socket can take the port before the child binds it, so retry on a fresh one. + let mut retries = 0; + loop { + if let Some(server) = try_spawn_server(runtime_path, current_dir, stdout()) { + return server; + } + retries += 1; + assert!(retries <= 3, "server port was taken on every attempt"); + move_to_fresh_port(runtime_path); + } +} + +/// Point a runtime's listener at a fresh port. +fn move_to_fresh_port(runtime_path: &Path) { + let text = std::fs::read_to_string(runtime_path).unwrap(); + let bind = text + .lines() + .find(|l| l.contains("bind:")) + .expect("listener bind"); + let (address, _) = bind.rsplit_once(':').expect("bind port"); + let moved = text.replacen(bind, &format!("{address}:{}", free_port()), 1); + std::fs::write(runtime_path, moved).unwrap(); +} + +/// Fail with the server's output unless it exited because its port was taken. +fn fail_unless_port_taken(status: ExitStatus, stderr: &mut File, context: &str) { + let mut output = String::new(); + stderr + .seek(SeekFrom::Start(0)) + .expect("rewind server stderr"); + stderr + .read_to_string(&mut output) + .expect("read server stderr"); + let runtime_invalid = registry_render::ProblemKind::RuntimeInvalid.exit_code(); + if status.code() != Some(runtime_invalid) || !output.contains("Address already in use") { + panic!("{context} with {status}:\n{output}"); + } +} + +/// Spawn a server and wait for its health answer; `None` when its port was +/// taken before it could bind. +fn try_spawn_server( + runtime_path: &Path, + current_dir: Option<&Path>, + stdout: Stdio, +) -> Option { + let mut stderr = tempfile::tempfile().expect("server stderr file"); let mut command = Command::new(env!("CARGO_BIN_EXE_registry-render")); command .arg("serve") .arg("--runtime-config") .arg(runtime_path) .stdout(stdout) - .stderr(Stdio::null()); + .stderr(stderr.try_clone().expect("server stderr handle")); if let Some(dir) = current_dir { command.current_dir(dir); } @@ -145,25 +202,34 @@ fn spawn_server(runtime_path: &Path, current_dir: Option<&Path>, stdout: Stdio) .expect("port from runtime"); let deadline = Instant::now() + Duration::from_secs(30); loop { + if let Some(status) = child.try_wait().expect("server status") { + fail_unless_port_taken(status, &mut stderr, "server exited early"); + return None; + } if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)) { let _ = stream .write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); let mut buf = [0u8; 256]; if stream.read(&mut buf).is_ok_and(|n| n > 0) { - return Server { + if let Some(status) = child.try_wait().expect("server status") { + fail_unless_port_taken( + status, + &mut stderr, + "server exited before becoming healthy", + ); + return None; + } + return Some(Server { child, port, home: PathBuf::from("/tmp"), - }; + }); } } if Instant::now() > deadline { let _ = child.kill(); panic!("server did not become healthy in 30s"); } - if let Ok(Some(status)) = child.try_wait() { - panic!("server exited early with {status}"); - } std::thread::sleep(Duration::from_millis(100)); } } @@ -308,6 +374,24 @@ fn serve_health_and_ready() { let _ = home; } +#[test] +fn a_fixture_port_taken_before_the_bind_moves_to_a_fresh_port() { + let (_home, runtime, port) = deployment( + DEFAULT_LIMITS, + &repo_root().join("products/render/bundles/receipt"), + ); + // A listener that answers nothing holds the port, the way a parallel port + // discovery can hold it while the child binds. + let holder = TcpListener::bind(("127.0.0.1", port)).expect("hold the fixture port"); + std::thread::spawn(move || holder.incoming().for_each(drop)); + let server = start_server(&runtime); + assert_ne!(server.port, port); + assert_eq!( + request(server.port, "GET", "/health", &[], None).status, + 200 + ); +} + #[test] fn the_runtime_file_serves_the_same_from_any_working_directory() { // The natural deployment layout: runtime file and key files together in From ca45ca8c3749d34fd5bc077d0630b934dfb8c403 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 13 Oct 2026 02:56:13 +0700 Subject: [PATCH 02/11] test(casework): cover the macOS installer on both sides of the bundle boundary The Casework installer takes raw binaries through v0.32.9 and a bundle carrying the FIPS runtime from v0.33.0, and its test covered Linux only. The fixture now writes a bundle for the later release and binaries that answer --version, and the test mv shim translates the pointer switch in both directions so the forced macOS run also passes on a Linux runner. Signed-off-by: Jeremi Joslin --- .../registry-casework/tests/install_script.rs | 106 +++++++++++++++--- 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/registry-casework/tests/install_script.rs b/crates/registry-casework/tests/install_script.rs index 052732a5ae..34a364e194 100644 --- a/crates/registry-casework/tests/install_script.rs +++ b/crates/registry-casework/tests/install_script.rs @@ -296,6 +296,36 @@ fn linux_binary_that_cannot_start_leaves_the_previous_toolset_selected() { } } +#[test] +fn macos_assets_follow_the_raw_binary_to_bundled_runtime_release_boundary() { + for version in ["v0.32.9", "v0.33.0"] { + let fixture = InstallerFixture::macos(version); + let output = fixture.run(false); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_release_toolset_active(); + for binary in BINARIES { + let stem = format!("{binary}-{version}-macos-arm64"); + if version == "v0.32.9" { + assert!(fixture.release_dir.join(&stem).is_file()); + assert!(!fixture.release_dir.join(format!("{stem}.tar.gz")).exists()); + } else { + assert!(fixture.release_dir.join(format!("{stem}.tar.gz")).is_file()); + let bundle = fixture + .install_dir + .join(".casework-current") + .join(format!("{binary}.bundle")); + assert!(bundle.join(&stem).is_file()); + assert!(bundle.join("libaws_lc_fips_0_14_2_crypto.dylib").is_file()); + assert!(bundle.join("THIRD_PARTY_NOTICES").is_file()); + } + } + } +} + #[test] fn installer_carries_the_shared_glibc_floor() { let (major, minor) = glibc_floor(); @@ -317,6 +347,8 @@ struct InstallerFixture { fake_bin: PathBuf, asset_suffix: String, forced_uname: Option<(String, String)>, + version: &'static str, + macos_bundle: bool, } impl InstallerFixture { @@ -331,6 +363,14 @@ impl InstallerFixture { } fn build(forced_uname: Option<(String, String)>) -> Self { + Self::build_version(forced_uname, TEST_VERSION) + } + + fn macos(version: &'static str) -> Self { + Self::build_version(Some(("Darwin".to_owned(), "arm64".to_owned())), version) + } + + fn build_version(forced_uname: Option<(String, String)>, version: &'static str) -> Self { let unique = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed); let root = std::env::temp_dir().join(format!( "registry-casework-installer-test-{}-{unique}", @@ -376,26 +416,35 @@ printf 'ldd (GNU libc) %s\n' "${FAKE_GLIBC:-2.41}" "#, ); let asset_suffix = match &forced_uname { - Some(_) => "linux-amd64".to_owned(), + Some((system, machine)) => match (system.as_str(), machine.as_str()) { + ("Linux", "x86_64") => "linux-amd64".to_owned(), + ("Darwin", "arm64") => "macos-arm64".to_owned(), + _ => panic!("unsupported fixture platform"), + }, None => platform_suffix().to_owned(), }; + let mut components = version.trim_start_matches('v').split('.'); + let major: u32 = components.next().unwrap().parse().unwrap(); + let minor: u32 = components.next().unwrap().parse().unwrap(); + let macos_bundle = asset_suffix == "macos-arm64" && (major > 0 || minor >= 33); if forced_uname.is_some() { // A forced Linux run reaches the installer's GNU pointer switch, // which asks for mv -T. A macOS workstation spells that same - // guarantee mv -h, so translate it there and pass it through - // untouched on a Linux runner. + // guarantee mv -h, so translate it there. A forced macOS run asks + // for mv -h, which GNU mv spells -T, so translate it back on a + // Linux runner. Everything else passes through untouched. write_executable( &fake_bin.join("mv"), r#"#!/usr/bin/env bash set -euo pipefail -if [[ "$(/usr/bin/uname -s)" != Darwin ]]; then - exec /bin/mv "$@" -fi +host="$(/usr/bin/uname -s)" arguments=() for argument in "$@"; do - case "$argument" in - -Tf | -fT) arguments+=(-f -h) ;; - -T) arguments+=(-h) ;; + case "$host:$argument" in + Darwin:-Tf | Darwin:-fT) arguments+=(-f -h) ;; + Darwin:-T) arguments+=(-h) ;; + Linux:-fh | Linux:-hf) arguments+=(-T -f) ;; + Linux:-h) arguments+=(-T) ;; *) arguments+=("$argument") ;; esac done @@ -410,6 +459,8 @@ exec /bin/mv "${arguments[@]}" fake_bin, asset_suffix, forced_uname, + version, + macos_bundle, }; fixture.write_release_assets(); fixture @@ -419,9 +470,36 @@ exec /bin/mv "${arguments[@]}" let suffix = &self.asset_suffix; let mut sums = String::new(); for binary in BINARIES { - let asset = format!("{binary}-{TEST_VERSION}-{suffix}"); + let stem = format!("{binary}-{}-{suffix}", self.version); + let asset = if self.macos_bundle { + format!("{stem}.tar.gz") + } else { + stem.clone() + }; let path = self.release_dir.join(&asset); - write_fixture_binary(&path, binary, &self.start_log()); + if self.macos_bundle { + let source = self.root.join("bundle-sources").join(binary); + fs::create_dir_all(&source).unwrap(); + write_fixture_binary(&source.join(&stem), binary, self.version, &self.start_log()); + let library = "libaws_lc_fips_0_14_2_crypto.dylib"; + write_executable(&source.join(library), "synthetic library\n"); + fs::write(source.join("THIRD_PARTY_NOTICES"), "synthetic notices\n").unwrap(); + let output = Command::new("tar") + .arg("-czf") + .arg(&path) + .arg("-C") + .arg(&source) + .args([stem.as_str(), library, "THIRD_PARTY_NOTICES"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "tar stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } else { + write_fixture_binary(&path, binary, self.version, &self.start_log()); + } sums.push_str(&format!("{} {asset}\n", sha256(&path))); } fs::write(self.release_dir.join("SHA256SUMS"), sums).unwrap(); @@ -556,7 +634,7 @@ exit 1 command .arg(installer_path()) .env("PATH", path) - .env("CASEWORK_VERSION", TEST_VERSION) + .env("CASEWORK_VERSION", self.version) .env("CASEWORK_ASSET_DIR", &self.release_dir) .env("CASEWORK_INSTALL_DIR", &self.install_dir); if let Some((system, machine)) = &self.forced_uname { @@ -739,7 +817,7 @@ fn write_executable(path: &Path, body: &str) { /// A release asset that starts: it answers `--version` the way the published /// binary does and otherwise names itself. -fn write_fixture_binary(path: &Path, binary: &str, start_log: &Path) { +fn write_fixture_binary(path: &Path, binary: &str, version: &str, start_log: &Path) { write_executable( path, &format!( @@ -752,7 +830,7 @@ else printf '%s\n' '{binary} release binary' fi "#, - version = &TEST_VERSION[1..], + version = version.trim_start_matches('v'), start_log = start_log.display() ), ); From baada70a8dd921983c01c93415564d20948413a9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 10:01:11 +0700 Subject: [PATCH 03/11] build: pull pinned prerequisite images from digest-identical mirrors CI services, release recipe roots, and the bregctl, caseworkctl, and messagingctl dev databases pull PostgreSQL, Debian, and Mailpit from the ECR Public Docker Official Images and GHCR mirrors at the same manifest digests. Public help keeps the canonical identities, and retained dev containers accept either exact reference. Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 12 ++-- .github/workflows/nightly-rust-coverage.yml | 2 +- crates/registry-bregctl/src/dev/mod.rs | 17 +++++- crates/registry-bregctl/src/dev/tests.rs | 55 ++++++++++++++++++- crates/registry-caseworkctl/src/dev/mod.rs | 9 ++- crates/registry-caseworkctl/src/dev/tests.rs | 41 ++++++++++++++ .../registry-messagingctl/src/dev/docker.rs | 5 +- .../build-a-breg-production-candidate.mdx | 2 +- products/breg/DEV.md | 8 ++- release/OPERATIONS.md | 18 ++++++ release/docker/Dockerfile.breg | 2 +- release/docker/Dockerfile.breg-mcp | 2 +- release/docker/Dockerfile.breg-review | 2 +- release/docker/Dockerfile.casework | 2 +- release/docker/Dockerfile.discovery | 2 +- release/docker/Dockerfile.evidence | 2 +- release/docker/Dockerfile.evidence-oid4vci | 2 +- release/docker/Dockerfile.messaging | 2 +- release/docker/Dockerfile.registry-render | 2 +- release/docker/Dockerfile.scheduling | 2 +- release/scripts/check-debian13-images.py | 2 +- release/scripts/check-gates-inventory.py | 6 +- release/scripts/rehearse-upgrade.py | 2 +- release/scripts/test_check_gates_inventory.py | 8 +-- 24 files changed, 171 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c20eb125e..1f5e344ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,7 @@ jobs: # which fail rather than skip without their disposable database. services: postgres: - image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 env: POSTGRES_DB: dispatch POSTGRES_USER: dispatch @@ -760,7 +760,7 @@ jobs: timeout-minutes: 30 services: postgres: - image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 env: POSTGRES_DB: casework POSTGRES_USER: casework @@ -957,7 +957,7 @@ jobs: timeout-minutes: 30 services: postgres: - image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 env: POSTGRES_DB: scheduling POSTGRES_USER: scheduling @@ -1091,7 +1091,7 @@ jobs: timeout-minutes: 30 services: postgres: - image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 env: POSTGRES_DB: messaging POSTGRES_USER: messaging @@ -1167,7 +1167,7 @@ jobs: timeout-minutes: 20 services: mailpit: - image: axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d + image: ghcr.io/axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d options: >- --health-cmd "/mailpit readyz" --health-interval 5s @@ -2068,7 +2068,7 @@ jobs: timeout-minutes: 30 services: postgres: - image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 env: POSTGRES_DB: casework POSTGRES_USER: casework diff --git a/.github/workflows/nightly-rust-coverage.yml b/.github/workflows/nightly-rust-coverage.yml index d7ddacbb9e..3c7211a951 100644 --- a/.github/workflows/nightly-rust-coverage.yml +++ b/.github/workflows/nightly-rust-coverage.yml @@ -60,7 +60,7 @@ jobs: # ci.yml, which .github/scripts/test_ci_changes.py holds. services: postgres: - image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 env: POSTGRES_DB: dispatch POSTGRES_USER: dispatch diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index 22374ae45d..c3779bbe19 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -55,7 +55,11 @@ use zeroize::Zeroizing; const DATABASE_ID: &str = "breg-dev-database"; const MIGRATION_ROLE: &str = "breg_dev_migration"; const RUNTIME_ROLE: &str = "breg_dev_runtime"; +// Pull the digest-identical Docker Official Images mirror. Public help keeps +// the canonical upstream tag and digest, whose image identity is unchanged. const IMAGE: &str = + "public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; +const CANONICAL_IMAGE: &str = "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; const SPATIAL_IMAGE: &str = "postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6"; @@ -2159,9 +2163,18 @@ fn inspect(docker: &Path, state: &State) -> Result> { .into_iter() .next() .context("Docker returned no exact container")?; + verified_container(state, &container)?; + Ok(Some(container)) +} + +fn verified_container(state: &State, container: &Value) -> Result<()> { + let image = container["Config"]["Image"].as_str(); + let expected_image = state.database_image(); + let image_matches = image == Some(expected_image) + || (!state.requires_postgis && image == Some(CANONICAL_IMAGE)); if container["Name"] != format!("/{}", state.container_name()) || container["Config"]["Labels"][LABEL] != state.owner - || container["Config"]["Image"] != state.database_image() + || !image_matches || state .container_id .as_ref() @@ -2169,7 +2182,7 @@ fn inspect(docker: &Path, state: &State) -> Result> { { bail!("container ownership differs from retained local state; no resource was changed"); } - Ok(Some(container)) + Ok(()) } fn database(docker: &Path, state: &mut State) -> Result<()> { diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 9a4b6507e6..33fcdeed45 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -1643,6 +1643,53 @@ fn private_files_refuse_symlinks_hardlinks_and_public_modes() { assert!(private::read(&root.join("public"), 20).is_err()); } +#[test] +fn retained_database_inspection_accepts_only_verified_image_aliases() { + let (_temp, mut state, _, _) = fixture(); + state.container_id = Some("retained-container-id".to_owned()); + let container = json!({ + "Id": "retained-container-id", + "Name": format!("/{}", state.container_name()), + "Config": { "Labels": { (LABEL): state.owner }, "Image": IMAGE }, + }); + for image in [CANONICAL_IMAGE, IMAGE] { + let mut owned = container.clone(); + owned["Config"]["Image"] = json!(image); + verified_container(&state, &owned).unwrap(); + for (pointer, replacement) in [ + ("/Name", "different-container"), + ("/Id", "different-container-id"), + ( + "/Config/Labels/org.registrystack.bregctl.dev-owner", + "different-owner", + ), + ] { + let mut changed = owned.clone(); + *changed.pointer_mut(pointer).unwrap() = json!(replacement); + assert!(verified_container(&state, &changed).is_err()); + } + } + for image in [ + IMAGE.replace("67f41722", "07f41722"), + CANONICAL_IMAGE.replace("67f41722", "07f41722"), + format!("untrusted.example/{CANONICAL_IMAGE}"), + "postgres:17.11".to_owned(), + "public.ecr.aws/docker/library/postgres:17.11".to_owned(), + ] { + let mut changed = container.clone(); + changed["Config"]["Image"] = json!(image); + assert!(verified_container(&state, &changed).is_err()); + } + state.requires_postgis = true; + let mut spatial = container.clone(); + spatial["Config"]["Image"] = json!(SPATIAL_IMAGE); + verified_container(&state, &spatial).unwrap(); + for image in [CANONICAL_IMAGE, IMAGE] { + spatial["Config"]["Image"] = json!(image); + assert!(verified_container(&state, &spatial).is_err()); + } +} + #[test] fn state_refuses_changed_ownership_without_touching_paths() { let (_temp, state, clients, files) = fixture(); @@ -2020,7 +2067,7 @@ fn generated_postgres_leaf_verifies_against_its_distinct_ca() { /// The pinned image and the two deadlines are facts an operator checks before /// a first start. Hold the owning document, the command's own help text and -/// the supervisor's constants equal so they cannot drift apart. +/// the supervisor's canonical identity equal so they cannot drift apart. #[test] fn the_documented_image_and_deadlines_are_the_supervisors_own() { let document = fs::read_to_string( @@ -2041,8 +2088,12 @@ fn the_documented_image_and_deadlines_are_the_supervisors_own() { .and_then(|command| command.get_long_about()) .expect("dev start describes its own supervision") .to_string(); + assert!( + document.contains(IMAGE), + "the owning document names the pull source" + ); for fact in [ - IMAGE.to_owned(), + CANONICAL_IMAGE.to_owned(), SPATIAL_IMAGE.to_owned(), format!("{} seconds", CHILD_DEADLINE.as_secs()), format!("{} seconds", READY_DEADLINE.as_secs()), diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 4f375fd3f4..831dce7f55 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -47,7 +47,11 @@ use zeroize::Zeroizing; const DATABASE_NAME: &str = "casework_dev"; const MIGRATION_ROLE: &str = "casework_dev_migration"; const RUNTIME_ROLE: &str = "casework_dev_runtime"; +// Pull the digest-identical Docker Official Images mirror. Public help keeps +// the canonical upstream tag and digest, whose image identity is unchanged. const IMAGE: &str = + "public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; +const CANONICAL_IMAGE: &str = "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; const LABEL: &str = "org.registrystack.caseworkctl.dev-owner"; /// Loopback ports a first start takes when no flag names one. Each lies below @@ -3770,7 +3774,10 @@ fn inspect_with_termination( fn verified_container(state: &State, container: &Value) -> Result<()> { if container["Name"] != format!("/{}", state.container_name()) || container["Config"]["Labels"][LABEL] != state.owner - || container["Config"]["Image"] != IMAGE + || !matches!( + container["Config"]["Image"].as_str(), + Some(IMAGE | CANONICAL_IMAGE) + ) || state .container_id .as_ref() diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index e7487490c6..aa717f167f 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -1704,6 +1704,47 @@ fn volume_removal_requires_the_retained_owner_label() { /// A volume is owned only through its label: one that carries no label is /// refused like one labeled for another owner. +#[test] +fn retained_database_inspection_accepts_only_verified_image_aliases() { + let root = crate::canonical_tempdir(); + let project = standalone(root.path()); + let mut state = session(&project); + state.container_id = Some("retained-container-id".to_owned()); + let container = json!({ + "Id": "retained-container-id", + "Name": format!("/{}", state.container_name()), + "Config": { "Labels": { (LABEL): state.owner }, "Image": IMAGE }, + }); + for image in [CANONICAL_IMAGE, IMAGE] { + let mut owned = container.clone(); + owned["Config"]["Image"] = json!(image); + verified_container(&state, &owned).unwrap(); + for (pointer, replacement) in [ + ("/Name", "different-container"), + ("/Id", "different-container-id"), + ( + "/Config/Labels/org.registrystack.caseworkctl.dev-owner", + "different-owner", + ), + ] { + let mut changed = owned.clone(); + *changed.pointer_mut(pointer).unwrap() = json!(replacement); + assert!(verified_container(&state, &changed).is_err()); + } + } + for image in [ + IMAGE.replace("67f41722", "07f41722"), + CANONICAL_IMAGE.replace("67f41722", "07f41722"), + format!("untrusted.example/{CANONICAL_IMAGE}"), + "postgres:17.11".to_owned(), + "public.ecr.aws/docker/library/postgres:17.11".to_owned(), + ] { + let mut changed = container.clone(); + changed["Config"]["Image"] = json!(image); + assert!(verified_container(&state, &changed).is_err()); + } +} + #[test] fn a_volume_without_the_owner_label_is_never_removed() { let root = crate::canonical_tempdir(); diff --git a/crates/registry-messagingctl/src/dev/docker.rs b/crates/registry-messagingctl/src/dev/docker.rs index c3b8972120..9703c2db5f 100644 --- a/crates/registry-messagingctl/src/dev/docker.rs +++ b/crates/registry-messagingctl/src/dev/docker.rs @@ -18,11 +18,12 @@ use super::{failed, interrupted, DevResult, DATABASE_NAME, MIGRATION_ROLE, RUNTI /// The pinned PostgreSQL image, the same one Casework's development /// session runs. +// Pull digest-identical upstream mirrors; public help names the canonical images. pub(super) const POSTGRES_IMAGE: &str = - "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; + "public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; /// The pinned Mailpit image. pub(super) const MAILPIT_IMAGE: &str = - "axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d"; + "ghcr.io/axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d"; /// The label naming the session that owns a container. pub(super) const OWNER_LABEL: &str = "org.registrystack.messagingctl.dev-owner"; diff --git a/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx b/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx index f93581d366..515dbb9daf 100644 --- a/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx +++ b/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx @@ -150,7 +150,7 @@ openssl req -new -nodes -newkey rsa:2048 -subj '/CN=localhost' -keyout tutorial- printf 'subjectAltName=DNS:localhost\n' > tutorial-work/candidate/postgres/server.ext openssl x509 -req -sha256 -days 2 -in tutorial-work/candidate/postgres/server.csr -CA tutorial-work/candidate/postgres/ca.pem -CAkey tutorial-work/candidate/postgres/ca.key -CAcreateserial -extfile tutorial-work/candidate/postgres/server.ext -out tutorial-work/candidate/postgres/server.crt printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 24)" > tutorial-work/candidate/postgres/postgres.env -docker run --detach --name breg-candidate-postgres --env-file tutorial-work/candidate/postgres/postgres.env --publish 127.0.0.1:5433:5432 postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 +docker run --detach --name breg-candidate-postgres --env-file tutorial-work/candidate/postgres/postgres.env --publish 127.0.0.1:5433:5432 public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 until docker exec breg-candidate-postgres pg_isready -h localhost -U postgres >/dev/null; do sleep 1; done docker cp tutorial-work/candidate/postgres/server.crt breg-candidate-postgres:/var/lib/postgresql/data/server.crt docker cp tutorial-work/candidate/postgres/server.key breg-candidate-postgres:/var/lib/postgresql/data/server.key diff --git a/products/breg/DEV.md b/products/breg/DEV.md index 1afaca8cd3..ed6798c728 100644 --- a/products/breg/DEV.md +++ b/products/breg/DEV.md @@ -37,9 +37,13 @@ explicit seed creation succeed. Default loopback ports are BReg `8090`, issuer original ports and clients-file location. Conflicting ports are refused. The database runs the pinned image -`postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675`, +`public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675`, or `postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6` -when the compiled schema requires PostGIS. The selection is retained with the +when the compiled schema requires PostGIS. Public help names the canonical +`postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675` +identity; the implementation pulls the byte-identical Docker Official Images +ECR mirror above. Retained owned containers accept either exact PostgreSQL +reference at that digest. The selection is retained with the owned database. Spatial setup grants the migration role permission to SET the no-login bbox owner; the runtime role never receives that membership. Each supervised diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 25da1058c0..cf80c226da 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -316,6 +316,24 @@ upstream manifest digest byte for byte, so it adds no org-controlled build input; every other GHCR reference stays banned so the rehearsal cannot read published release artifacts. +### Pull pinned prerequisite images + +Maintained CI services and local dev tools use the Docker Official Images +published through `public.ecr.aws/docker/library/postgres`. Debian recipe roots +use `public.ecr.aws/docker/library/debian`, and Mailpit uses its upstream +`ghcr.io/axllent/mailpit` mirror. Each reference retains the exact upstream +manifest digest; the registry endpoint changes, not the selected image bytes. +Public CLI help retains the canonical `postgres:17.11@sha256:…` and +`axllent/mailpit:v1.31.2@sha256:…` identities; the implementations pull the +corresponding mirror URI at the same full digest. + +[Docker Official Images on ECR Public](https://aws.amazon.com/blogs/containers/docker-official-images-now-available-on-amazon-elastic-container-registry-public/) +and [Mailpit's Docker installation guide](https://mailpit.axllent.org/docs/install/docker/) +document these upstream alternatives. When updating a pin, verify the returned +manifest's SHA256 at the selected endpoint before changing its immutable digest. +The strict recipe and gate checks continue to require the recorded repository +and digest. No registry account is required for these public image pulls. + ### Provision client registries Registry Stack v0.22.0 through v0.26.0 promoted separate Evidence, Relay, and, diff --git a/release/docker/Dockerfile.breg b/release/docker/Dockerfile.breg index 6205963f54..9f1f9ef4c1 100644 --- a/release/docker/Dockerfile.breg +++ b/release/docker/Dockerfile.breg @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.breg-mcp b/release/docker/Dockerfile.breg-mcp index 59fbe8f31f..ca83f1ac69 100644 --- a/release/docker/Dockerfile.breg-mcp +++ b/release/docker/Dockerfile.breg-mcp @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.breg-review b/release/docker/Dockerfile.breg-review index 32d6a9190a..2f5b3bd67f 100644 --- a/release/docker/Dockerfile.breg-review +++ b/release/docker/Dockerfile.breg-review @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.casework b/release/docker/Dockerfile.casework index efe6a6a735..84d38c832d 100644 --- a/release/docker/Dockerfile.casework +++ b/release/docker/Dockerfile.casework @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.discovery b/release/docker/Dockerfile.discovery index bc2bd37653..c4a416a152 100644 --- a/release/docker/Dockerfile.discovery +++ b/release/docker/Dockerfile.discovery @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.evidence b/release/docker/Dockerfile.evidence index 28b212bb56..35c60c90ad 100644 --- a/release/docker/Dockerfile.evidence +++ b/release/docker/Dockerfile.evidence @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.evidence-oid4vci b/release/docker/Dockerfile.evidence-oid4vci index d037f52633..7bc335b29c 100644 --- a/release/docker/Dockerfile.evidence-oid4vci +++ b/release/docker/Dockerfile.evidence-oid4vci @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.messaging b/release/docker/Dockerfile.messaging index bc0eefa330..04299e5728 100644 --- a/release/docker/Dockerfile.messaging +++ b/release/docker/Dockerfile.messaging @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.registry-render b/release/docker/Dockerfile.registry-render index 4b73549a87..e985b277a3 100644 --- a/release/docker/Dockerfile.registry-render +++ b/release/docker/Dockerfile.registry-render @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/docker/Dockerfile.scheduling b/release/docker/Dockerfile.scheduling index 4a1d1751c4..e280fac79b 100644 --- a/release/docker/Dockerfile.scheduling +++ b/release/docker/Dockerfile.scheduling @@ -2,7 +2,7 @@ ARG SOURCE_DATE_EPOCH=0 -FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root +FROM public.ecr.aws/docker/library/debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a AS runtime-root ARG SOURCE_DATE_EPOCH ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index 7fea4e7677..b9e0487c8f 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -83,7 +83,7 @@ '--date="@${SOURCE_DATE_EPOCH}" {} +' ) DEBIAN_PREPARATION = ( - "debian:trixie-slim@sha256:" + "public.ecr.aws/docker/library/debian:trixie-slim@sha256:" "a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a" ) # This index carries libssl3t64 3.5.7-1~deb13u2 on both supported Linux diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 41515592ea..6a7d190b84 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -273,7 +273,7 @@ # pin names the job it belongs to. A bare digest would be satisfied by # the other product's copy and would stop reporting its own removal. "Casework PostgreSQL 17 image pin", - "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + "image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" " env:\n" " POSTGRES_DB: casework", ), @@ -399,7 +399,7 @@ ), ( "Scheduling PostgreSQL 17 image pin", - "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + "image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" " env:\n" " POSTGRES_DB: scheduling", ), @@ -465,7 +465,7 @@ ), ( "Messaging PostgreSQL 17 image pin", - "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + "image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" " env:\n" " POSTGRES_DB: messaging", ), diff --git a/release/scripts/rehearse-upgrade.py b/release/scripts/rehearse-upgrade.py index efaa645cb1..2c361b248c 100644 --- a/release/scripts/rehearse-upgrade.py +++ b/release/scripts/rehearse-upgrade.py @@ -96,7 +96,7 @@ # Messaging state to upgrade. PRODUCT_PLATFORMS = {"messaging": ("linux-amd64",)} POSTGRES_IMAGE = ( - "postgres:17.11@sha256:" + "public.ecr.aws/docker/library/postgres:17.11@sha256:" "67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675" ) # The server key must be owned by postgres with mode 0600, which a bind mount diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index df498843d6..c62f9450fb 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -825,8 +825,8 @@ def test_missing_casework_workflow_gates_are_reported(self) -> None: "Casework PostgreSQL gate", ), ( - "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", - "postgres:17.11", + "public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", + "public.ecr.aws/docker/library/postgres:17.11", "Casework PostgreSQL 17 image pin", ), ( @@ -983,10 +983,10 @@ def test_missing_scheduling_workflow_gates_are_reported(self) -> None: "Scheduling PostgreSQL gate", ), ( - "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + "image: public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" " env:\n" " POSTGRES_DB: scheduling", - "image: postgres:17.11\n env:\n POSTGRES_DB: scheduling", + "image: public.ecr.aws/docker/library/postgres:17.11\n env:\n POSTGRES_DB: scheduling", "Scheduling PostgreSQL 17 image pin", ), ( From 882c030ee77e9e40e7ef724d95539f16b8456769 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 10:12:03 +0700 Subject: [PATCH 04/11] refactor(platform,breg): share the exact task-grant status transport Move the task-grant status registry and the exact-status request into registry-platform-oidc, and define the taskGrantStatus entry once as the TaskGrantStatusConfig shared block in registry-platform-config, so every resource server checks a grant through one bounded transport configured by one block. BReg keeps its persisted binding and BReg-only bounds validation and now calls the shared transport. registry-platform-httputil exposes the private_key_jwt identity check and requested-scope validation the transport needs. Signed-off-by: Jeremi Joslin --- Cargo.lock | 6 + crates/registry-breg/src/runtime_config.rs | 24 +- crates/registry-breg/src/task_grant.rs | 127 ++---- crates/registry-breg/src/task_grant/config.rs | 177 ++++---- crates/registry-breg/src/task_grant/tests.rs | 3 +- crates/registry-breg/tests/runtime_config.rs | 121 ++++++ crates/registry-bregctl/tests/cli.rs | 1 + crates/registry-platform-config/README.md | 4 + crates/registry-platform-config/src/blocks.rs | 63 ++- .../src/blocks_tests.rs | 77 ++++ crates/registry-platform-config/src/lib.rs | 7 +- crates/registry-platform-config/src/schema.rs | 2 + crates/registry-platform-httputil/README.md | 4 + .../src/client/mod.rs | 11 +- .../src/client/private_key_jwt.rs | 223 +++++++--- crates/registry-platform-httputil/src/lib.rs | 12 +- crates/registry-platform-oidc/Cargo.toml | 6 + crates/registry-platform-oidc/README.md | 43 +- crates/registry-platform-oidc/src/lib.rs | 1 + .../registry-platform-oidc/src/task_grant.rs | 275 +++++++++++++ .../src/task_grant/config.rs | 380 ++++++++++++++++++ .../src/task_grant/tests.rs | 320 +++++++++++++++ products/breg/TASK_GRANTS.md | 48 ++- .../generated/runtime/runtime.schema.json | 28 +- .../identifiers/generated/catalog.v1.json | 2 +- products/platform/CONFIG-CONVENTIONS.md | 5 +- products/platform/fuzz/Cargo.lock | 21 + .../runtime-config-blocks.schema.json | 54 +++ .../scripts/check-config-conformance.py | 1 + .../scripts/check-config-conventions.py | 2 +- 30 files changed, 1747 insertions(+), 301 deletions(-) create mode 100644 crates/registry-platform-oidc/src/task_grant.rs create mode 100644 crates/registry-platform-oidc/src/task_grant/config.rs create mode 100644 crates/registry-platform-oidc/src/task_grant/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 69ab2ca6b5..8581ffd1ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6129,13 +6129,19 @@ version = "0.41.0" dependencies = [ "axum", "base64 0.23.1", + "chrono", "jsonwebtoken", + "registry-platform-config", + "registry-platform-crypto", "registry-platform-httputil", + "registry-platform-yaml", "reqwest 0.12.28", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "tokio", + "uuid", ] [[package]] diff --git a/crates/registry-breg/src/runtime_config.rs b/crates/registry-breg/src/runtime_config.rs index 2906effcb3..8f9ceed257 100644 --- a/crates/registry-breg/src/runtime_config.rs +++ b/crates/registry-breg/src/runtime_config.rs @@ -17,7 +17,8 @@ use registry_platform_audit::{AuditDestination, AuditDestinationKind, AuditProfi use registry_platform_config::{ AuditKeyConfig, JwksSource, ListenerBind, OidcIssuerConfig, PackageConfig as SharedPackageConfig, RemovedKey, RuntimeConfigLoader, RuntimeEnvelope, - SecretError, SecretReference, SecretResolver, REMOVED_OIDC_JWKS_SOURCE_KIND, + SecretError, SecretReference, SecretResolver, TaskGrantStatusConfig, + REMOVED_OIDC_JWKS_SOURCE_KIND, }; use registry_platform_crypto::{parse_json_strict, PublicJwk, SigningAlgorithm}; #[cfg(feature = "schema")] @@ -264,6 +265,8 @@ pub enum RuntimeConfigError { Reader(ReaderRefusal), #[error("runtime configuration contains an invalid deployment binding")] InvalidBinding, + #[error("runtime configuration taskGrantStatus must contain at most 32 unique absolute source issuers; configure safe service/token URLs, non-empty client and assertion audience, absolute resource identifiers, one status scope token, and a status path with one {{grantId}} segment")] + InvalidTaskGrantStatus, #[error( "runtime configuration identity.instanceId must start with a lowercase letter and hold at most 64 lowercase letters, digits, `-`, or `_`; set identity.instanceId to such a value" )] @@ -324,6 +327,7 @@ impl RuntimeConfigError { match self { Self::Reader(refusal) => &refusal.deciding_diagnostic().code, Self::InvalidBinding => "breg.runtime.invalid-binding", + Self::InvalidTaskGrantStatus => "breg.runtime.invalid-task-grant-status", Self::InvalidInstanceId => "breg.runtime.invalid-instance-id", Self::EnvironmentIdentityConflict => "breg.runtime.environment-identity-conflict", Self::InvalidListener => "breg.runtime.invalid-listener", @@ -356,6 +360,7 @@ impl RuntimeConfigError { match self { Self::Reader(refusal) => &refusal.deciding_diagnostic().path, Self::InvalidBinding | Self::Secret => "", + Self::InvalidTaskGrantStatus => "/taskGrantStatus", Self::InvalidInstanceId => "/identity/instanceId", Self::EnvironmentIdentityConflict => "/identity/databaseInitializationEnvironment", Self::InvalidListener => "/listener", @@ -387,6 +392,7 @@ impl RuntimeConfigError { fn suggested_action(&self) -> &'static str { match self { Self::Reader(_) => "", + Self::InvalidTaskGrantStatus => "Configure at most 32 distinct taskGrantStatus source issuers, safe service/token URLs, a client and assertion audience, absolute resource identifiers, one statusScope token, and a statusPath that starts with / and holds exactly one {grantId} segment.", Self::InvalidBinding => { "Give every identity value as trimmed text without control characters, and declare in the runtime file, with a valid endpoint and credential, every task grant status source, Evidence provider, review authority, and review executor the package relies on." } @@ -687,10 +693,15 @@ fn runtime_stand_in(pointer: &str) -> &'static str { ["audit", "path"] | ["fieldEncryption", "provider", "unixSocketPath"] | ["eventDestinations", _, "path"] => "/deferred", + ["taskGrantStatus", _, "statusPath"] => "/{grantId}", ["attachmentStorage" | "attachmentVerification", "endpoint"] | ["eventDestinations", _, "origin"] | ["evidenceProviders", _, "baseUrl"] - | ["taskGrantStatus", _, "baseUrl" | "sourceIssuer"] + | ["taskGrantStatus", _, "baseUrl" + | "sourceIssuer" + | "tokenEndpoint" + | "clientAssertionAudience" + | "caseworkResource"] | ["reviewAuthorities" | "reviewExecutors", _, "endpoint"] | ["reviewAuthorities" | "reviewExecutors", _, "privateKeyJwt", "tokenEndpoint" | "assertionAudience" | "resource"] => { "https://deferred.invalid/" @@ -725,7 +736,7 @@ pub struct RuntimeConfig { field_encryption: crate::field_encryption::FieldEncryptionConfig, package: PackageConfig, authentication: AuthenticationConfig, - task_grant_status: Vec, + task_grant_status: Vec, audit: AuditConfig, cursor: CursorConfig, event_destinations: EventDestinationConfigs, @@ -759,6 +770,11 @@ impl RuntimeConfig { .map_err(|_| RuntimeConfigError::InvalidFieldEncryption)?; let package = PackageConfig::from_raw(raw.package)?; let authentication = AuthenticationConfig::from_raw(raw.authentication)?; + crate::task_grant::TaskGrantStatusRegistry::validate_configuration( + &raw.task_grant_status, + &authentication.oidc.audience, + ) + .map_err(|_| RuntimeConfigError::InvalidTaskGrantStatus)?; let audit = AuditConfig::from_raw(raw.audit)?; let cursor = CursorConfig::from_raw(raw.cursor)?; let event_destinations = EventDestinationConfigs::from_raw(raw.event_destinations) @@ -2868,7 +2884,7 @@ struct RawRuntimeConfig { package: RawPackageConfig, authentication: RawAuthenticationConfig, #[serde(default)] - task_grant_status: Vec, + task_grant_status: Vec, audit: RawAuditConfig, cursor: RawCursorConfig, #[serde(default, deserialize_with = "local_id_map")] diff --git a/crates/registry-breg/src/task_grant.rs b/crates/registry-breg/src/task_grant.rs index 4b75c613d1..f088ea73d7 100644 --- a/crates/registry-breg/src/task_grant.rs +++ b/crates/registry-breg/src/task_grant.rs @@ -1,17 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 //! Immutable task authority retained separately from a later human reviewer. -use registry_platform_httputil::{ - client::{build_client, OutboundOptions, PrivateKeyJwt, ServiceBaseUrl, TokenProvider}, - read_bounded, validate_response_headers, -}; +use registry_platform_httputil::client::PrivateKeyJwt; +pub use registry_platform_oidc::task_grant::TaskGrantError; use registry_platform_oidc::{GrantBounds, GrantClaims}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{collections::BTreeMap, fmt, sync::Arc, time::Duration}; -use thiserror::Error; +use std::{collections::BTreeMap, fmt, sync::Arc}; use uuid::Uuid; mod config; -pub use config::{TaskGrantStatusConfig, TaskGrantStatusRegistry}; +pub use config::TaskGrantStatusRegistry; +pub use registry_platform_config::TaskGrantStatusConfig; #[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -112,16 +110,6 @@ impl TaskGrantBinding { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] -pub enum TaskGrantError { - #[error("task authority was refused")] - Refused, - #[error("task authority status is unavailable")] - Unavailable, - #[error("task authority status configuration is invalid")] - Configuration, -} - pub trait TaskGrantStatusChecker: Send + Sync { /// Each invocation must perform a fresh check. Never cache positive status. fn check<'a>( @@ -130,13 +118,8 @@ pub trait TaskGrantStatusChecker: Send + Sync { ) -> std::pin::Pin> + Send + 'a>>; } -pub struct TaskGrantStatusClient { - source_issuer: String, - resource: String, - base: ServiceBaseUrl, - http: reqwest::Client, - token: Arc, -} +/// BReg retains its product-specific binding policy around the shared transport. +pub struct TaskGrantStatusClient(registry_platform_oidc::task_grant::TaskGrantStatusClient); impl fmt::Debug for TaskGrantStatusClient { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("TaskGrantStatusClient()") @@ -150,97 +133,35 @@ impl TaskGrantStatusClient { token: Arc, trusted_roots: Option<&[u8]>, ) -> Result { - if !registry_platform_httputil::valid_resource_uri(&source_issuer) - || !registry_platform_httputil::valid_resource_uri(&resource) - { - return Err(TaskGrantError::Configuration); - } - let base = ServiceBaseUrl::new(base).map_err(|_| TaskGrantError::Configuration)?; - let http = build_client(OutboundOptions { - request_timeout: Duration::from_secs(5), - connect_timeout: Duration::from_secs(3), - user_agent: Some("registry-breg-task-status"), - trusted_root_certificates: trusted_roots, - }) - .map_err(|_| TaskGrantError::Configuration)?; - Ok(Self { + registry_platform_oidc::task_grant::TaskGrantStatusClient::new( source_issuer, resource, base, - http, + registry_platform_config::DEFAULT_TASK_GRANT_STATUS_PATH, token, - }) + trusted_roots, + ) + .map(Self) } } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct StatusResponse { - active: bool, - #[serde(default)] - grant: Option, -} impl TaskGrantStatusChecker for TaskGrantStatusClient { fn check<'a>( &'a self, binding: &'a TaskGrantBinding, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - binding.validate()?; - if !binding.is_current() - || binding.source_issuer != self.source_issuer - || binding.resource != self.resource - { - return Err(TaskGrantError::Refused); - } - let token = self - .token - .bearer_token() - .await - .map_err(|_| TaskGrantError::Unavailable)?; - let url = registry_platform_httputil::url::append_path_segments( - self.base.as_url(), - &["v1", "task-grants", &binding.grant_id, "status"], - ) - .map_err(|_| TaskGrantError::Configuration)?; - let response = self - .http - .get(url) - .header( - reqwest::header::AUTHORIZATION, - token.authorization_header_value(), - ) - .header(reqwest::header::ACCEPT, "application/json") - .send() - .await - .map_err(|_| TaskGrantError::Unavailable)?; - validate_response_headers(response.headers()) - .map_err(|_| TaskGrantError::Unavailable)?; - if matches!(response.status().as_u16(), 401 | 403 | 404) { - return Err(TaskGrantError::Refused); - } - if response.status() != reqwest::StatusCode::OK - || response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(';').next()) - .is_none_or(|value| !value.trim().eq_ignore_ascii_case("application/json")) - { - return Err(TaskGrantError::Unavailable); - } - let bytes = read_bounded(response, 64 * 1024) - .await - .map_err(|_| TaskGrantError::Unavailable)?; - let json = registry_platform_crypto::parse_json_strict(&bytes) - .map_err(|_| TaskGrantError::Unavailable)?; - let status: StatusResponse = - serde_json::from_value(json).map_err(|_| TaskGrantError::Unavailable)?; - if !status.active || status.grant.as_ref() != Some(binding) || !binding.is_current() { - return Err(TaskGrantError::Refused); - } - Ok(()) - }) + Box::pin(async move { self.0.check(&binding.status_binding()?).await }) + } +} +impl TaskGrantBinding { + fn status_binding( + &self, + ) -> Result { + self.validate()?; + // Retain the existing persisted BReg shape and product-specific bounds + // check; the shared transport consumes the identical closed wire model. + serde_json::from_value(serde_json::to_value(self).map_err(|_| TaskGrantError::Refused)?) + .map_err(|_| TaskGrantError::Refused) } } diff --git a/crates/registry-breg/src/task_grant/config.rs b/crates/registry-breg/src/task_grant/config.rs index 3a4704ea23..58803359df 100644 --- a/crates/registry-breg/src/task_grant/config.rs +++ b/crates/registry-breg/src/task_grant/config.rs @@ -1,112 +1,49 @@ // SPDX-License-Identifier: Apache-2.0 -//! Operator-pinned Casework endpoints and BREG's own status credentials. +//! Operator-pinned Casework endpoints and BREG's own status credentials, +//! checked by the shared exact-status transport. use super::*; -use registry_platform_config::{SecretReference, SecretResolver}; -use registry_platform_httputil::client::PrivateKeyJwtConfig; -use registry_platform_yaml::Url; +use registry_platform_config::{SecretResolver, TaskGrantStatusConfig}; +use registry_platform_oidc::task_grant::TaskGrantStatusEndpoint; -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[derive(Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TaskGrantStatusConfig { - /// The Casework task authority's issuer, exactly as Casework states it. - /// `http` and `https` are both accepted, since the value is compared, not - /// fetched. - pub source_issuer: Url, - /// The Casework base URL the status check calls. It is `https`; `http` - /// is accepted only for a loopback host, for local development. - pub base_url: Url, - pub token_endpoint: String, - pub client_assertion_audience: String, - pub client_id: String, - pub private_key_ref: SecretReference, - pub casework_resource: String, - /// PEM CA bundle for Casework and its token endpoint. Omitted, the - /// platform's trusted roots verify them. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ca_bundle_ref: Option, -} - -pub struct TaskGrantStatusRegistry { - clients: BTreeMap, +fn endpoints(configs: &[TaskGrantStatusConfig]) -> Vec { + configs.iter().map(TaskGrantStatusEndpoint::from).collect() } +pub struct TaskGrantStatusRegistry(registry_platform_oidc::task_grant::TaskGrantStatusRegistry); impl TaskGrantStatusRegistry { pub fn contains(&self, source_issuer: &str) -> bool { - self.clients.contains_key(source_issuer) + self.0.contains(source_issuer) + } + /// Check nonsecret client structure without resolving credential material. + pub fn validate_configuration( + configs: &[TaskGrantStatusConfig], + resource: &str, + ) -> Result<(), TaskGrantError> { + registry_platform_oidc::task_grant::TaskGrantStatusRegistry::validate_configuration( + &endpoints(configs), + resource, + ) } - pub fn activate( configs: &[TaskGrantStatusConfig], resource: &str, secrets: &SecretResolver, ) -> Result { - if configs.len() > 32 { - return Err(TaskGrantError::Configuration); - } - let mut clients = BTreeMap::new(); - for config in configs { - let secret = secrets - .resolve_reference(&config.private_key_ref) - .map_err(|_| TaskGrantError::Configuration)?; - let key = registry_platform_crypto::parse_json_strict(secret.expose_secret()) - .map_err(|_| TaskGrantError::Configuration)?; - let key = serde_json::from_value(key).map_err(|_| TaskGrantError::Configuration)?; - let ca = config - .ca_bundle_ref - .as_ref() - .map(|reference| secrets.resolve_reference(reference)) - .transpose() - .map_err(|_| TaskGrantError::Configuration)?; - let mut token = PrivateKeyJwtConfig::new( - config - .token_endpoint - .parse() - .map_err(|_| TaskGrantError::Configuration)?, - &config.client_id, - key, - ) - .with_audience(&config.client_assertion_audience) - .with_resource(&config.casework_resource) - .with_scopes(["casework:grants:status"]); - if let Some(ca) = &ca { - token = token.with_trusted_root_certificates(ca.expose_secret().to_vec()); - } - let client = TaskGrantStatusClient::new( - config.source_issuer.as_str().to_owned(), - resource.to_owned(), - config - .base_url - .as_str() - .parse() - .map_err(|_| TaskGrantError::Configuration)?, - Arc::new(PrivateKeyJwt::new(token).map_err(|_| TaskGrantError::Configuration)?), - ca.as_ref().map(|value| value.expose_secret()), - )?; - if clients - .insert(config.source_issuer.as_str().to_owned(), client) - .is_some() - { - return Err(TaskGrantError::Configuration); - } - } - Ok(Self { clients }) + registry_platform_oidc::task_grant::TaskGrantStatusRegistry::activate( + &endpoints(configs), + resource, + secrets, + ) + .map(Self) } } - impl TaskGrantStatusChecker for TaskGrantStatusRegistry { fn check<'a>( &'a self, binding: &'a TaskGrantBinding, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - let client = self - .clients - .get(&binding.source_issuer) - .ok_or(TaskGrantError::Refused)?; - client.check(binding).await - }) + Box::pin(async move { self.0.check(&binding.status_binding()?).await }) } } @@ -114,8 +51,59 @@ impl TaskGrantStatusChecker for TaskGrantStatusRegistry { mod tests { use super::*; use registry_platform_config::SecretProvider; + use registry_platform_yaml::{ExternalId, Url}; use std::os::unix::fs::PermissionsExt; + fn status_config(extra: serde_json::Value) -> TaskGrantStatusConfig { + let mut raw = serde_json::json!({ + "sourceIssuer":"https://casework.test", + "baseUrl":"https://casework.test", + "tokenEndpoint":"https://identity.test/oauth2/token", + "clientAssertionAudience":"https://identity.test", + "clientId":"breg-status", + "privateKeyRef":"secret:file/status-key", + "caseworkResource":"urn:casework:test" + }); + raw.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::from_value(raw).unwrap() + } + + #[test] + fn status_scope_and_path_default_to_casework_and_accept_operator_values() { + let defaults = status_config(serde_json::json!({})); + assert_eq!(defaults.status_scope.as_str(), "casework:grants:status"); + assert_eq!(defaults.status_path, "/v1/task-grants/{grantId}/status"); + let endpoint = TaskGrantStatusEndpoint::from(&defaults); + assert_eq!(endpoint.status_resource.as_str(), "urn:casework:test"); + assert_eq!(endpoint.status_scope.as_str(), "casework:grants:status"); + assert_eq!(endpoint.status_path, "/v1/task-grants/{grantId}/status"); + + let configured = status_config(serde_json::json!({ + "statusScope":"grants:status", + "statusPath":"/api/grants/{grantId}" + })); + let endpoint = TaskGrantStatusEndpoint::from(&configured); + assert_eq!(endpoint.status_scope.as_str(), "grants:status"); + assert_eq!(endpoint.status_path, "/api/grants/{grantId}"); + TaskGrantStatusRegistry::validate_configuration(&[configured], "urn:breg:test").unwrap(); + + for extra in [ + serde_json::json!({"statusPath":"/api/grants"}), + serde_json::json!({"statusScope":"grants:status other"}), + ] { + assert!( + TaskGrantStatusRegistry::validate_configuration( + &[status_config(extra.clone())], + "urn:breg:test" + ) + .is_err(), + "{extra}" + ); + } + } + #[test] fn configured_status_clients_pin_source_issuer_and_refuse_unsafe_or_duplicate_bindings() { let root = tempfile::tempdir().unwrap(); @@ -141,16 +129,7 @@ mod tests { std::fs::write(&path, serde_json::to_vec(&private).unwrap()).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); let secrets = SecretResolver::new([SecretProvider::File], root.path()).unwrap(); - let config = TaskGrantStatusConfig { - source_issuer: Url::new("https://casework.test").unwrap(), - base_url: Url::new("https://casework.test").unwrap(), - token_endpoint: "https://identity.test/oauth2/token".into(), - client_assertion_audience: "https://identity.test".into(), - client_id: "breg-status".into(), - private_key_ref: SecretReference::parse("secret:file/status-key").unwrap(), - casework_resource: "urn:casework:test".into(), - ca_bundle_ref: None, - }; + let config = status_config(serde_json::json!({})); let status = TaskGrantStatusRegistry::activate( std::slice::from_ref(&config), "urn:breg:test", @@ -177,7 +156,7 @@ mod tests { ); } let mut invalid = config.clone(); - invalid.casework_resource = "not-an-absolute-resource".into(); + invalid.casework_resource = ExternalId::new("not-an-absolute-resource").unwrap(); assert!(TaskGrantStatusRegistry::activate(&[invalid], "urn:breg:test", &secrets).is_err()); let mut retired = serde_json::to_value(config.clone()).unwrap(); retired["authority"] = serde_json::json!("casework-v1"); diff --git a/crates/registry-breg/src/task_grant/tests.rs b/crates/registry-breg/src/task_grant/tests.rs index 5dbc230427..4f5ec26837 100644 --- a/crates/registry-breg/src/task_grant/tests.rs +++ b/crates/registry-breg/src/task_grant/tests.rs @@ -111,7 +111,8 @@ async fn activated_status_client_uses_the_configured_assertion_audience_and_chec })) .unwrap(); let mut wrong = config.clone(); - wrong.client_assertion_audience = format!("http://{address}/token"); + wrong.client_assertion_audience = + registry_platform_yaml::ExternalId::new(format!("http://{address}/token")).unwrap(); let wrong = TaskGrantStatusRegistry::activate(&[wrong], "urn:breg:test", &secrets).unwrap(); assert_eq!( wrong.check(&binding).await, diff --git a/crates/registry-breg/tests/runtime_config.rs b/crates/registry-breg/tests/runtime_config.rs index 2ef433c677..9a86c382d6 100644 --- a/crates/registry-breg/tests/runtime_config.rs +++ b/crates/registry-breg/tests/runtime_config.rs @@ -1319,6 +1319,57 @@ fn two_unknown_keys_inside_each_shared_block_are_all_reported_with_positions() { assert!(!human.contains("canary-"), "{human}"); } +/// A `taskGrantStatus` entry is the platform's shared status block, so every +/// unknown key inside one is reported at its own line and column, and the +/// refusal never repeats the value. +#[test] +fn unknown_keys_inside_a_task_grant_status_entry_are_reported_with_positions() { + let fixture = RuntimeFixture::new(); + let raw = format!( + "{}\ntaskGrantStatus:\n - sourceIssuer: urn:casework:source\n \ + baseUrl: https://status.example\n \ + tokenEndpoint: https://identity.example/token\n \ + clientAssertionAudience: urn:identity:test\n \ + clientId: breg-task-status\n \ + privateKeyRef: secret:file/task-status-key\n \ + caseworkResource: urn:casework:test\n \ + statusScopes: canary-a\n \ + scopes: canary-b\n", + valid_runtime(&fixture.secret_root, &fixture.package_root) + ); + let error = parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("unknown keys inside a status entry are refused"); + reader_refusal(&error); + let line_of = |key: &str| { + raw.lines() + .position(|line| line.trim_start().starts_with(&format!("{key}: canary-"))) + .map(|index| index + 1) + .expect("the fixture holds the key") + }; + let reported: Vec<(String, String, Option, Option)> = error + .diagnostics(None) + .into_iter() + .map(|diagnostic| { + let source = diagnostic.source.expect("a reader diagnostic has a source"); + (diagnostic.code, diagnostic.path, source.line, source.column) + }) + .collect(); + for (path, key) in [ + ("/taskGrantStatus/0/statusScopes", "statusScopes"), + ("/taskGrantStatus/0/scopes", "scopes"), + ] { + let expected = ( + "config.unknown-key".to_owned(), + path.to_owned(), + Some(line_of(key)), + Some(5), + ); + assert!(reported.contains(&expected), "{expected:?} in {reported:?}"); + } + let human = error.render_human(None); + assert!(!human.contains("canary-"), "{human}"); +} + /// Substitution runs before the document is read, so an unknown key that /// holds a set variable is refused as a document error naming the key, and /// the refusal never repeats the substituted value. @@ -4537,3 +4588,73 @@ fn an_audit_key_that_is_not_a_secret_reference_is_refused_at_the_reference() { ); } } + +#[test] +fn task_status_structural_bindings_are_refused_before_runtime_activation() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime(&fixture.secret_root, &fixture.package_root); + let status = json!({ + "sourceIssuer":"urn:casework:source", + "baseUrl":"https://unreachable-status.invalid", + "tokenEndpoint":"http://127.0.0.1:1/token", + "clientAssertionAudience":"urn:identity:test", + "clientId":"breg-task-status", + "privateKeyRef":"secret:file/missing-task-status-key", + "caseworkResource":"urn:casework:test", + "caBundleRef":"secret:file/missing-task-status-ca" + }); + parse_runtime_config(&format!( + "{base}\ntaskGrantStatus: {}\n", + json!([status.clone()]) + )) + .expect("nonsecret structural checking performs no secret/network I/O"); + for (field, value) in [ + ("sourceIssuer", "not-an-absolute-issuer"), + ("baseUrl", "http://public.test"), + ("tokenEndpoint", "https://user@identity.test/token"), + ("clientId", ""), + ("clientAssertionAudience", ""), + ("caseworkResource", "not-an-absolute-resource"), + ("statusScope", "two scopes"), + ("statusPath", "/v1/task-grants/status"), + ] { + let mut invalid = status.clone(); + invalid[field] = json!(value); + let error = + parse_runtime_config(&format!("{base}\ntaskGrantStatus: {}\n", json!([invalid]))) + .unwrap_err(); + if field == "tokenEndpoint" || field == "clientId" || field == "clientAssertionAudience" { + let diagnostic = &error.diagnostics(None)[0]; + assert_eq!(diagnostic.path, format!("/taskGrantStatus/0/{field}")); + assert_eq!(diagnostic.code, "config.invalid-value"); + } else { + assert_eq!(error, RuntimeConfigError::InvalidTaskGrantStatus, "{field}"); + } + } + for count in [2, 33] { + let entries: Vec<_> = (0..count) + .map(|index| { + let mut entry = status.clone(); + if count == 33 { + entry["sourceIssuer"] = json!(format!("urn:casework:source:{index}")); + } + entry + }) + .collect(); + assert_eq!( + parse_runtime_config(&format!("{base}\ntaskGrantStatus: {}\n", json!(entries))) + .unwrap_err(), + RuntimeConfigError::InvalidTaskGrantStatus + ); + } + let mut invalid = status.clone(); + invalid["caseworkResource"] = json!("not-an-absolute-resource"); + let error = parse_runtime_config(&format!("{base}\ntaskGrantStatus: {}\n", json!([invalid]))) + .unwrap_err(); + let diagnostic = &error.diagnostics(None)[0]; + assert_eq!(diagnostic.path, "/taskGrantStatus"); + assert_eq!(diagnostic.code, "breg.runtime.invalid-task-grant-status"); + assert!(error.to_string().contains("at most 32")); + assert!(!fixture.secret_root.join("missing-task-status-key").exists()); + assert!(!fixture.secret_root.join("missing-task-status-ca").exists()); +} diff --git a/crates/registry-bregctl/tests/cli.rs b/crates/registry-bregctl/tests/cli.rs index 8aa21e4d08..a1bd73790f 100644 --- a/crates/registry-bregctl/tests/cli.rs +++ b/crates/registry-bregctl/tests/cli.rs @@ -2167,6 +2167,7 @@ taskGrantStatus: clientId: registry privateKeyRef: secret:file/casework-key caseworkResource: ${BREG_CHECK_TEST_CASEWORK_RESOURCE} + statusPath: ${BREG_CHECK_TEST_STATUS_PATH} "; edited_runtime_config(scratch, |source| { let source = source diff --git a/crates/registry-platform-config/README.md b/crates/registry-platform-config/README.md index d11483cc3f..b16c3d27d0 100644 --- a/crates/registry-platform-config/README.md +++ b/crates/registry-platform-config/README.md @@ -96,6 +96,10 @@ change it. - `ListenerConfig` and `PrivateListenerConfig`: `bind` as `host:port` with an IP address host, and for private listeners the declared TLS termination and network exposure. +- `TaskGrantStatusConfig`: one `taskGrantStatus` entry naming a task + authority's issuer, its base URL and status route, and the runtime's own + private-key JWT credentials for the status check. `statusPath` and + `statusScope` default to Casework's route and scope. ## Package diff --git a/crates/registry-platform-config/src/blocks.rs b/crates/registry-platform-config/src/blocks.rs index ba633742c0..e428aaab41 100644 --- a/crates/registry-platform-config/src/blocks.rs +++ b/crates/registry-platform-config/src/blocks.rs @@ -14,7 +14,7 @@ use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::path::{Component, Path, PathBuf}; use std::str::FromStr; -use registry_platform_yaml::Url; +use registry_platform_yaml::{ExternalId, Url}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::{SecretError, SecretProvider, SecretReference, SecretResolver, MAX_SECRET_BYTES}; @@ -980,6 +980,67 @@ fn is_unique_local(address: Ipv6Addr) -> bool { address.octets()[0] & 0xfe == 0xfc } +/// The status route a task-grant status check appends to `baseUrl` when +/// `statusPath` is omitted, Casework's route. +pub const DEFAULT_TASK_GRANT_STATUS_PATH: &str = "/v1/task-grants/{grantId}/status"; + +/// The scope a task-grant status credential is requested with when +/// `statusScope` is omitted, Casework's status scope. +pub const DEFAULT_TASK_GRANT_STATUS_SCOPE: &str = "casework:grants:status"; + +/// One task authority whose grants a runtime checks for fresh status, and the +/// runtime's own credentials for that check, written as one entry of +/// `taskGrantStatus`. The status route and scope default to Casework's. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantStatusConfig { + /// The task authority's issuer, exactly as the authority states it, + /// including an absolute URN issuer. It is compared rather than fetched + /// and must be an absolute resource URI. + pub source_issuer: ExternalId, + /// The authority's base URL the status check calls. It is `https`; + /// `http` is accepted only for a loopback host, for local development. + pub base_url: Url, + pub token_endpoint: Url, + pub client_assertion_audience: ExternalId, + pub client_id: ExternalId, + pub private_key_ref: SecretReference, + /// The audience the runtime requests its status credential for, an + /// absolute resource URI. + pub casework_resource: ExternalId, + /// The one scope the runtime requests its status credential with. + /// Omitted, `casework:grants:status`. + #[serde(default = "default_task_grant_status_scope")] + pub status_scope: ExternalId, + /// The status route appended to `baseUrl`'s path. It starts with `/` and + /// has exactly one whole `{grantId}` segment; every other segment uses + /// only letters, digits, `-`, `.`, `_`, and `~`. Omitted, + /// `/v1/task-grants/{grantId}/status`. + #[serde(default = "default_task_grant_status_path")] + pub status_path: String, + /// PEM CA bundle for the authority and its token endpoint. Omitted, the + /// platform's trusted roots verify them. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schemars(with = "SecretReference"))] + pub ca_bundle_ref: Option, +} + +impl fmt::Debug for TaskGrantStatusConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("TaskGrantStatusConfig()") + } +} + +fn default_task_grant_status_scope() -> ExternalId { + ExternalId::new(DEFAULT_TASK_GRANT_STATUS_SCOPE) + .expect("the default status scope is an external id") +} + +fn default_task_grant_status_path() -> String { + DEFAULT_TASK_GRANT_STATUS_PATH.to_owned() +} + #[cfg(test)] #[path = "blocks_tests.rs"] mod tests; diff --git a/crates/registry-platform-config/src/blocks_tests.rs b/crates/registry-platform-config/src/blocks_tests.rs index 1fa4035d96..b70d4619e3 100644 --- a/crates/registry-platform-config/src/blocks_tests.rs +++ b/crates/registry-platform-config/src/blocks_tests.rs @@ -804,3 +804,80 @@ fn a_secret_failure_names_the_member_and_never_the_reference() { assert!(!sentence.contains("reference-canary"), "{sentence}"); } } + +const TASK_GRANT_STATUS: &str = "sourceIssuer: urn:authority:source\n\ +baseUrl: https://authority.example.test\n\ +tokenEndpoint: https://identity.example.test/oauth2/token\n\ +clientAssertionAudience: https://identity.example.test\n\ +clientId: status-client\n\ +privateKeyRef: secret:file/status-key\n\ +caseworkResource: urn:authority:status\n"; + +#[test] +fn task_grant_status_defaults_to_the_casework_route_and_scope_and_accepts_operator_values() { + let defaults: TaskGrantStatusConfig = read_block(TASK_GRANT_STATUS).unwrap(); + assert_eq!(defaults.source_issuer.as_str(), "urn:authority:source"); + assert_eq!(defaults.casework_resource.as_str(), "urn:authority:status"); + assert_eq!(defaults.status_scope.as_str(), "casework:grants:status"); + assert_eq!(defaults.status_path, "/v1/task-grants/{grantId}/status"); + assert_eq!(defaults.ca_bundle_ref, None); + + let configured: TaskGrantStatusConfig = read_block(&format!( + "{TASK_GRANT_STATUS}statusScope: grants:status\n\ + statusPath: /api/grants/{{grantId}}\n\ + caBundleRef: secret:file/status-ca\n" + )) + .unwrap(); + assert_eq!(configured.status_scope.as_str(), "grants:status"); + assert_eq!(configured.status_path, "/api/grants/{grantId}"); + assert_eq!( + configured + .ca_bundle_ref + .as_ref() + .map(SecretReference::as_str), + Some("secret:file/status-ca") + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&configured).unwrap()) + .unwrap(), + configured + ); + assert!(serde_json::to_value(&defaults) + .unwrap() + .get("caBundleRef") + .is_none()); + assert_eq!( + format!("{configured:?}"), + "TaskGrantStatusConfig()" + ); +} + +#[test] +fn task_grant_status_refuses_an_unknown_key_with_its_position_and_never_its_value() { + let yaml = format!("{TASK_GRANT_STATUS}statusScopes: canary-a\nscopes: canary-b\n"); + let report = read_block::(&yaml).unwrap_err(); + let reported: Vec<_> = report + .diagnostics() + .iter() + .map(|diagnostic| { + let source = diagnostic + .source + .as_ref() + .expect("a reader diagnostic has a source"); + ( + diagnostic.code.as_str(), + diagnostic.path.as_str(), + source.line, + source.column, + ) + }) + .collect(); + assert_eq!( + reported, + [ + ("config.unknown-key", "/statusScopes", Some(8), Some(1)), + ("config.unknown-key", "/scopes", Some(9), Some(1)), + ] + ); + assert!(!report.to_string().contains("canary-"), "{report}"); +} diff --git a/crates/registry-platform-config/src/lib.rs b/crates/registry-platform-config/src/lib.rs index 1fdce53015..c3f2412eaa 100644 --- a/crates/registry-platform-config/src/lib.rs +++ b/crates/registry-platform-config/src/lib.rs @@ -23,9 +23,10 @@ pub use blocks::{ ConfigBlockErrorKind, DatabaseConfig, EnvironmentSecretProviderConfig, FileSecretProviderConfig, JwksSource, ListenerBind, ListenerConfig, ListenerNetworkExposure, OidcClientsConfig, OidcIssuerConfig, PackageConfig, PackageDigestMismatch, - PrivateListenerConfig, SecretProvidersConfig, TlsTermination, MAX_ASSERTION_ISSUERS_PER_CLIENT, - MAX_ASSERTION_ISSUER_BYTES, MAX_ASSERTION_ISSUER_CLIENTS, MAX_ASSERTION_ISSUER_CLIENT_BYTES, - MAX_LISTENER_BIND_CHARACTERS, MAX_OIDC_AUDIENCE_CHARACTERS, + PrivateListenerConfig, SecretProvidersConfig, TaskGrantStatusConfig, TlsTermination, + DEFAULT_TASK_GRANT_STATUS_PATH, DEFAULT_TASK_GRANT_STATUS_SCOPE, + MAX_ASSERTION_ISSUERS_PER_CLIENT, MAX_ASSERTION_ISSUER_BYTES, MAX_ASSERTION_ISSUER_CLIENTS, + MAX_ASSERTION_ISSUER_CLIENT_BYTES, MAX_LISTENER_BIND_CHARACTERS, MAX_OIDC_AUDIENCE_CHARACTERS, }; pub use loader::{ contains_environment_expression, reject_environment_expressions_in_authored_yaml, diff --git a/crates/registry-platform-config/src/schema.rs b/crates/registry-platform-config/src/schema.rs index 5e11529086..042f00712b 100644 --- a/crates/registry-platform-config/src/schema.rs +++ b/crates/registry-platform-config/src/schema.rs @@ -5,6 +5,7 @@ use serde_json::{Map, Value}; use crate::blocks::{ AuditKeyConfig, DatabaseConfig, JwksSource, ListenerConfig, OidcClientsConfig, OidcIssuerConfig, PackageConfig, PrivateListenerConfig, SecretProvidersConfig, + TaskGrantStatusConfig, }; /// File name the shared-blocks generator writes. @@ -24,6 +25,7 @@ struct SharedBlocks { audit_key: AuditKeyConfig, oidc_issuer: OidcIssuerConfig, oidc_clients: OidcClientsConfig, + task_grant_status: TaskGrantStatusConfig, } /// The pretty-printed shared-blocks document, newline terminated. diff --git a/crates/registry-platform-httputil/README.md b/crates/registry-platform-httputil/README.md index 5e818a5f1f..c104c33332 100644 --- a/crates/registry-platform-httputil/README.md +++ b/crates/registry-platform-httputil/README.md @@ -28,6 +28,10 @@ Outbound HTTP utilities for registry services. `RedeemedAuthorizationCode`: the access token, a monotonic expiry when the issuer stated one, and the unverified ID token, which the caller verifies before reading any claim. + `PrivateKeyJwtConfig::check_identity` exposes the constructor's nonsecret + client, assertion-audience and token-endpoint checks for offline configuration + validation. Key import, certificate and provider readiness remain construction + checks. - `ExchangeAuthorization` for one immutable host-verified person or task-grant context. The first-party source signs a bounded grantless JWT; the remote source obtains a new assertion with a narrowly configured bootstrap on each diff --git a/crates/registry-platform-httputil/src/client/mod.rs b/crates/registry-platform-httputil/src/client/mod.rs index 8bc96e336e..e99ae3acce 100644 --- a/crates/registry-platform-httputil/src/client/mod.rs +++ b/crates/registry-platform-httputil/src/client/mod.rs @@ -24,11 +24,12 @@ pub use outbound::{ transport_protects_the_credential, OutboundOptions, }; pub use private_key_jwt::{ - valid_resource_uri, valid_scope_token, PrivateKeyJwt, PrivateKeyJwtConfig, - RedeemedAuthorizationCode, SubjectTokenType, DEFAULT_ASSERTION_LIFETIME_SECONDS, - DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, - MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, - MAXIMUM_SCOPE_PARAMETER_BYTES, MAXIMUM_TOKEN_RESPONSE_BYTES, + valid_resource_uri, valid_scope_token, validate_requested_scopes, PrivateKeyJwt, + PrivateKeyJwtConfig, RedeemedAuthorizationCode, SubjectTokenType, + DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, + MAXIMUM_ASSERTION_LIFETIME_SECONDS, MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, + MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, MAXIMUM_SCOPE_PARAMETER_BYTES, + MAXIMUM_TOKEN_RESPONSE_BYTES, }; pub use retry::{ classify_keyed_attempt, retry_keyed_mutation, KeyedMutationAttempt, RetryAfter, diff --git a/crates/registry-platform-httputil/src/client/private_key_jwt.rs b/crates/registry-platform-httputil/src/client/private_key_jwt.rs index 9c96cf3fd1..0ce1d7d910 100644 --- a/crates/registry-platform-httputil/src/client/private_key_jwt.rs +++ b/crates/registry-platform-httputil/src/client/private_key_jwt.rs @@ -294,6 +294,33 @@ pub fn valid_scope_token(value: &str) -> bool { }) } +/// Validate the exact scope list accepted by [`PrivateKeyJwt`] without keys or I/O. +/// The list must be nonempty, unique and within the provider's token and wire bounds. +pub fn validate_requested_scopes(scopes: &[String]) -> Result<(), TokenError> { + let refuse = |reason| TokenError::Configuration { reason }; + if scopes.is_empty() { + return Err(refuse("the requested scopes must state at least one scope")); + } + if scopes.len() > MAXIMUM_REQUESTED_SCOPES { + return Err(refuse("the requested scopes must be at most 32 values")); + } + let mut seen = std::collections::BTreeSet::new(); + for value in scopes { + if value.len() > MAXIMUM_REQUESTED_SCOPE_BYTES || !valid_scope_token(value) { + return Err(refuse( + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + )); + } + if !seen.insert(value.as_str()) { + return Err(refuse("the requested scopes must not repeat a value")); + } + } + if scopes.join(" ").len() > MAXIMUM_SCOPE_PARAMETER_BYTES { + return Err(refuse("the requested scopes must encode within 4096 bytes")); + } + Ok(()) +} + /// Split a scope string as RFC 6749 defines it: one or more scope-tokens /// separated by exactly one space each. A leading or trailing space, a doubled /// space, or a byte outside the `scope-token` set is a value no @@ -362,6 +389,45 @@ pub struct PrivateKeyJwtConfig { } impl PrivateKeyJwtConfig { + /// Check the nonsecret client identity and credential transport fields. + /// This performs no key import, certificate loading or network access. + /// + /// # Errors + /// Returns the same configuration refusals as provider construction for an + /// empty client/audience or an unsafe token endpoint. + pub fn check_identity( + token_endpoint: &Url, + client_id: &str, + audience: Option<&str>, + ) -> Result<(), TokenError> { + let refuse = |reason: &'static str| TokenError::Configuration { reason }; + if client_id.trim().is_empty() { + return Err(refuse("the client identifier must not be empty")); + } + // Only a stated audience can be empty. A caller that stated none gets the + // token endpoint, which is a parsed URL and therefore never is. + if audience.is_some_and(|audience| audience.trim().is_empty()) { + return Err(refuse("the assertion audience must not be empty")); + } + if !token_endpoint.username().is_empty() + || token_endpoint.password().is_some() + || token_endpoint.fragment().is_some() + { + return Err(refuse( + "the token endpoint must carry no credentials or fragment", + )); + } + // The assertion authenticates the client, so it is as sensitive in transit + // as the access token it is exchanged for, and the same transport rule + // applies to both. + if !transport_protects_the_credential(token_endpoint) { + return Err(refuse( + "the token endpoint must use HTTPS, or HTTP with a loopback host", + )); + } + Ok(()) + } + /// Authenticate as `client_id` at `token_endpoint`, signing with /// `client_key`. /// @@ -745,34 +811,11 @@ impl PrivateKeyJwt { ) -> Result { let refuse = |reason: &'static str| TokenError::Configuration { reason }; - if config.client_id.trim().is_empty() { - return Err(refuse("the client identifier must not be empty")); - } - // Only a stated audience can be empty. A caller that stated none gets the - // token endpoint, which is a parsed URL and therefore never is. - if config - .audience - .as_ref() - .is_some_and(|audience| audience.trim().is_empty()) - { - return Err(refuse("the assertion audience must not be empty")); - } - if !config.token_endpoint.username().is_empty() - || config.token_endpoint.password().is_some() - || config.token_endpoint.fragment().is_some() - { - return Err(refuse( - "the token endpoint must carry no credentials or fragment", - )); - } - // The assertion authenticates the client, so it is as sensitive in transit - // as the access token it is exchanged for, and the same transport rule - // applies to both. - if !transport_protects_the_credential(&config.token_endpoint) { - return Err(refuse( - "the token endpoint must use HTTPS, or HTTP with a loopback host", - )); - } + PrivateKeyJwtConfig::check_identity( + &config.token_endpoint, + &config.client_id, + config.audience.as_deref(), + )?; // The assertion header names the algorithm so the server can verify // without guessing, which means it must state what this key actually // signs with rather than one fixed name. Restricting the client to a @@ -851,28 +894,8 @@ impl PrivateKeyJwt { // for the same reason as every other refusal above: a set the provider // would refuse to send belongs in one construction-time failure. let (scope, requested_scopes) = if let Some(scopes) = &config.scopes { - if scopes.is_empty() { - return Err(refuse("the requested scopes must state at least one scope")); - } - if scopes.len() > MAXIMUM_REQUESTED_SCOPES { - return Err(refuse("the requested scopes must be at most 32 values")); - } - let mut seen = std::collections::BTreeSet::new(); - for value in scopes { - if value.len() > MAXIMUM_REQUESTED_SCOPE_BYTES || !valid_scope_token(value) { - return Err(refuse( - "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", - )); - } - if !seen.insert(value.as_str()) { - return Err(refuse("the requested scopes must not repeat a value")); - } - } - let canonical = scopes.join(" "); - if canonical.len() > MAXIMUM_SCOPE_PARAMETER_BYTES { - return Err(refuse("the requested scopes must encode within 4096 bytes")); - } - (Some(canonical), scopes.clone()) + validate_requested_scopes(scopes)?; + (Some(scopes.join(" ")), scopes.clone()) } else { (None, Vec::new()) }; @@ -2747,9 +2770,109 @@ mod tests { ); } + #[test] + fn requested_scope_validation_preserves_reasons_and_exact_bounds() { + for (scopes, reason) in [ + (vec![], "the requested scopes must state at least one scope"), + ( + (0..33).map(|i| format!("scope:{i}")).collect(), + "the requested scopes must be at most 32 values", + ), + ( + vec!["a".repeat(257)], + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + ), + ( + vec!["bad scope".into()], + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + ), + ( + vec!["read".into(), "read".into()], + "the requested scopes must not repeat a value", + ), + ( + (0..16) + .map(|i| format!("{i:x}{}", "a".repeat(255))) + .collect(), + "the requested scopes must encode within 4096 bytes", + ), + ] { + assert_eq!( + validate_requested_scopes(&scopes), + Err(TokenError::Configuration { reason }) + ); + } + for scopes in [ + (0..32).map(|i| format!("scope:{i}")).collect::>(), + vec!["a".repeat(256)], + (0..16) + .map(|i| format!("{i:x}{}", "a".repeat(if i == 15 { 255 } else { 254 }))) + .collect(), + ] { + validate_requested_scopes(&scopes).unwrap(); + } + } + /// A provider that could not authenticate, could not protect its assertion in /// transit, or could not sign at all fails at construction rather than once /// per request. + #[test] + fn nonsecret_identity_check_keeps_the_constructor_transport_and_identity_rules() { + for endpoint in [ + "https://identity.invalid/token?realm=test", + "http://127.0.0.1:1/token", + "http://[::1]:1/token", + "http://localhost:1/token", + ] { + let endpoint = Url::parse(endpoint).unwrap(); + PrivateKeyJwtConfig::check_identity(&endpoint, "client", None).unwrap(); + PrivateKeyJwtConfig::check_identity(&endpoint, "client", Some("urn:identity:test")) + .unwrap(); + } + for (endpoint, client, audience, reason) in [ + ( + "https://identity.test/token", + "", + None, + "the client identifier must not be empty", + ), + ( + "https://identity.test/token", + " ", + None, + "the client identifier must not be empty", + ), + ( + "https://identity.test/token", + "client", + Some(" "), + "the assertion audience must not be empty", + ), + ( + "https://user@identity.test/token", + "client", + None, + "the token endpoint must carry no credentials or fragment", + ), + ( + "https://identity.test/token#fragment", + "client", + None, + "the token endpoint must carry no credentials or fragment", + ), + ( + "http://identity.test/token", + "client", + None, + "the token endpoint must use HTTPS, or HTTP with a loopback host", + ), + ] { + assert!( + matches!(PrivateKeyJwtConfig::check_identity(&Url::parse(endpoint).unwrap(),client,audience),Err(TokenError::Configuration {reason:actual}) if actual==reason) + ); + } + } + #[test] fn an_unusable_provider_configuration_is_refused() { let cases: Vec<(&str, PrivateKeyJwtConfig)> = vec![ diff --git a/crates/registry-platform-httputil/src/lib.rs b/crates/registry-platform-httputil/src/lib.rs index 0b8f77a714..4c0de24a21 100644 --- a/crates/registry-platform-httputil/src/lib.rs +++ b/crates/registry-platform-httputil/src/lib.rs @@ -10,12 +10,12 @@ pub mod client; pub mod destination; pub use client::{ - exchange_authorization_from_json, valid_resource_uri, valid_scope_token, BearerToken, - ExchangeAssertionSource, ExchangeAuthorization, ExchangeContext, FirstPartyAssertionSource, - OAuthErrorCode, PrivateKeyJwt, PrivateKeyJwtConfig, RedeemedAuthorizationCode, - RemoteAssertionSource, ServiceBaseUrl, ServiceBaseUrlError, ServiceBaseUrlJoinError, - SignedExchangeAssertion, StaticToken, SubjectTokenType, TokenError, TokenProvider, - TransportKind, UpstreamSubjectToken, DEFAULT_ASSERTION_LIFETIME_SECONDS, + exchange_authorization_from_json, valid_resource_uri, valid_scope_token, + validate_requested_scopes, BearerToken, ExchangeAssertionSource, ExchangeAuthorization, + ExchangeContext, FirstPartyAssertionSource, OAuthErrorCode, PrivateKeyJwt, PrivateKeyJwtConfig, + RedeemedAuthorizationCode, RemoteAssertionSource, ServiceBaseUrl, ServiceBaseUrlError, + ServiceBaseUrlJoinError, SignedExchangeAssertion, StaticToken, SubjectTokenType, TokenError, + TokenProvider, TransportKind, UpstreamSubjectToken, DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, MAXIMUM_SCOPE_PARAMETER_BYTES, MAXIMUM_TOKEN_RESPONSE_BYTES, diff --git a/crates/registry-platform-oidc/Cargo.toml b/crates/registry-platform-oidc/Cargo.toml index 7f25f20b3f..fca3a402b0 100644 --- a/crates/registry-platform-oidc/Cargo.toml +++ b/crates/registry-platform-oidc/Cargo.toml @@ -13,7 +13,12 @@ workspace = true [dependencies] base64.workspace = true jsonwebtoken.workspace = true +registry-platform-crypto = { workspace = true } registry-platform-httputil = { workspace = true } +registry-platform-config.workspace = true +registry-platform-yaml.workspace = true +uuid = { workspace = true, features = ["serde"] } +chrono.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true @@ -22,3 +27,4 @@ tokio.workspace = true [dev-dependencies] axum.workspace = true +tempfile.workspace = true diff --git a/crates/registry-platform-oidc/README.md b/crates/registry-platform-oidc/README.md index 9d295e5014..8708e97833 100644 --- a/crates/registry-platform-oidc/README.md +++ b/crates/registry-platform-oidc/README.md @@ -13,6 +13,11 @@ OIDC discovery, JWKS caching, and JWT verification for registry services. - Scope mapping for translating provider scopes into platform permissions. - Strict extraction and client/resource binding for the shared contextual authorization claims. +- Typed exact task-grant status bindings and operator-pinned status clients + for any task authority; the consuming product supplies each authority's + status route, scope, and resource. `TaskGrantStatusRegistry::validate_configuration` + checks the same nonsecret structure before activation without resolving + credentials or contacting status/token endpoints. ## Typical Use @@ -95,10 +100,10 @@ The four elements of the change: oversized permission content that a verifier would accept and later fail to enforce. The closed union answers the first: an unknown tag is a malformed claim for every verifier, old or new. The per-value bounds answer the - second, and they mirror the BREG bounds exactly (64 permissions, 32 actions + second, and they mirror the BReg bounds exactly (64 permissions, 32 actions each, 512-byte service and location values, unique `(service, location)` pairs), so the worst case a signed claim can carry is the same - compositional bound BREG already allows. + compositional bound BReg already allows. - **Wire format.** The claim serializes as `"scheduling": {"permissions": [{"service": ..., "location": ..., "actions": [...]}]}` with `deny_unknown_fields` at every level; unknown members are refused, not @@ -119,8 +124,38 @@ The four elements of the change: 128/129-byte action boundary values, the 64-permission and 32-action limits, unique `(service, location)` enforcement, wildcard refusal, redacted `Debug` output, round-trip serialization of the tag spelling, and - a downstream case that BREG's binding refuses a scheduling grant because it - carries no BREG permissions. + a downstream case that BReg's binding refuses a scheduling grant because it + carries no BReg permissions. + +## Fresh task authorization status + +`task_grant::TaskGrantStatusBinding::from_verified` captures the original +verified grant principal, client, resource, purpose, bounds, disclosed identity +subjects, and deadline. Products first enforce their own supported bounds and +claim profile. A product may retain its own persisted binding and refuse bounds +it does not support before delegating to the shared transport. + +`TaskGrantStatusEndpoint` names one authority: its issuer, the base URL and the +status path appended to it, which holds exactly one whole `{grantId}` segment, +the token endpoint and client credentials, and the one resource and one scope +the service credential is requested for. A runtime's `taskGrantStatus` entries +carry these values in the shared `TaskGrantStatusConfig` block of +`registry-platform-config`, which owns their grammar and defaults (Casework's +status route and scope) and maps onto `TaskGrantStatusEndpoint` with `From`. + +`TaskGrantStatusRegistry` pins each authority to its endpoint and the resource +server's own private-key-JWT service credential. Each `check` obtains a fresh +strict status document and compares every immutable member. Only the service +credential is cached; a positive authorization observation never is. Unknown +sources, inactive/mismatched contexts, and expired grants are refused. Outages +are unavailable. A status endpoint rejecting the resource server's service +credential with HTTP 401 or 403 also makes the observation unavailable; it does +not establish that the task grant is inactive. Concealed or missing grants +(HTTP 404) remain refused. The complete observation, including service token +acquisition, is bounded to ten seconds, with a five-second status HTTP timeout and a 64 KiB +response ceiling. Products decide when the check precedes their effects and +how failures map to their own API; the primitive owns no mutation or durable +lifecycle state. ## Testing diff --git a/crates/registry-platform-oidc/src/lib.rs b/crates/registry-platform-oidc/src/lib.rs index da07a78aba..8709934e84 100644 --- a/crates/registry-platform-oidc/src/lib.rs +++ b/crates/registry-platform-oidc/src/lib.rs @@ -6,6 +6,7 @@ //! authorization endpoints, PKCE, token minting, or refresh flows. mod authorization_claims; +pub mod task_grant; pub use authorization_claims::{ actor_kind, grant_claims, ActorKind, BregPermission, ClaimError, ClaimMember, ClaimNames, diff --git a/crates/registry-platform-oidc/src/task_grant.rs b/crates/registry-platform-oidc/src/task_grant.rs new file mode 100644 index 0000000000..c8e2dfeb02 --- /dev/null +++ b/crates/registry-platform-oidc/src/task_grant.rs @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Fresh, bounded exact-status checks for immutable task authorizations. +use crate::{GrantBounds, GrantClaims}; +use registry_platform_httputil::{ + client::{build_client, OutboundOptions, PrivateKeyJwt, ServiceBaseUrl, TokenProvider}, + read_bounded, validate_response_headers, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::BTreeMap, fmt, sync::Arc, time::Duration}; +use thiserror::Error; +use uuid::Uuid; +mod config; +pub use config::{TaskGrantStatusEndpoint, TaskGrantStatusRegistry}; + +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantStatusBinding { + grant_id: String, + source_issuer: String, + principal: String, + client: String, + resource: String, + purpose: String, + bounds: GrantBounds, + subjects: BTreeMap, + expires_at: u64, +} +impl fmt::Debug for TaskGrantStatusBinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskGrantStatusBinding()") + } +} +impl TaskGrantStatusBinding { + /// The product must first verify the credential, client/resource context, + /// and its own supported bounds. No request body can create this binding. + pub fn from_verified( + grant: &GrantClaims, + subjects: BTreeMap, + ) -> Result { + let binding = Self { + grant_id: grant.id().into(), + source_issuer: grant.source_issuer().into(), + principal: grant.principal().into(), + client: grant.client().into(), + resource: grant.resource().into(), + purpose: grant.purpose().into(), + bounds: grant.bounds().clone(), + subjects, + expires_at: grant.exp(), + }; + binding.validate()?; + Ok(binding) + } + + fn validate(&self) -> Result<(), TaskGrantError> { + if Uuid::parse_str(&self.grant_id).is_err() + || [ + &self.source_issuer, + &self.principal, + &self.client, + &self.resource, + &self.purpose, + ] + .iter() + .any(|value| { + value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) + }) + || self.subjects.is_empty() + || self.subjects.len() > 32 + || self.subjects.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 128 + || key.chars().any(char::is_control) + || match value { + Value::String(value) => { + value.is_empty() + || value.len() > 512 + || value.chars().any(char::is_control) + } + Value::Bool(_) => false, + Value::Number(value) => { + value.as_i64().is_none() && value.as_u64().is_none() + } + _ => true, + } + }) + { + return Err(TaskGrantError::Refused); + } + Ok(()) + } + fn is_current(&self) -> bool { + u64::try_from(chrono::Utc::now().timestamp()).is_ok_and(|now| now < self.expires_at) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] +pub enum TaskGrantError { + #[error("task authority was refused")] + Refused, + #[error("task authority status is unavailable")] + Unavailable, + #[error("task authority status configuration is invalid")] + Configuration, +} + +/// A status route appended to the service base's path, parsed from a template +/// that starts with `/` and has exactly one whole `{grantId}` segment. Every +/// other segment is made of RFC 3986 unreserved characters, so the route +/// cannot add a query, a fragment, an encoded character, or a dot segment. +#[derive(Clone, Debug)] +struct StatusPath(Vec>); +impl StatusPath { + const GRANT_ID: &'static str = "{grantId}"; + + fn parse(template: &str) -> Result { + let segments = template + .strip_prefix('/') + .filter(|_| template.len() <= 256) + .ok_or(TaskGrantError::Configuration)? + .split('/') + .map(|segment| match segment { + Self::GRANT_ID => Ok(None), + "." | ".." => Err(TaskGrantError::Configuration), + literal + if !literal.is_empty() + && literal.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) + }) => + { + Ok(Some(literal.to_owned())) + } + _ => Err(TaskGrantError::Configuration), + }) + .collect::, _>>()?; + if segments.iter().filter(|segment| segment.is_none()).count() != 1 { + return Err(TaskGrantError::Configuration); + } + Ok(Self(segments)) + } + + fn segments<'a>(&'a self, grant_id: &'a str) -> Vec<&'a str> { + self.0 + .iter() + .map(|segment| segment.as_deref().unwrap_or(grant_id)) + .collect() + } +} + +pub struct TaskGrantStatusClient { + source_issuer: String, + resource: String, + base: ServiceBaseUrl, + status_path: StatusPath, + http: reqwest::Client, + token: Arc, +} +impl fmt::Debug for TaskGrantStatusClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskGrantStatusClient()") + } +} +impl TaskGrantStatusClient { + /// `status_path` is the status route appended to `base`'s path: it starts + /// with `/` and has exactly one whole `{grantId}` segment. + pub fn new( + source_issuer: String, + resource: String, + base: reqwest::Url, + status_path: &str, + token: Arc, + trusted_roots: Option<&[u8]>, + ) -> Result { + if !registry_platform_httputil::valid_resource_uri(&source_issuer) + || !registry_platform_httputil::valid_resource_uri(&resource) + { + return Err(TaskGrantError::Configuration); + } + let base = ServiceBaseUrl::new(base).map_err(|_| TaskGrantError::Configuration)?; + let status_path = StatusPath::parse(status_path)?; + let http = build_client(OutboundOptions { + request_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(3), + user_agent: Some("registry-task-status"), + trusted_root_certificates: trusted_roots, + }) + .map_err(|_| TaskGrantError::Configuration)?; + Ok(Self { + source_issuer, + resource, + base, + status_path, + http, + token, + }) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StatusResponse { + active: bool, + #[serde(default)] + grant: Option, +} +impl TaskGrantStatusClient { + /// Perform one fresh observation. The caller supplies only a verified immutable + /// context, not a request body. Positive status is never cached. The complete + /// attempt, including acquisition of the service credential, is bounded. + pub async fn check(&self, binding: &TaskGrantStatusBinding) -> Result<(), TaskGrantError> { + tokio::time::timeout(Duration::from_secs(10), async { + binding.validate()?; + if !binding.is_current() + || binding.source_issuer != self.source_issuer + || binding.resource != self.resource + { + return Err(TaskGrantError::Refused); + } + let token = self + .token + .bearer_token() + .await + .map_err(|_| TaskGrantError::Unavailable)?; + let url = registry_platform_httputil::url::append_path_segments( + self.base.as_url(), + &self.status_path.segments(&binding.grant_id), + ) + .map_err(|_| TaskGrantError::Configuration)?; + let response = self + .http + .get(url) + .header( + reqwest::header::AUTHORIZATION, + token.authorization_header_value(), + ) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|_| TaskGrantError::Unavailable)?; + validate_response_headers(response.headers()) + .map_err(|_| TaskGrantError::Unavailable)?; + // A concealed or absent grant is a refusal. Rejected status-service + // credentials do not establish inactive authority and remain unavailable. + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(TaskGrantError::Refused); + } + if response.status() != reqwest::StatusCode::OK + || response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .is_none_or(|value| !value.trim().eq_ignore_ascii_case("application/json")) + { + return Err(TaskGrantError::Unavailable); + } + let bytes = read_bounded(response, 64 * 1024) + .await + .map_err(|_| TaskGrantError::Unavailable)?; + let json = registry_platform_crypto::parse_json_strict(&bytes) + .map_err(|_| TaskGrantError::Unavailable)?; + let status: StatusResponse = + serde_json::from_value(json).map_err(|_| TaskGrantError::Unavailable)?; + if !status.active || status.grant.as_ref() != Some(binding) || !binding.is_current() { + return Err(TaskGrantError::Refused); + } + Ok(()) + }) + .await + .map_err(|_| TaskGrantError::Unavailable)? + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/registry-platform-oidc/src/task_grant/config.rs b/crates/registry-platform-oidc/src/task_grant/config.rs new file mode 100644 index 0000000000..41d8a796d0 --- /dev/null +++ b/crates/registry-platform-oidc/src/task_grant/config.rs @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Operator-pinned authority endpoints and the resource server's status credential. +use super::*; +use registry_platform_config::{SecretReference, SecretResolver, TaskGrantStatusConfig}; +use registry_platform_httputil::client::{validate_requested_scopes, PrivateKeyJwtConfig}; +use registry_platform_yaml::{ExternalId, Url}; + +/// One operator-pinned status authority, as a runtime's `taskGrantStatus` +/// entry states it. The shared [`TaskGrantStatusConfig`] block owns the +/// configuration grammar and its defaults and maps onto this type with +/// `From`; this type carries no protocol constant of its own. +#[derive(Clone)] +pub struct TaskGrantStatusEndpoint { + /// The exact authority identifier, including an absolute URN issuer. + /// It is compared rather than fetched and must be an absolute resource URI. + pub source_issuer: ExternalId, + /// HTTPS service base; plaintext HTTP is allowed only on loopback. + pub base_url: Url, + /// The status route appended to `base_url`'s path: it starts with `/` and + /// has exactly one whole `{grantId}` segment, such as + /// `/v1/task-grants/{grantId}/status`. + pub status_path: String, + pub token_endpoint: Url, + pub client_assertion_audience: ExternalId, + pub client_id: ExternalId, + pub private_key_ref: SecretReference, + /// The status service audience, which must be an absolute resource URI. + pub status_resource: ExternalId, + /// The one RFC 6749 scope token the status credential is requested with. + pub status_scope: ExternalId, + pub ca_bundle_ref: Option, +} +impl fmt::Debug for TaskGrantStatusEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskGrantStatusEndpoint()") + } +} +impl From<&TaskGrantStatusConfig> for TaskGrantStatusEndpoint { + fn from(config: &TaskGrantStatusConfig) -> Self { + Self { + source_issuer: config.source_issuer.clone(), + base_url: config.base_url.clone(), + status_path: config.status_path.clone(), + token_endpoint: config.token_endpoint.clone(), + client_assertion_audience: config.client_assertion_audience.clone(), + client_id: config.client_id.clone(), + private_key_ref: config.private_key_ref.clone(), + status_resource: config.casework_resource.clone(), + status_scope: config.status_scope.clone(), + ca_bundle_ref: config.ca_bundle_ref.clone(), + } + } +} + +pub struct TaskGrantStatusRegistry { + clients: BTreeMap, +} + +impl TaskGrantStatusRegistry { + pub fn contains(&self, source_issuer: &str) -> bool { + self.clients.contains_key(source_issuer) + } + + /// Validate operator-pinned nonsecret structure without resolving keys or + /// certificates, constructing HTTP clients, or contacting an endpoint. + /// Activation repeats this check before its secret and provider readiness. + /// + /// # Errors + /// Returns [`TaskGrantError::Configuration`] for duplicate source issuers, + /// more than 32 entries, a status path or scope the client would not + /// send, or a field the status/token clients would refuse. + pub fn validate_configuration( + configs: &[TaskGrantStatusEndpoint], + resource: &str, + ) -> Result<(), TaskGrantError> { + if configs.len() > 32 { + return Err(TaskGrantError::Configuration); + } + let mut issuers = std::collections::BTreeSet::new(); + for config in configs { + if !registry_platform_httputil::valid_resource_uri(&config.source_issuer) + || !registry_platform_httputil::valid_resource_uri(resource) + || !registry_platform_httputil::valid_resource_uri(&config.status_resource) + || validate_requested_scopes(&[config.status_scope.as_str().to_owned()]).is_err() + || !issuers.insert(config.source_issuer.as_str()) + { + return Err(TaskGrantError::Configuration); + } + StatusPath::parse(&config.status_path)?; + ServiceBaseUrl::new( + config + .base_url + .as_str() + .parse() + .map_err(|_| TaskGrantError::Configuration)?, + ) + .map_err(|_| TaskGrantError::Configuration)?; + PrivateKeyJwtConfig::check_identity( + &config + .token_endpoint + .as_str() + .parse() + .map_err(|_| TaskGrantError::Configuration)?, + config.client_id.as_str(), + Some(&config.client_assertion_audience), + ) + .map_err(|_| TaskGrantError::Configuration)?; + } + Ok(()) + } + + pub fn activate( + configs: &[TaskGrantStatusEndpoint], + resource: &str, + secrets: &SecretResolver, + ) -> Result { + Self::validate_configuration(configs, resource)?; + let mut clients = BTreeMap::new(); + for config in configs { + let secret = secrets + .resolve_reference(&config.private_key_ref) + .map_err(|_| TaskGrantError::Configuration)?; + let key = registry_platform_crypto::parse_json_strict(secret.expose_secret()) + .map_err(|_| TaskGrantError::Configuration)?; + let key = serde_json::from_value(key).map_err(|_| TaskGrantError::Configuration)?; + let ca = config + .ca_bundle_ref + .as_ref() + .map(|reference| secrets.resolve_reference(reference)) + .transpose() + .map_err(|_| TaskGrantError::Configuration)?; + let mut token = PrivateKeyJwtConfig::new( + config + .token_endpoint + .as_str() + .parse() + .map_err(|_| TaskGrantError::Configuration)?, + config.client_id.as_str(), + key, + ) + .with_audience(config.client_assertion_audience.as_str()) + .with_resource(config.status_resource.as_str()) + .with_scopes([config.status_scope.as_str()]); + if let Some(ca) = &ca { + token = token.with_trusted_root_certificates(ca.expose_secret().to_vec()); + } + let client = TaskGrantStatusClient::new( + config.source_issuer.as_str().to_owned(), + resource.to_owned(), + config + .base_url + .as_str() + .parse() + .map_err(|_| TaskGrantError::Configuration)?, + &config.status_path, + Arc::new(PrivateKeyJwt::new(token).map_err(|_| TaskGrantError::Configuration)?), + ca.as_ref().map(|value| value.expose_secret()), + )?; + if clients + .insert(config.source_issuer.as_str().to_owned(), client) + .is_some() + { + return Err(TaskGrantError::Configuration); + } + } + Ok(Self { clients }) + } +} + +impl TaskGrantStatusRegistry { + pub async fn check(&self, binding: &TaskGrantStatusBinding) -> Result<(), TaskGrantError> { + binding.validate()?; + let client = self + .clients + .get(&binding.source_issuer) + .ok_or(TaskGrantError::Refused)?; + client.check(binding).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_platform_config::SecretProvider; + use std::os::unix::fs::PermissionsExt; + + fn endpoint() -> TaskGrantStatusEndpoint { + TaskGrantStatusEndpoint { + source_issuer: ExternalId::new("https://authority.test").unwrap(), + base_url: Url::new("https://authority.test").unwrap(), + status_path: "/v1/task-grants/{grantId}/status".into(), + token_endpoint: Url::new("https://identity.test/oauth2/token").unwrap(), + client_assertion_audience: ExternalId::new("https://identity.test").unwrap(), + client_id: ExternalId::new("status-client").unwrap(), + private_key_ref: SecretReference::parse("secret:file/status-key").unwrap(), + status_resource: ExternalId::new("urn:authority:test").unwrap(), + status_scope: ExternalId::new("grants:status").unwrap(), + ca_bundle_ref: None, + } + } + + #[test] + fn status_path_names_one_grant_segment_and_the_scope_is_one_token() { + for path in [ + "/{grantId}", + "/v1/task-grants/{grantId}/status", + "/api/v2/grants/{grantId}", + "/a.b_c~d-e/{grantId}", + ] { + let mut config = endpoint(); + config.status_path = path.into(); + TaskGrantStatusRegistry::validate_configuration(&[config], "urn:resource:test") + .unwrap_or_else(|_| panic!("{path}")); + } + for path in [ + "", + "/", + "{grantId}", + "v1/{grantId}", + "/v1/status", + "/{grantId}/{grantId}", + "/v1//{grantId}", + "/v1/{grantId}/", + "/v1/../{grantId}", + "/./{grantId}", + "/v1/{grantId}?view=full", + "/v1/{grantId}#status", + "/v1/%2e%2e/{grantId}", + "/v1/grant-{grantId}", + "/v1/{grantid}", + "/v1/{id}", + "/v 1/{grantId}", + ] { + let mut config = endpoint(); + config.status_path = path.into(); + assert!( + TaskGrantStatusRegistry::validate_configuration(&[config], "urn:resource:test") + .is_err(), + "{path:?}" + ); + } + let mut long = endpoint(); + long.status_path = format!("/{}/{{grantId}}", "a".repeat(256)); + assert!( + TaskGrantStatusRegistry::validate_configuration(&[long], "urn:resource:test").is_err() + ); + for scope in ["grants:status other", "grants\\status", "grants\"status"] { + let mut config = endpoint(); + config.status_scope = ExternalId::new(scope).unwrap(); + assert!( + TaskGrantStatusRegistry::validate_configuration(&[config], "urn:resource:test") + .is_err(), + "{scope}" + ); + } + } + + #[test] + fn status_structure_is_checked_without_resolving_credential_material() { + let mut config = endpoint(); + config.source_issuer = ExternalId::new("urn:authority:source").unwrap(); + config.base_url = Url::new("https://unreachable.invalid/path").unwrap(); + config.token_endpoint = Url::new("http://127.0.0.1:1/token").unwrap(); + config.client_assertion_audience = ExternalId::new("urn:identity:test").unwrap(); + config.private_key_ref = SecretReference::parse("secret:file/missing-key").unwrap(); + config.ca_bundle_ref = Some(SecretReference::parse("secret:file/missing-ca").unwrap()); + TaskGrantStatusRegistry::validate_configuration( + std::slice::from_ref(&config), + "urn:resource:test", + ) + .unwrap(); + let root = tempfile::tempdir().unwrap(); + let secrets = SecretResolver::new([SecretProvider::File], root.path()).unwrap(); + assert!( + TaskGrantStatusRegistry::activate( + std::slice::from_ref(&config), + "urn:resource:test", + &secrets + ) + .is_err(), + "activation still requires real signer and CA readiness" + ); + for resource in ["", "not-an-absolute-resource"] { + TaskGrantStatusRegistry::validate_configuration(&[], resource).unwrap(); + TaskGrantStatusRegistry::activate(&[], resource, &secrets).unwrap(); + assert!(TaskGrantStatusRegistry::validate_configuration( + std::slice::from_ref(&config), + resource + ) + .is_err()); + } + let mut duplicate = config.clone(); + duplicate.base_url = Url::new("https://other.invalid").unwrap(); + assert!(TaskGrantStatusRegistry::validate_configuration( + &[config.clone(), duplicate], + "urn:resource:test" + ) + .is_err()); + let entries: Vec<_> = (0..33) + .map(|index| { + let mut entry = config.clone(); + entry.source_issuer = + ExternalId::new(format!("urn:authority:source:{index}")).unwrap(); + entry + }) + .collect(); + TaskGrantStatusRegistry::validate_configuration(&entries[..32], "urn:resource:test") + .unwrap(); + assert!( + TaskGrantStatusRegistry::validate_configuration(&entries, "urn:resource:test").is_err() + ); + assert!(!root.path().join("missing-key").exists()); + assert!(!root.path().join("missing-ca").exists()); + } + + #[test] + fn configured_status_clients_pin_source_issuer_and_refuse_unsafe_or_duplicate_bindings() { + let root = tempfile::tempdir().unwrap(); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + let path = root.path().join("status-key"); + let mut private = serde_json::to_value(&key).unwrap(); + for (name, value) in [ + ("d", &key.d), + ("p", &key.p), + ("q", &key.q), + ("dp", &key.dp), + ("dq", &key.dq), + ("qi", &key.qi), + ] { + if let Some(value) = value { + private[name] = Value::String(value.clone()); + } + } + std::fs::write(&path, serde_json::to_vec(&private).unwrap()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let secrets = SecretResolver::new([SecretProvider::File], root.path()).unwrap(); + let config = endpoint(); + let status = TaskGrantStatusRegistry::activate( + std::slice::from_ref(&config), + "urn:resource:test", + &secrets, + ) + .unwrap(); + assert!(status.contains(&config.source_issuer)); + assert!(!status.contains("https://other.test")); + let mut urn_authority = config.clone(); + urn_authority.source_issuer = ExternalId::new("urn:authority:source").unwrap(); + let urn_status = + TaskGrantStatusRegistry::activate(&[urn_authority], "urn:resource:test", &secrets) + .unwrap(); + assert!(urn_status.contains("urn:authority:source")); + let mut duplicate = config.clone(); + duplicate.base_url = Url::new("https://other-authority.test").unwrap(); + assert!(TaskGrantStatusRegistry::activate( + &[config.clone(), duplicate], + "urn:resource:test", + &secrets + ) + .is_err()); + assert!(Url::new("https://user@authority.test").is_err()); + for url in ["http://authority.test", "https://authority.test/#fragment"] { + let mut invalid = config.clone(); + invalid.base_url = Url::new(url).unwrap(); + assert!( + TaskGrantStatusRegistry::activate(&[invalid], "urn:resource:test", &secrets) + .is_err(), + "{url}" + ); + } + let mut invalid = config; + invalid.status_resource = ExternalId::new("not-an-absolute-resource").unwrap(); + assert!( + TaskGrantStatusRegistry::activate(&[invalid], "urn:resource:test", &secrets).is_err() + ); + } +} diff --git a/crates/registry-platform-oidc/src/task_grant/tests.rs b/crates/registry-platform-oidc/src/task_grant/tests.rs new file mode 100644 index 0000000000..92482d66c8 --- /dev/null +++ b/crates/registry-platform-oidc/src/task_grant/tests.rs @@ -0,0 +1,320 @@ +use super::*; +use axum::{ + extract::State, + http::{HeaderMap, Uri}, + routing::{get, post}, + Form, Json, Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_config::{SecretProvider, SecretReference, SecretResolver}; +use registry_platform_yaml::{ExternalId, Url}; +use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, +}; + +#[derive(Clone)] +struct StateData { + response: Arc>, + status_code: Arc, + status_calls: Arc, + token_calls: Arc, + rejected_token_calls: Arc, + client_assertion_audience: String, + requested: Arc>>, + status_paths: Arc>>, +} +async fn token( + State(state): State, + Form(form): Form>, +) -> Result, axum::http::StatusCode> { + let assertion = form + .get("client_assertion") + .ok_or(axum::http::StatusCode::UNAUTHORIZED)?; + let claims = assertion + .split('.') + .nth(1) + .and_then(|value| URL_SAFE_NO_PAD.decode(value).ok()) + .and_then(|value| serde_json::from_slice::(&value).ok()) + .ok_or(axum::http::StatusCode::UNAUTHORIZED)?; + if claims["aud"] != state.client_assertion_audience { + state.rejected_token_calls.fetch_add(1, Ordering::SeqCst); + return Err(axum::http::StatusCode::UNAUTHORIZED); + } + state.token_calls.fetch_add(1, Ordering::SeqCst); + let scope = form.get("scope").cloned().unwrap_or_default(); + state.requested.lock().unwrap().push(( + scope.clone(), + form.get("resource").cloned().unwrap_or_default(), + )); + Ok(Json( + serde_json::json!({"access_token":"synthetic-status-service","token_type":"Bearer","expires_in":300,"scope":scope}), + )) +} +async fn status( + State(state): State, + uri: Uri, + headers: HeaderMap, +) -> Result, axum::http::StatusCode> { + assert_eq!( + headers.get("authorization").unwrap(), + "Bearer synthetic-status-service" + ); + state.status_calls.fetch_add(1, Ordering::SeqCst); + state + .status_paths + .lock() + .unwrap() + .push(uri.path().to_owned()); + let code = axum::http::StatusCode::from_u16( + u16::try_from(state.status_code.load(Ordering::SeqCst)).unwrap(), + ) + .unwrap(); + if code != axum::http::StatusCode::OK { + return Err(code); + } + Ok(Json(state.response.lock().unwrap().clone())) +} +fn binding() -> TaskGrantStatusBinding { + serde_json::from_value(serde_json::json!({"grantId":Uuid::new_v4().to_string(),"sourceIssuer":"https://authority.test","principal":"agent","client":"agent-client","resource":"urn:resource:test","purpose":"review","bounds":{"type":"breg","permissions":[{"collection":"records","operations":["get","patch"]}]},"subjects":{"subject_reference":"synthetic-subject","active":true},"expiresAt":chrono::Utc::now().timestamp()+900})).unwrap() +} +fn state(response: Value, client_assertion_audience: String) -> StateData { + StateData { + response: Arc::new(Mutex::new(response)), + status_code: Arc::new(AtomicUsize::new(200)), + status_calls: Arc::new(AtomicUsize::new(0)), + token_calls: Arc::new(AtomicUsize::new(0)), + rejected_token_calls: Arc::new(AtomicUsize::new(0)), + client_assertion_audience, + requested: Arc::default(), + status_paths: Arc::default(), + } +} +/// Writes an owner-only private JWK as `status-key` under `root`. +fn status_key_secrets(root: &std::path::Path) -> SecretResolver { + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + let path = root.join("status-key"); + let mut private = serde_json::to_value(&key).unwrap(); + for (name, value) in [ + ("d", &key.d), + ("p", &key.p), + ("q", &key.q), + ("dp", &key.dp), + ("dq", &key.dq), + ("qi", &key.qi), + ] { + if let Some(value) = value { + private[name] = Value::String(value.clone()); + } + } + std::fs::write(&path, serde_json::to_vec(&private).unwrap()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + SecretResolver::new([SecretProvider::File], root).unwrap() +} +fn endpoint(base: &str) -> TaskGrantStatusEndpoint { + TaskGrantStatusEndpoint { + source_issuer: ExternalId::new("https://authority.test").unwrap(), + base_url: Url::new(base).unwrap(), + status_path: "/v1/task-grants/{grantId}/status".into(), + token_endpoint: Url::new(format!("{base}/token")).unwrap(), + client_assertion_audience: ExternalId::new(base).unwrap(), + client_id: ExternalId::new("status-client").unwrap(), + private_key_ref: SecretReference::parse("secret:file/status-key").unwrap(), + status_resource: ExternalId::new("urn:authority:test").unwrap(), + status_scope: ExternalId::new("grants:status").unwrap(), + ca_bundle_ref: None, + } +} + +#[tokio::test] +async fn status_client_requests_the_configured_scope_resource_and_status_path() { + let binding = binding(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let state = state( + serde_json::json!({"active":true,"grant":binding}), + base.clone(), + ); + let app = Router::new() + .route("/token", post(token)) + .route("/authority/grants/{id}/state", get(status)) + .with_state(state.clone()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let root = tempfile::tempdir().unwrap(); + let secrets = status_key_secrets(root.path()); + let mut endpoint = endpoint(&base); + endpoint.status_path = "/authority/grants/{grantId}/state".into(); + endpoint.status_scope = ExternalId::new("grants:read-status").unwrap(); + endpoint.status_resource = ExternalId::new("urn:authority:status").unwrap(); + let client = + TaskGrantStatusRegistry::activate(&[endpoint], "urn:resource:test", &secrets).unwrap(); + client.check(&binding).await.unwrap(); + assert_eq!( + *state.requested.lock().unwrap(), + vec![( + "grants:read-status".to_owned(), + "urn:authority:status".to_owned() + )] + ); + assert_eq!( + *state.status_paths.lock().unwrap(), + vec![format!("/authority/grants/{}/state", binding.grant_id)] + ); + server.abort(); +} + +#[tokio::test] +async fn activated_status_client_uses_the_configured_assertion_audience_and_checks_each_attempt() { + let binding = binding(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let issuer = format!("http://{address}"); + let state = state( + serde_json::json!({"active":true,"grant":binding}), + issuer.clone(), + ); + let response = state.response.clone(); + let app = Router::new() + .route("/token", post(token)) + .route("/v1/task-grants/{id}/status", get(status)) + .with_state(state.clone()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let root = tempfile::tempdir().unwrap(); + let secrets = status_key_secrets(root.path()); + let config = endpoint(&issuer); + let mut wrong = config.clone(); + wrong.client_assertion_audience = + registry_platform_yaml::ExternalId::new(format!("http://{address}/token")).unwrap(); + let wrong = TaskGrantStatusRegistry::activate(&[wrong], "urn:resource:test", &secrets).unwrap(); + assert_eq!( + wrong.check(&binding).await, + Err(TaskGrantError::Unavailable) + ); + assert_eq!(state.rejected_token_calls.load(Ordering::SeqCst), 1); + assert_eq!(state.status_calls.load(Ordering::SeqCst), 0); + + let client = + TaskGrantStatusRegistry::activate(&[config], "urn:resource:test", &secrets).unwrap(); + client.check(&binding).await.unwrap(); + *response.lock().unwrap() = serde_json::json!({"active":false}); + assert_eq!(client.check(&binding).await, Err(TaskGrantError::Refused)); + for field in [ + "grantId", + "sourceIssuer", + "principal", + "client", + "resource", + "purpose", + "bounds", + "subjects", + "expiresAt", + ] { + let mut changed = serde_json::to_value(&binding).unwrap(); + changed[field] = match field { + "grantId" => serde_json::json!(Uuid::new_v4().to_string()), + "bounds" => { + serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get"]}]}) + } + "subjects" => serde_json::json!({"subject_reference":"other-subject","active":true}), + "expiresAt" => serde_json::json!(binding.expires_at + 1), + _ => serde_json::json!("different"), + }; + *response.lock().unwrap() = serde_json::json!({"active":true,"grant":changed}); + assert_eq!( + client.check(&binding).await, + Err(TaskGrantError::Refused), + "{field}" + ); + } + *response.lock().unwrap() = serde_json::json!({"active":true,"grant":binding}); + client.check(&binding).await.unwrap(); + assert_eq!(state.status_calls.load(Ordering::SeqCst), 12); + assert_eq!( + state.token_calls.load(Ordering::SeqCst), + 1, + "only the service credential can be cached" + ); + let mut expired = binding.clone(); + expired.expires_at = 0; + assert_eq!(client.check(&expired).await, Err(TaskGrantError::Refused)); + assert_eq!( + state.status_calls.load(Ordering::SeqCst), + 12, + "expired authority is refused before I/O" + ); + // A prior positive result cannot substitute for a fresh observation. + // Rejected resource-server credentials do not prove this grant inactive. + let mut observed = Vec::new(); + for code in [401, 403, 404] { + state.status_code.store(code, Ordering::SeqCst); + observed.push((code, client.check(&binding).await)); + } + assert_eq!(state.status_calls.load(Ordering::SeqCst), 15); + assert_eq!(state.token_calls.load(Ordering::SeqCst), 1); + assert_eq!( + observed, + vec![ + (401, Err(TaskGrantError::Unavailable)), + (403, Err(TaskGrantError::Unavailable)), + (404, Err(TaskGrantError::Refused)), + ] + ); + server.abort(); +} + +#[test] +fn binding_debug_and_scalar_validation_preserve_privacy_and_exact_subjects() { + let mut grant = binding(); + assert_eq!(format!("{grant:?}"), "TaskGrantStatusBinding()"); + assert!(grant.validate().is_ok()); + for value in [ + Value::Null, + serde_json::json!(["subject"]), + serde_json::json!({"id":"subject"}), + serde_json::json!(1.5), + ] { + grant.subjects.insert("subject_reference".into(), value); + assert_eq!(grant.validate(), Err(TaskGrantError::Refused)); + } +} + +#[test] +fn the_shared_status_block_maps_onto_one_endpoint_member_for_member() { + let block: registry_platform_config::TaskGrantStatusConfig = + serde_json::from_value(serde_json::json!({ + "sourceIssuer":"https://authority.test", + "baseUrl":"https://authority.test", + "tokenEndpoint":"https://authority.test/token", + "clientAssertionAudience":"https://authority.test", + "clientId":"status-client", + "privateKeyRef":"secret:file/status-key", + "caseworkResource":"urn:authority:test", + "statusScope":"grants:status", + "caBundleRef":"secret:file/status-ca" + })) + .unwrap(); + let mapped = TaskGrantStatusEndpoint::from(&block); + let expected = endpoint("https://authority.test"); + assert_eq!(mapped.source_issuer, expected.source_issuer); + assert_eq!(mapped.base_url, expected.base_url); + assert_eq!(mapped.status_path, expected.status_path); + assert_eq!(mapped.token_endpoint, expected.token_endpoint); + assert_eq!( + mapped.client_assertion_audience, + expected.client_assertion_audience + ); + assert_eq!(mapped.client_id, expected.client_id); + assert_eq!(mapped.private_key_ref, expected.private_key_ref); + assert_eq!(mapped.status_resource, expected.status_resource); + assert_eq!(mapped.status_scope, expected.status_scope); + assert_eq!( + mapped.ca_bundle_ref.as_ref().map(SecretReference::as_str), + Some("secret:file/status-ca") + ); +} diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md index 69e919f993..885897237b 100644 --- a/products/breg/TASK_GRANTS.md +++ b/products/breg/TASK_GRANTS.md @@ -2,7 +2,8 @@ A Casework task grant gives an institutional agent bounded authority under its own principal. It does not make the agent the human who approved the task. -BREG verifies the exchanged access token and selected access profile, then +Base Registry Engine (BReg) verifies the exchanged access token and selected +access profile, then checks current Casework status before each new governed mutation. Task agents use change-request drafts and lifecycle operations; a task grant does not authorize direct changes to the target records, including imports. A task-grant profile cannot @@ -11,7 +12,7 @@ hold `apply-request`: the compiler refuses it with The authored access profile selects `actorKind: agent`, exact `requesterClients`, `requiredPurposes`, and a `taskGrant` containing the exact -`sourceIssuer`. The token must match that profile, BREG's configured audience, +`sourceIssuer`. The token must match that profile, BReg's configured audience, and the compiled collection and operation bounds. A task token cannot fall back to a standing access profile. Ordinary profiles retain their own authority. A `taskGrant` is authored only in project `accessProfiles`; a module cannot @@ -20,8 +21,8 @@ contribute a task-grant profile, and the compiler refuses one with An authored `permissions` entry is the Registry's governed ceiling for a profile. A delegated `taskGrant` is signed, short-lived authority for one task -inside that ceiling. It never adds an operation or field. BREG requires the -grant's complete BREG permission bounds to equal the profile's compiled +inside that ceiling. It never adds an operation or field. BReg requires the +grant's complete BReg permission bounds to equal the profile's compiled collections and operations. Wider, narrower, partial, and wrong-resource bounds are refused. @@ -39,7 +40,7 @@ authentication: ``` The access token keeps the citizen in `sub`. Its `act.sub` must equal the -configured actor for the verified `azp` or `client_id`; BREG does not accept a +configured actor for the verified `azp` or `client_id`; BReg does not accept a caller-supplied actor alias. `act` is exactly `{sub}` or exactly `{sub, iss}`, and `iss`, when present, must be a string equal to the verified token issuer; any other member, a different issuer, or a nested `act` refuses the token. A @@ -89,7 +90,7 @@ a profile without one. ## Configure current status -Configure BREG's runtime with one entry for each original source issuer used by +Configure BReg's runtime with one entry for each original source issuer used by its task profiles: ```yaml @@ -103,9 +104,23 @@ taskGrantStatus: caseworkResource: urn:casework:case-management ``` +`statusScope` and `statusPath` are optional. Omitted, BReg requests the +`casework:grants:status` scope and calls Casework's +`/v1/task-grants/{grantId}/status` route under `baseUrl`. Set them only for a +task authority that answers under another scope or route: `statusScope` is one +scope token, and `statusPath` starts with `/` and holds exactly one whole +`{grantId}` segment. + +Runtime configuration parsing checks the status registry's nonsecret structure: +at most 32 entries, distinct absolute source issuers, safe service and token URLs, +non-empty client and assertion audience, absolute resource identifiers, one +status scope token, and a status path with one `{grantId}` segment. +Parsing does not resolve status signer or CA material or contact its endpoints; +startup repeats these checks and verifies the credential material. + Register this client for client credentials with the exact Casework resource and -`casework:grants:status` scope. Register the same client in Casework's status -client mapping for this BREG resource. BREG supplies its configured OIDC audience +its status scope. Register the same client in Casework's status +client mapping for this BReg resource. BReg supplies its configured OIDC audience as that resource; neither a caller nor the grant chooses an outbound endpoint. `clientAssertionAudience` is required and must exactly match the audience the identity provider accepts for the signed client assertion. Stock ThunderID uses @@ -115,7 +130,7 @@ Production endpoints use HTTPS; loopback HTTP is available for local development Startup refuses a task profile without its configured source-issuer mapping. A failed status request refuses the mutation. No positive status result -is cached. BREG compares all retained authorization fields, including the +is cached. BReg compares all retained authorization fields, including the original principal, client, resource, purpose, bounds, subjects, and deadline. ## Approval, retries, and expiry @@ -136,21 +151,28 @@ or revoked task requires an explicit new authorized submission; refreshing an access token cannot extend the original grant deadline. Status refusal returns a failed precondition; status service failure returns -unavailable. Human rejection and owner cancellation keep their existing +unavailable. HTTP 401 or 403 from the status endpoint means its service +credential was rejected and therefore returns unavailable; it does not establish +that the task grant is inactive. A concealed or missing grant (HTTP 404) remains +a failed precondition. Human rejection and owner cancellation keep their existing permissions. These actions do not require the old submitter grant to remain active. Read cursors and representation ETags do not incorporate grant IDs. -The bounded status check runs while the proposal is locked. Casework revocation -can still occur between that check and the local commit. BREG and Casework do +The bounded status check uses the shared platform exact-status transport and +runs while the proposal is locked. Its total five-second bound includes obtaining +the status service credential; positive status is never cached. BReg keeps its +own persisted binding and BReg-only bounds validation. Casework revocation +can still occur between that check and the local commit. BReg and Casework do not share a distributed transaction. Proposal-detail erasure also removes its retained task subjects, under the existing operator retention boundary. + ## Audit Terminal and refusal records for a request carrying a verified task grant include an `authorization` object using the shared authorization audit fields. Grant, principal, client and approver identifiers are keyed pseudonyms, scoped to the activation that serves the request. The object also records the source issuer and grant deadline. -It contains no subjects, bounds values or purpose value. BREG continues +It contains no subjects, bounds values or purpose value. BReg continues to record purpose presence separately. Later human review remains a separate actor, and the retained original grant continues to govern status checks. diff --git a/products/breg/generated/runtime/runtime.schema.json b/products/breg/generated/runtime/runtime.schema.json index 8d32473a30..9db08098a2 100644 --- a/products/breg/generated/runtime/runtime.schema.json +++ b/products/breg/generated/runtime/runtime.schema.json @@ -1415,33 +1415,45 @@ }, "TaskGrantStatusConfig": { "additionalProperties": false, + "description": "One task authority whose grants a runtime checks for fresh status, and the\nruntime's own credentials for that check, written as one entry of\n`taskGrantStatus`. The status route and scope default to Casework's.", "properties": { "baseUrl": { "$ref": "#/$defs/Url", - "description": "The Casework base URL the status check calls. It is `https`; `http`\nis accepted only for a loopback host, for local development." + "description": "The authority's base URL the status check calls. It is `https`;\n`http` is accepted only for a loopback host, for local development." }, "caBundleRef": { "$ref": "#/$defs/SecretReference", - "description": "PEM CA bundle for Casework and its token endpoint. Omitted, the\nplatform's trusted roots verify them." + "description": "PEM CA bundle for the authority and its token endpoint. Omitted, the\nplatform's trusted roots verify them." }, "caseworkResource": { - "type": "string" + "$ref": "#/$defs/ExternalId", + "description": "The audience the runtime requests its status credential for, an\nabsolute resource URI." }, "clientAssertionAudience": { - "type": "string" + "$ref": "#/$defs/ExternalId" }, "clientId": { - "type": "string" + "$ref": "#/$defs/ExternalId" }, "privateKeyRef": { "$ref": "#/$defs/SecretReference" }, "sourceIssuer": { - "$ref": "#/$defs/Url", - "description": "The Casework task authority's issuer, exactly as Casework states it.\n`http` and `https` are both accepted, since the value is compared, not\nfetched." + "$ref": "#/$defs/ExternalId", + "description": "The task authority's issuer, exactly as the authority states it,\nincluding an absolute URN issuer. It is compared rather than fetched\nand must be an absolute resource URI." }, - "tokenEndpoint": { + "statusPath": { + "default": "/v1/task-grants/{grantId}/status", + "description": "The status route appended to `baseUrl`'s path. It starts with `/` and\nhas exactly one whole `{grantId}` segment; every other segment uses\nonly letters, digits, `-`, `.`, `_`, and `~`. Omitted,\n`/v1/task-grants/{grantId}/status`.", "type": "string" + }, + "statusScope": { + "$ref": "#/$defs/ExternalId", + "default": "casework:grants:status", + "description": "The one scope the runtime requests its status credential with.\nOmitted, `casework:grants:status`." + }, + "tokenEndpoint": { + "$ref": "#/$defs/Url" } }, "required": [ diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 4f8119ff58..f870660dc5 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -3219,7 +3219,7 @@ }, "artifact": { "path": "products/breg/generated/runtime/runtime.schema.json", - "sha256": "8fa2aebe5d11d696c103d8f1cc249ffaa734971b7221099828c34e7d2bae991f", + "sha256": "31b561f5e17ad94b393b13157b10d9d6e5ca56b7bd6947b4faf0d896c5cfe4cc", "mediaType": "application/schema+json" } }, diff --git a/products/platform/CONFIG-CONVENTIONS.md b/products/platform/CONFIG-CONVENTIONS.md index b0ef897461..ea0aeced6f 100644 --- a/products/platform/CONFIG-CONVENTIONS.md +++ b/products/platform/CONFIG-CONVENTIONS.md @@ -782,8 +782,9 @@ The reader refuses unknown keys in the same positions (CFG-SCHEMA-8). **CFG-SCHEMA-5 (MUST). Shared blocks have one definition.** The platform defines `ListenerConfig`, `PrivateListenerConfig`, `DatabaseConfig`, `OidcIssuerConfig`, `OidcClientsConfig`, `JwksSource`, `AuditKeyConfig`, -`SecretProvidersConfig`, `SecretReference`, `IdentityConfig`, -`ProjectIdentity`, `Url`, `LocalId`, `ExternalId`, and `Digest`. A product +`SecretProvidersConfig`, `SecretReference`, `TaskGrantStatusConfig`, +`IdentityConfig`, `ProjectIdentity`, `Url`, `LocalId`, `ExternalId`, and +`Digest`. A product embeds a shared definition unchanged under its shared name, or flattens a shared block from the platform crate, and never declares its own type for a concept a shared block covers, whatever its shape. Embedded definitions are diff --git a/products/platform/fuzz/Cargo.lock b/products/platform/fuzz/Cargo.lock index c42a991c48..1e80a39cbe 100644 --- a/products/platform/fuzz/Cargo.lock +++ b/products/platform/fuzz/Cargo.lock @@ -1663,6 +1663,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "registry-platform-config" +version = "0.41.0" +dependencies = [ + "registry-platform-canonical-json", + "registry-platform-yaml", + "rustix", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror", + "url", + "zeroize", +] + [[package]] name = "registry-platform-crypto" version = "0.41.0" @@ -1738,13 +1753,18 @@ name = "registry-platform-oidc" version = "0.41.0" dependencies = [ "base64 0.23.1", + "chrono", "jsonwebtoken", + "registry-platform-config", + "registry-platform-crypto", "registry-platform-httputil", + "registry-platform-yaml", "reqwest", "serde", "serde_json", "thiserror", "tokio", + "uuid", ] [[package]] @@ -2517,6 +2537,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/products/platform/generated/runtime-config-blocks.schema.json b/products/platform/generated/runtime-config-blocks.schema.json index 9a45515560..41f41f5633 100644 --- a/products/platform/generated/runtime-config-blocks.schema.json +++ b/products/platform/generated/runtime-config-blocks.schema.json @@ -320,6 +320,60 @@ "pattern": "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", "type": "string" }, + "TaskGrantStatusConfig": { + "additionalProperties": false, + "description": "One task authority whose grants a runtime checks for fresh status, and the\nruntime's own credentials for that check, written as one entry of\n`taskGrantStatus`. The status route and scope default to Casework's.", + "properties": { + "baseUrl": { + "$ref": "#/$defs/Url", + "description": "The authority's base URL the status check calls. It is `https`;\n`http` is accepted only for a loopback host, for local development." + }, + "caBundleRef": { + "$ref": "#/$defs/SecretReference", + "description": "PEM CA bundle for the authority and its token endpoint. Omitted, the\nplatform's trusted roots verify them." + }, + "caseworkResource": { + "$ref": "#/$defs/ExternalId", + "description": "The audience the runtime requests its status credential for, an\nabsolute resource URI." + }, + "clientAssertionAudience": { + "$ref": "#/$defs/ExternalId" + }, + "clientId": { + "$ref": "#/$defs/ExternalId" + }, + "privateKeyRef": { + "$ref": "#/$defs/SecretReference" + }, + "sourceIssuer": { + "$ref": "#/$defs/ExternalId", + "description": "The task authority's issuer, exactly as the authority states it,\nincluding an absolute URN issuer. It is compared rather than fetched\nand must be an absolute resource URI." + }, + "statusPath": { + "default": "/v1/task-grants/{grantId}/status", + "description": "The status route appended to `baseUrl`'s path. It starts with `/` and\nhas exactly one whole `{grantId}` segment; every other segment uses\nonly letters, digits, `-`, `.`, `_`, and `~`. Omitted,\n`/v1/task-grants/{grantId}/status`.", + "type": "string" + }, + "statusScope": { + "$ref": "#/$defs/ExternalId", + "default": "casework:grants:status", + "description": "The one scope the runtime requests its status credential with.\nOmitted, `casework:grants:status`." + }, + "tokenEndpoint": { + "$ref": "#/$defs/Url" + } + }, + "required": [ + "sourceIssuer", + "baseUrl", + "tokenEndpoint", + "clientAssertionAudience", + "clientId", + "privateKeyRef", + "caseworkResource" + ], + "type": "object" + }, "TlsTermination": { "description": "Declares the trusted transport boundary for a runtime's plaintext HTTP\nlistener. Production listeners require operator-controlled upstream TLS\ntermination; direct plaintext is limited to the explicit loopback-only\ndevelopment mode.", "enum": [ diff --git a/products/platform/scripts/check-config-conformance.py b/products/platform/scripts/check-config-conformance.py index 17c913d5d4..589cc9d707 100755 --- a/products/platform/scripts/check-config-conformance.py +++ b/products/platform/scripts/check-config-conformance.py @@ -253,6 +253,7 @@ class Row: "ListenerBind", "SecretProvidersConfig", "SecretReference", + "TaskGrantStatusConfig", ), reference_refusal=TestRef( "crates/registry-breg/tests/runtime_config.rs", diff --git a/products/platform/scripts/check-config-conventions.py b/products/platform/scripts/check-config-conventions.py index da99314f4b..7b8e36b411 100644 --- a/products/platform/scripts/check-config-conventions.py +++ b/products/platform/scripts/check-config-conventions.py @@ -143,7 +143,7 @@ "ListenerConfig", "PrivateListenerConfig", "DatabaseConfig", "OidcIssuerConfig", "OidcClientsConfig", "JwksSource", "AuditKeyConfig", "SecretProvidersConfig", "SecretReference", "IdentityConfig", "ProjectIdentity", "Url", "LocalId", - "ExternalId", "Digest", + "ExternalId", "Digest", "TaskGrantStatusConfig", ) VALUE_TYPES = ("Url", "Digest", "LocalId", "ExternalId", "SecretReference") FOREIGN = "x-registry-foreign" From a58667d2062559d7553843357deaf3bdf31bd1d0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 10:26:26 +0700 Subject: [PATCH 05/11] feat(casework): approve deferred task grants for delayed work A task template may opt into authorizationMode: deferred for an approved duration of at most seven days; omitted mode stays immediate with its 900-second ceiling. Deferred review grants pin a trigger-maintained assignment generation, so release, reassignment, or completion cannot revive an approval, and retained work-item grants reuse the existing invalidation triggers. The clients, bindings, facade, and OpenAPI carry the mode. Refs #1952 Signed-off-by: Jeremi Joslin --- .../registry-casework-client-node/README.md | 8 + .../__test__/facade.test-d.ts | 11 +- .../__test__/task-grants.test.js | 23 +- .../registry-casework-client-node/client.d.ts | 6 + crates/registry-casework-client-py/README.md | 8 + .../registry_casework_client/__init__.pyi | 9 +- .../tests/python/test_task_grants.py | 28 +- crates/registry-casework-client/README.md | 6 + crates/registry-casework-client/src/lib.rs | 8 +- .../src/task_assertion_source.rs | 192 ++++++- .../registry-casework-core/src/task_grant.rs | 230 +++++++- .../0022_review_assignment_generation.sql | 45 ++ crates/registry-casework/src/activation.rs | 7 +- crates/registry-casework/src/store.rs | 6 +- crates/registry-casework/src/task_grants.rs | 145 +++-- .../src/task_grants/http_tests.rs | 508 +++++++++++++++++- .../tests/activation_postgres.rs | 27 +- .../tests/postgres_transactions.rs | 157 +++++- .../casework/client.d.ts | 6 + crates/registry-stack-client/src/lib.rs | 6 +- products/breg/TASK_GRANTS.md | 4 +- products/casework/SECURITY-REVIEW-NOTES.md | 70 ++- products/casework/TASK_GRANTS.md | 79 ++- .../contracts/security-invariant-matrix.yaml | 3 +- .../contracts/security-test-traceability.yaml | 3 +- .../generated/project/project.schema.json | 66 ++- .../generated/registry-casework.openapi.json | 59 +- products/casework/scripts/generate_openapi.py | 12 +- .../casework/scripts/test_generate_openapi.py | 17 + .../identifiers/generated/catalog.v1.json | 2 +- 30 files changed, 1630 insertions(+), 121 deletions(-) create mode 100644 crates/registry-casework/migrations/0022_review_assignment_generation.sql diff --git a/crates/registry-casework-client-node/README.md b/crates/registry-casework-client-node/README.md index 171d84f491..a29357f023 100644 --- a/crates/registry-casework-client-node/README.md +++ b/crates/registry-casework-client-node/README.md @@ -45,6 +45,14 @@ status calls take only a bearer token and grant ID, without human or source profile headers. Neither binding retains credentials or resends a mutation beyond the bounded same-key retry below. +Previews and grant views include `authorizationMode: "deferred"` for a deferred +template; omission means the existing immediate mode. Approval still selects +only the governed template ID and version. Keep the grant ID, original deadline, +and business operation's idempotency key across a delay or restart. Acquire a +fresh assertion when executing; its short credential lifetime does not extend +the approved deadline. See the [task-grant lifecycle](../../products/casework/TASK_GRANTS.md) +for expiry, revocation, and changes that require another approval. + A keyed mutation whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same key up to `maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. diff --git a/crates/registry-casework-client-node/__test__/facade.test-d.ts b/crates/registry-casework-client-node/__test__/facade.test-d.ts index 3b1bae575a..d73e346a25 100644 --- a/crates/registry-casework-client-node/__test__/facade.test-d.ts +++ b/crates/registry-casework-client-node/__test__/facade.test-d.ts @@ -13,6 +13,7 @@ import { type ReviewResultOutcome, type ReviewTaskContext, type ReviewTaskDecisionRequest, + type TaskAuthorizationMode, type ReviewTaskQuery, type SupervisoryReviewTask, type OwnReviewDecision, @@ -289,15 +290,21 @@ void client.updateDirectoryTeam(token, 'administrator', 'review-team', 4, 'team- void client.previewTaskTemplates(token, profile, sourceProfile, item.itemId).then(result => { const revision: number = result.value.itemRevision const lifetime: number | undefined = result.value.templates[0]?.lifetimeSeconds - void revision; void lifetime + const mode: TaskAuthorizationMode | undefined = result.value.templates[0]?.authorizationMode + void revision; void lifetime; void mode +}) +void client.listTaskGrants(token, profile, sourceProfile, item.itemId).then(result => { + const mode: 'immediate' | 'deferred' | undefined = result.value.grants[0]?.authorizationMode + void mode }) -void client.listTaskGrants(token, profile, sourceProfile, item.itemId) void client.approveTaskGrant(token, profile, sourceProfile, item.itemId, 7, 'task-key', { templateId: 'verify-status', templateVersion: '1' }) void client.revokeTaskGrant(token, profile, sourceProfile, item.itemId, item.itemId) void client.taskAssertion(token, item.itemId) void client.taskGrantStatus(token, item.itemId) // @ts-expect-error Policy bounds must come from the governed template. void client.approveTaskGrant(token, profile, sourceProfile, item.itemId, 7, 'task-key', { templateId: 'verify-status', templateVersion: '1', resource: 'urn:forged' }) +// @ts-expect-error The approval cannot change the governed authorization mode. +void client.approveTaskGrant(token, profile, sourceProfile, item.itemId, 7, 'task-key', { templateId: 'verify-status', templateVersion: '1', authorizationMode: 'deferred' }) // @ts-expect-error Agent assertion calls do not accept human profiles. void client.taskAssertion(token, profile, sourceProfile, item.itemId) diff --git a/crates/registry-casework-client-node/__test__/task-grants.test.js b/crates/registry-casework-client-node/__test__/task-grants.test.js index 9e175914ce..0ed94ab1fa 100644 --- a/crates/registry-casework-client-node/__test__/task-grants.test.js +++ b/crates/registry-casework-client-node/__test__/task-grants.test.js @@ -7,32 +7,39 @@ const item='00000000-0000-4000-8000-000000000001'; const grant='00000000-0000-4000-8000-000000000002'; const preview={id:'verify-status',version:'1',label:'Verify status',agent:{issuer:'https://issuer.example',subject:'agent-one'},client:'agent-client',resource:'urn:evidence',scopes:['evidence:invoke'],evidenceContext:{requesterTags:['licensing-authority'],audience:'urn:licensing:review'},purpose:'verify-status',bounds:{type:'evidence',requirement:'status'},subjects:{person_reference:'synthetic-reference'},lifetimeSeconds:900}; const view={id:grant,templateId:preview.id,templateVersion:preview.version,agent:preview.agent,client:preview.client,resource:preview.resource,scopes:preview.scopes,evidenceContext:preview.evidenceContext,purpose:preview.purpose,bounds:preview.bounds,expiresAt:2000000900,invalidated:false}; -test('task grants preserve preview, approval revision/key and token-only machine calls',async(t)=>{ +for (const mode of [undefined, 'deferred']) { +test(`${mode ?? 'immediate'} task grants preserve preview, approval revision/key and token-only machine calls`,async(t)=>{ + const shownPreview=mode?{...preview,authorizationMode:mode,lifetimeSeconds:604800}:{...preview}; + const shownView=mode?{...view,authorizationMode:mode,expiresAt:2000604800}:{...view}; const requests=[]; const server=http.createServer((req,res)=>{let body='';req.on('data',b=>body+=b);req.on('end',()=>{requests.push({path:req.url,method:req.method,headers:req.headers,body});let value; - if(req.url.endsWith('/task-templates'))value={itemRevision:7,templates:[preview]}; - else if(req.url.endsWith('/assertion'))value={assertion:'synthetic-assertion',expiresAt:2000000060,grantExpiresAt:2000000900}; + if(req.url.endsWith('/task-templates'))value={itemRevision:7,templates:[shownPreview]}; + else if(req.url.endsWith('/assertion'))value={assertion:'synthetic-assertion',expiresAt:2000000060,grantExpiresAt:shownView.expiresAt}; else if(req.url.endsWith('/status'))value={active:false}; else if(req.url.endsWith('/revoke'))value={id:grant,invalidated:true}; - else value=req.method==='POST'?view:{grants:[view]}; + else value=req.method==='POST'?shownView:{grants:[shownView]}; res.writeHead(200,{'content-type':'application/json',traceparent:'00-0123456789abcdef0123456789abcdef-0123456789abcdef-01'});res.end(JSON.stringify(value));});}); await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r))); const client=new CaseworkClient({baseUrl:`http://127.0.0.1:${server.address().port}/`}); assert.equal(client.taskAssertionEndpoint(grant),`http://127.0.0.1:${server.address().port}/v1/task-grants/${grant}/assertion`); - assert.deepEqual((await client.previewTaskTemplates('human-token','staff','source-reviewer',item)).value.templates,[preview]); - assert.equal((await client.listTaskGrants('human-token','staff','source-reviewer',item)).value.grants[0].id,grant); + assert.deepEqual((await client.previewTaskTemplates('human-token','staff','source-reviewer',item)).value.templates,[shownPreview]); + assert.deepEqual((await client.listTaskGrants('human-token','staff','source-reviewer',item)).value.grants,[shownView]); const approval={templateId:'verify-status',templateVersion:'1'}; - await client.approveTaskGrant('human-token','staff','source-reviewer',item,7,'caller-attempt-key',approval); + assert.deepEqual((await client.approveTaskGrant('human-token','staff','source-reviewer',item,7,'caller-attempt-key',approval)).value,shownView); assert.deepEqual(JSON.parse(requests[2].body),approval);assert.equal(requests[2].headers['if-match'],'"7"');assert.equal(requests[2].headers['idempotency-key'],'caller-attempt-key'); await client.revokeTaskGrant('human-token','staff','source-reviewer',item,grant); assert.equal(requests[3].body,''); - assert.equal((await client.taskAssertion('bootstrap-token',grant)).value.assertion,'synthetic-assertion'); + assert.deepEqual((await client.taskAssertion('bootstrap-token',grant)).value,{assertion:'synthetic-assertion',expiresAt:2000000060,grantExpiresAt:shownView.expiresAt}); assert.deepEqual((await client.taskGrantStatus('resource-token',grant)).value,{active:false}); for(const request of requests.slice(0,4)){assert.equal(request.headers['registry-casework-profile'],'staff');assert.equal(request.headers['registry-source-profile'],'source-reviewer');} for(const request of requests.slice(4)){assert.equal(request.headers['registry-casework-profile'],undefined);assert.equal(request.headers['registry-source-profile'],undefined);} const before=requests.length; await assert.rejects(client.approveTaskGrant('human-token','staff','source-reviewer',item,7,'caller-attempt-key',{...approval,resource:'urn:other'}),e=>e.kind==='invalid-request'); + await assert.rejects(client.approveTaskGrant('human-token','staff','source-reviewer',item,7,'caller-attempt-key',{...approval,authorizationMode:'deferred'}),e=>e.kind==='invalid-request'); await assert.rejects(client.taskAssertion('bootstrap-token','not-a-grant-id'),e=>e.kind==='invalid-request'); assert.throws(()=>client.taskAssertionEndpoint('not-a-grant-id'),e=>e.kind==='invalid-request'); assert.equal(requests.length,before); + shownPreview.authorizationMode='unbounded'; + await assert.rejects(client.previewTaskTemplates('human-token','staff','source-reviewer',item),e=>e.kind==='protocol'); }); +} diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 41351c2939..f50a8ae270 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -37,15 +37,21 @@ export interface TaskPermission { collection: string; operations: ReadonlyArray< export interface SchedulingTaskPermission { service: string; location: string; actions: ReadonlyArray } export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } +/** Deferred authority keeps its approved deadline; execution credentials stay short-lived. */ +export type TaskAuthorizationMode = 'immediate' | 'deferred' export interface TaskTemplatePreview { id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger + /** Omitted for the existing immediate mode. */ + authorizationMode?: TaskAuthorizationMode } export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } /** Grant metadata deliberately excludes stored subject values. */ export interface TaskGrantView { id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean + /** Omitted for the existing immediate mode. */ + authorizationMode?: TaskAuthorizationMode } export interface TaskGrantList { grants: ReadonlyArray } export interface TaskGrantRevocation { id: string; invalidated: boolean } diff --git a/crates/registry-casework-client-py/README.md b/crates/registry-casework-client-py/README.md index a9fe2caf98..f033f4a0ba 100644 --- a/crates/registry-casework-client-py/README.md +++ b/crates/registry-casework-client-py/README.md @@ -84,3 +84,11 @@ and lifetime; the grant list omits stored subjects. Agent assertion and grant status calls take only a bearer token and grant ID, without human or source profile headers. Neither binding retains credentials or resends a mutation beyond the bounded same-key retry above. + +Previews and grant views include `authorizationMode: "deferred"` for a deferred +template; omission means the existing immediate mode. Approval still selects +only the governed template ID and version. Keep the grant ID, original deadline, +and business operation's idempotency key across a delay or restart. Acquire a +fresh assertion when executing; its short credential lifetime does not extend +the approved deadline. See the [task-grant lifecycle](../../products/casework/TASK_GRANTS.md) +for expiry, revocation, and changes that require another approval. diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 444e7b0a6e..129464a70e 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -49,9 +49,12 @@ class EvidenceRequesterContext(TypedDict): class TaskApprovalRequest(TypedDict): templateId: str templateVersion: str -class _OptionalEvidenceContext(TypedDict, total=False): +TaskAuthorizationMode: TypeAlias = Literal["immediate", "deferred"] +class _TaskGrantOptional(TypedDict, total=False): evidenceContext: EvidenceRequesterContext -class TaskTemplatePreview(_OptionalEvidenceContext): + # Omitted for the existing immediate mode; does not change credential TTLs. + authorizationMode: TaskAuthorizationMode +class TaskTemplatePreview(_TaskGrantOptional): id: str version: str label: str @@ -66,7 +69,7 @@ class TaskTemplatePreview(_OptionalEvidenceContext): class TaskTemplatePreviews(TypedDict): itemRevision: int templates: list[TaskTemplatePreview] -class TaskGrantView(_OptionalEvidenceContext): +class TaskGrantView(_TaskGrantOptional): id: str templateId: str templateVersion: str diff --git a/crates/registry-casework-client-py/tests/python/test_task_grants.py b/crates/registry-casework-client-py/tests/python/test_task_grants.py index 177a92c5cc..04b63f0a28 100644 --- a/crates/registry-casework-client-py/tests/python/test_task_grants.py +++ b/crates/registry-casework-client-py/tests/python/test_task_grants.py @@ -16,6 +16,15 @@ class TaskGrantTests(unittest.TestCase): def test_bounded_human_approval_and_token_only_machine_requests(self): + self.assert_task_grant_round_trip(dict(PREVIEW), dict(VIEW)) + + def test_deferred_approval_preserves_mode_and_deadline(self): + self.assert_task_grant_round_trip( + {**PREVIEW, "authorizationMode": "deferred", "lifetimeSeconds": 604800}, + {**VIEW, "authorizationMode": "deferred", "expiresAt": 2000604800}, + ) + + def assert_task_grant_round_trip(self, preview, view): requests = [] class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass @@ -24,11 +33,11 @@ def do_POST(self): self.respond() def respond(self): body = self.rfile.read(int(self.headers.get("content-length", "0"))) requests.append((self.path, dict(self.headers), body)) - if self.path.endswith("/task-templates"): value = {"itemRevision": 7, "templates": [PREVIEW]} - elif self.path.endswith("/assertion"): value = {"assertion": "synthetic-assertion", "expiresAt": 2000000060, "grantExpiresAt": 2000000900} + if self.path.endswith("/task-templates"): value = {"itemRevision": 7, "templates": [preview]} + elif self.path.endswith("/assertion"): value = {"assertion": "synthetic-assertion", "expiresAt": 2000000060, "grantExpiresAt": view["expiresAt"]} elif self.path.endswith("/status"): value = {"active": False} elif self.path.endswith("/revoke"): value = {"id": GRANT, "invalidated": True} - else: value = VIEW if self.command == "POST" else {"grants": [VIEW]} + else: value = view if self.command == "POST" else {"grants": [view]} self.send_response(200); self.send_header("content-type", "application/json"); self.send_header("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"); self.end_headers() self.wfile.write(json.dumps(value).encode()) server = HTTPServer(("127.0.0.1", 0), Handler) @@ -37,17 +46,17 @@ def respond(self): client = CaseworkClient(f"http://127.0.0.1:{server.server_port}/") self.assertEqual(client.task_assertion_endpoint(GRANT), f"http://127.0.0.1:{server.server_port}/v1/task-grants/{GRANT}/assertion") args = ("human-token", "staff", "source-reviewer", ITEM) - self.assertEqual(client.preview_task_templates(*args)["value"]["templates"], [PREVIEW]) - self.assertEqual(client.list_task_grants(*args)["value"]["grants"], [VIEW]) + self.assertEqual(client.preview_task_templates(*args)["value"]["templates"], [preview]) + self.assertEqual(client.list_task_grants(*args)["value"]["grants"], [view]) approval = {"templateId": "verify-status", "templateVersion": "1"} - client.approve_task_grant(*args, 7, "caller-attempt-key", approval) + self.assertEqual(client.approve_task_grant(*args, 7, "caller-attempt-key", approval)["value"], view) headers = {k.lower(): v for k, v in requests[2][1].items()} self.assertEqual(headers["if-match"], '\"7\"') self.assertEqual(headers["idempotency-key"], "caller-attempt-key") self.assertEqual(json.loads(requests[2][2]), approval) client.revoke_task_grant(*args, GRANT) self.assertEqual(requests[3][2], b"") - self.assertEqual(client.task_assertion("bootstrap-token", GRANT)["value"]["assertion"], "synthetic-assertion") + self.assertEqual(client.task_assertion("bootstrap-token", GRANT)["value"], {"assertion": "synthetic-assertion", "expiresAt": 2000000060, "grantExpiresAt": view["expiresAt"]}) self.assertEqual(client.task_grant_status("resource-token", GRANT)["value"], {"active": False}) for _, headers, _ in requests[:4]: headers = {k.lower(): v for k, v in headers.items()} @@ -59,8 +68,13 @@ def respond(self): self.assertNotIn("registry-source-profile", headers) count = len(requests) with self.assertRaises(CaseworkClientError): client.approve_task_grant(*args, 7, "caller-attempt-key", {**approval, "resource": "urn:other"}) + with self.assertRaises(CaseworkClientError): client.approve_task_grant(*args, 7, "caller-attempt-key", {**approval, "authorizationMode": "deferred"}) with self.assertRaises(CaseworkClientError): client.task_assertion("bootstrap-token", "invalid") with self.assertRaises(CaseworkClientError): client.task_assertion_endpoint("invalid") self.assertEqual(len(requests), count) + preview["authorizationMode"] = "unbounded" + with self.assertRaises(CaseworkClientError) as refused: + client.preview_task_templates(*args) + self.assertEqual(refused.exception.kind, "protocol") finally: server.shutdown(); thread.join(); server.server_close() diff --git a/crates/registry-casework-client/README.md b/crates/registry-casework-client/README.md index 1b2b9555f3..c4a076e046 100644 --- a/crates/registry-casework-client/README.md +++ b/crates/registry-casework-client/README.md @@ -50,6 +50,12 @@ revision the caller displayed and a caller-supplied idempotency key. Response-loss recovery reuses the original key and the server's stored prepared attempt. +The native Rust `CaseworkTaskAssertionSource` obtains a fresh task assertion +using only the agent's narrow bootstrap scope. A Casework 4xx refusal, including +an expired, revoked, or absent grant, becomes a fixed-text invalid credential +error. HTTP 408, 429, 5xx, and transport failures remain transient. The source does +not resend an assertion request or expose remote problem values in token errors. + ## Unknown outcomes and the same-key retry A 5xx answer may follow a commit, and so may a timeout or a broken exchange diff --git a/crates/registry-casework-client/src/lib.rs b/crates/registry-casework-client/src/lib.rs index d4691ef8a4..14af3165a9 100644 --- a/crates/registry-casework-client/src/lib.rs +++ b/crates/registry-casework-client/src/lib.rs @@ -56,10 +56,10 @@ pub use registry_casework_core::{ SourceContextBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, SubmissionDigest, SupervisoryReviewTask, SupervisoryReviewTaskPage, SupervisoryReviewTaskState, - TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, TaskGrantList, - TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, TaskPermission, - TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, WorkItem, WorkItemPage, WorkItemRouting, - WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, + TaskApprovalRequest, TaskAssertionResponse, TaskAuthorizationMode, TaskGrantBounds, + TaskGrantList, TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, + TaskPermission, TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, WorkItem, WorkItemPage, + WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, }; pub use registry_platform_httputil::client::BearerToken; pub use registry_review_client::ReviewMutationErrorClass; diff --git a/crates/registry-casework-client/src/task_assertion_source.rs b/crates/registry-casework-client/src/task_assertion_source.rs index fbbd297bbe..45e82c3b4c 100644 --- a/crates/registry-casework-client/src/task_assertion_source.rs +++ b/crates/registry-casework-client/src/task_assertion_source.rs @@ -7,7 +7,7 @@ use registry_platform_httputil::{ }; use uuid::Uuid; -use crate::CaseworkClient; +use crate::{CaseworkClient, CaseworkClientError}; /// Uses the agent's narrow Casework bootstrap to obtain a new task assertion. /// The source never accepts a human profile, an assertion body, or a replacement @@ -59,7 +59,7 @@ impl ExchangeAssertionSource for CaseworkTaskAssertionSource { .casework .task_assertion(&bootstrap, self.grant_id) .await - .map_err(|_| TokenError::Unavailable)?; + .map_err(assertion_error)?; let value = response.value; if i64::try_from(value.grant_expires_at).ok() != Some(context.deadline()) { return Err(TokenError::Invalid { @@ -75,6 +75,29 @@ impl ExchangeAssertionSource for CaseworkTaskAssertionSource { } } +// Assertion acquisition is unkeyed and single-shot. A 4xx status (even with an +// invalid problem body) is a refusal, except 408 and 429 which permit a later attempt. +// Never carry the product's remote problem text or identifiers into token errors. +fn assertion_error(error: CaseworkClientError) -> TokenError { + match error { + CaseworkClientError::Configuration { .. } => TokenError::Configuration { + reason: "the Casework assertion client cannot be used as configured", + }, + CaseworkClientError::InvalidRequest { .. } => TokenError::Invalid { + reason: "the Casework assertion request is invalid", + }, + CaseworkClientError::Transport { kind } => TokenError::Transport { kind }, + CaseworkClientError::Problem { status, .. } + | CaseworkClientError::Protocol { status, .. } => match status { + 408 | 429 | 500..=599 => TokenError::Unavailable, + 400..=499 => TokenError::Invalid { + reason: "Casework refused the task assertion", + }, + _ => TokenError::Protocol { status }, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -212,4 +235,169 @@ mod tests { ); assert!(assertion.headers.get("registry-casework-profile").is_none()); } + + #[tokio::test] + async fn assertion_refusals_and_transient_failures_remain_distinct_without_resends() { + use crate::CaseworkProblemCode; + + for (status, code, expected_kind) in [ + ( + 401, + Some(CaseworkProblemCode::AuthenticationRefused), + "invalid-credential", + ), + ( + 403, + Some(CaseworkProblemCode::OperationNotAuthorized), + "invalid-credential", + ), + ( + 404, + Some(CaseworkProblemCode::RequestNotFound), + "invalid-credential", + ), + // A malformed refusal still must not be retried as an outage. + (400, None, "invalid-credential"), + (403, None, "invalid-credential"), + (408, None, "unavailable"), + (429, None, "unavailable"), + ( + 503, + Some(CaseworkProblemCode::ServiceUnavailable), + "unavailable", + ), + (502, None, "unavailable"), + (200, None, "protocol"), + ] { + let server = MockServer::start().await; + let id = Uuid::new_v4(); + Mock::given(method("POST")).and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token":"bootstrap", "token_type":"Bearer", "expires_in":60, "scope":"casework:grants:assert"}))) + .mount(&server).await; + let mut response = ResponseTemplate::new(status).insert_header( + "traceparent", + "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + ); + if let Some(code) = &code { + let (title, detail) = code.expected_text().unwrap(); + response = response.set_body_raw(json!({ + "type": format!("https://id.registrystack.org/problems/registry-casework/{}", code.code().replace('.', "/")), + "title": title, "detail": detail, "code": code.code(), + "status": status, "traceId":"0123456789abcdef0123456789abcdef", + }).to_string(), "application/problem+json"); + } else { + response = response.set_body_string("remote-private-canary"); + } + Mock::given(method("POST")) + .and(path(format!("/v1/task-grants/{id}/assertion"))) + .respond_with(response) + .mount(&server) + .await; + let client = CaseworkClient::new(crate::CaseworkClientConfig::new( + server.uri().parse().unwrap(), + )) + .unwrap(); + if let Some(expected_code) = &code { + // Prove these fixtures meet the product problem contract, so + // the source sees a typed refusal rather than a parse failure. + let error = client + .task_assertion(&crate::BearerToken::new("bootstrap").unwrap(), id) + .await + .err() + .expect("typed product refusal"); + assert!( + matches!(&error, CaseworkClientError::Problem { + status: actual, code: actual_code, .. + } if *actual == status && actual_code == expected_code), + "status {status}: {error:?}" + ); + } + let source = CaseworkTaskAssertionSource::new( + client, + bootstrap(&server, &["casework:grants:assert"]), + id, + "urn:casework", + ) + .unwrap(); + let context = ExchangeContext::grant( + "https://casework.example", + "agent", + "https://issuer.example", + "generation", + 2_000_000_000, + id.to_string(), + ) + .unwrap(); + let error = source + .assertion(&context) + .await + .err() + .expect("assertion refused"); + assert_eq!(error.kind(), expected_kind, "status {status}"); + assert!(!format!("{error:?} {error}").contains("remote-private-canary")); + assert_eq!( + server.received_requests().await.unwrap().len(), + if code.is_some() { 3 } else { 2 }, + "single assertion attempt, status {status}" + ); + } + } + + #[test] + fn assertion_error_drops_remote_problem_values_and_preserves_transport_kind() { + use crate::{CaseworkProblemCode, CaseworkProtocolFailure}; + use registry_platform_httputil::client::TransportKind; + + for status in [400, 401, 403, 404, 408, 409, 410, 422, 429, 500, 503] { + let error = assertion_error(CaseworkClientError::Problem { + status, + code: CaseworkProblemCode::Unknown("remote-private-canary".into()), + detail: Some("remote-private-canary".into()), + trace_id: Some("remote-private-canary".into()), + original_attempt_id: None, + validation: None, + }); + assert_eq!( + error.kind(), + if matches!(status, 408 | 429 | 500..=599) { + "unavailable" + } else { + "invalid-credential" + } + ); + assert!(!format!("{error:?} {error}").contains("remote-private-canary")); + } + for kind in [ + TransportKind::Connect, + TransportKind::Timeout, + TransportKind::Exchange, + ] { + assert_eq!( + assertion_error(CaseworkClientError::Transport { kind }), + TokenError::Transport { kind } + ); + } + assert_eq!( + assertion_error(CaseworkClientError::Protocol { + status: 302, + failure: CaseworkProtocolFailure::Status, + trace_id: None, + }), + TokenError::Protocol { status: 302 } + ); + assert_eq!( + assertion_error(CaseworkClientError::Configuration { + reason: "remote-private-canary" + }) + .kind(), + "configuration" + ); + assert_eq!( + assertion_error(CaseworkClientError::InvalidRequest { + reason: "remote-private-canary" + }) + .kind(), + "invalid-credential" + ); + } } diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index b7dcc39e5b..2fd77d8aba 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -11,10 +11,44 @@ use uuid::Uuid; pub const TASK_GRANT_LIFETIME_SECONDS: u64 = 900; pub const TASK_ASSERTION_LIFETIME_SECONDS: u64 = 60; +/// Explicit opt-in authorization ceiling. This never changes credential TTLs. +pub const DEFERRED_TASK_GRANT_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TaskAuthorizationMode { + #[default] + Immediate, + Deferred, +} + +impl TaskAuthorizationMode { + pub fn is_immediate(&self) -> bool { + *self == Self::Immediate + } + pub fn maximum_lifetime_seconds(self) -> u64 { + match self { + Self::Immediate => TASK_GRANT_LIFETIME_SECONDS, + Self::Deferred => DEFERRED_TASK_GRANT_LIFETIME_SECONDS, + } + } +} #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr( + feature = "schema", + schemars(extend("allOf" = [{ + "if": {"properties": {"authorizationMode": {"const": "deferred"}}, "required": ["authorizationMode"]}, + "then": {"properties": {"lifetimeSeconds": {"maximum": DEFERRED_TASK_GRANT_LIFETIME_SECONDS}}}, + "else": {"properties": {"lifetimeSeconds": {"maximum": TASK_GRANT_LIFETIME_SECONDS}}} + }, { + "if": {"properties": {"authorizationMode": {"const": "deferred"}}, "required": ["authorizationMode"]}, + "then": {"properties": {"bounds": {"properties": {"type": {"not": {"const": "evidence"}}}}}} + }])) +)] pub struct TaskTemplate { #[serde(deserialize_with = "crate::typed::local_id")] #[cfg_attr(feature = "schema", schemars(with = "registry_platform_yaml::LocalId"))] @@ -61,10 +95,18 @@ pub struct TaskTemplate { schemars(with = "BTreeMap") )] pub subjects: BTreeMap, - #[serde(deserialize_with = "crate::typed::bounded_u64::<_, 1, TASK_GRANT_LIFETIME_SECONDS>")] + /// Immediate authorizations last at most fifteen minutes. Deferred is an + /// explicit governed window of at most seven days; assertion TTL stays sixty seconds. + #[serde(default, skip_serializing_if = "TaskAuthorizationMode::is_immediate")] + pub authorization_mode: TaskAuthorizationMode, + #[serde( + deserialize_with = "crate::typed::bounded_u64::<_, 1, DEFERRED_TASK_GRANT_LIFETIME_SECONDS>" + )] #[cfg_attr( feature = "schema", - schemars(with = "registry_platform_yaml::BoundedU64<1, TASK_GRANT_LIFETIME_SECONDS>") + schemars( + with = "registry_platform_yaml::BoundedU64<1, DEFERRED_TASK_GRANT_LIFETIME_SECONDS>" + ) )] pub lifetime_seconds: u64, } @@ -613,16 +655,29 @@ impl TaskTemplate { ); } } - if self.lifetime_seconds == 0 || self.lifetime_seconds > TASK_GRANT_LIFETIME_SECONDS { + let maximum_lifetime = self.authorization_mode.maximum_lifetime_seconds(); + if self.lifetime_seconds == 0 || self.lifetime_seconds > maximum_lifetime { findings.push( "casework.task-template.lifetime-out-of-range", "/lifetimeSeconds", format!( - "expected a whole number of seconds from 1 to {TASK_GRANT_LIFETIME_SECONDS}" + "expected a whole number of seconds from 1 to {maximum_lifetime} for this authorizationMode" ), "Write a lifetime within the bound.", ); } + // Evidence checks only the grant deadline and no grant status, so an + // Evidence grant keeps the immediate window. + if self.authorization_mode == TaskAuthorizationMode::Deferred + && matches!(self.bounds, TaskGrantBounds::Evidence { .. }) + { + findings.push( + "casework.task-template.deferred-evidence", + "/authorizationMode", + "a deferred grant cannot carry bounds of type evidence", + "Remove authorizationMode, or use BREG or Scheduling bounds.", + ); + } findings.extend_under("/bounds", self.bounds.findings()); match (&self.bounds, &self.evidence_context) { (TaskGrantBounds::Evidence { .. }, Some(context)) => { @@ -760,6 +815,9 @@ pub struct ReviewTaskGrant { pub task_id: Uuid, pub request_id: Uuid, pub task_revision: i64, + /// Deferred authorization pins responsibility independently of draft edits. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment_generation: Option, pub holder: IssuerPrincipal, pub template: TaskTemplate, pub template_digest: crate::ContentDigest, @@ -959,6 +1017,83 @@ mod tests { assert!(template.check(&project).is_ok()); } + #[test] + fn deferred_authorization_requires_explicit_mode_and_bounded_duration() { + let project: CaseworkProject = serde_json::from_value(serde_json::json!({ + "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, + "project": {"id":"tasks", "version":"1"}, + "accessProfiles":[{"id":"staff", "principalClaim":"sub", "requiredScopes":["casework:staff"], "role":"staff"}], + "queues":[{"id":"review", "label":"Review"}], + "sources":[{"id":"source", "adapter":"test", "description":"Test source", "requests":[{"entity":"request", "queue":"review"}]}] + })).unwrap(); + let mut template: TaskTemplate = serde_json::from_value(serde_json::json!({ + "id":"summary", "version":"1", "label":"Prepare summary", + "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", + "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test", "subject":"agent"}, + "client":"agent-client", "resource":"urn:test:breg", "scopes":["records:get", "records:draft"], + "purpose":"prepare-summary", "bounds":{"type":"breg", "permissions":[{"collection":"records", "operations":["get"]}]}, + "subjects":{"subject_reference":"subject-reference"}, "lifetimeSeconds":900 + })).unwrap(); + let immediate = serde_json::to_value(&template).unwrap(); + assert!(immediate.get("authorizationMode").is_none()); + assert_eq!( + template.authorization_mode, + TaskAuthorizationMode::Immediate + ); + template.lifetime_seconds = 901; + assert!(template.check(&project).is_err()); + template.authorization_mode = TaskAuthorizationMode::Deferred; + assert!(template.check(&project).is_ok()); + template.lifetime_seconds = DEFERRED_TASK_GRANT_LIFETIME_SECONDS; + assert!(template.check(&project).is_ok()); + template.lifetime_seconds += 1; + assert!(template.check(&project).is_err()); + template.lifetime_seconds = 0; + assert!(template.check(&project).is_err()); + let mut deferred = immediate.clone(); + deferred["authorizationMode"] = serde_json::json!("deferred"); + deferred["lifetimeSeconds"] = serde_json::json!(DEFERRED_TASK_GRANT_LIFETIME_SECONDS); + assert!(serde_json::from_value::(deferred.clone()) + .unwrap() + .check(&project) + .is_ok()); + for invalid_lifetime in [0, DEFERRED_TASK_GRANT_LIFETIME_SECONDS + 1] { + let mut invalid = deferred.clone(); + invalid["lifetimeSeconds"] = serde_json::json!(invalid_lifetime); + assert!(serde_json::from_value::(invalid).is_err()); + } + #[cfg(feature = "schema")] + { + let schema = serde_json::to_value(schemars::schema_for!(TaskTemplate)).unwrap(); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .unwrap(); + assert!(validator.is_valid(&immediate)); + assert!(validator.is_valid(&deferred)); + let mut implicit_long = immediate.clone(); + implicit_long["lifetimeSeconds"] = serde_json::json!(901); + assert!(!validator.is_valid(&implicit_long)); + implicit_long["authorizationMode"] = serde_json::json!("immediate"); + assert!(!validator.is_valid(&implicit_long)); + let mut overlong = deferred.clone(); + overlong["lifetimeSeconds"] = + serde_json::json!(DEFERRED_TASK_GRANT_LIFETIME_SECONDS + 1); + assert!(!validator.is_valid(&overlong)); + } + let mut invalid = immediate.clone(); + invalid["authorizationMode"] = serde_json::json!("standing"); + assert!(serde_json::from_value::(invalid).is_err()); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(immediate.clone()).unwrap() + ) + .unwrap(), + immediate + ); + } + #[test] fn proposal_identity_ignores_only_mutable_source_revision() { let binding = SourceBinding { @@ -1129,6 +1264,85 @@ mod tests { template.evidence_context.as_mut().unwrap().audience = "relative-audience".into(); assert!(template.check(&project).is_err()); } + + #[test] + fn deferred_authorization_cannot_carry_evidence_bounds() { + let project: CaseworkProject = serde_json::from_value(serde_json::json!({ + "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, + "project": {"id":"tasks", "version":"1"}, + "accessProfiles":[{"id":"staff", "principalClaim":"sub", "requiredScopes":["casework:staff"], "role":"staff"}], + "queues":[{"id":"review", "label":"Review"}], + "sources":[{"id":"source", "adapter":"test", "description":"Test source", "requests":[{"entity":"request", "queue":"review"}]}] + })) + .unwrap(); + let immediate = serde_json::json!({ + "id":"evidence-check", "version":"1", "label":"Check evidence", + "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", + "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test", "subject":"agent"}, + "client":"evidence-task-agent", "resource":"urn:test:evidence", + "scopes":["evidence:invoke"], "purpose":"fixture-eligibility", + "bounds":{"type":"evidence", "requirement":"urn:test:requirement:adult"}, + "evidenceContext":{"requesterTags":["fixture-agency"], "audience":"https://relying.test/procedure"}, + "subjects":{"given_name":"given-name"}, "lifetimeSeconds":900 + }); + let template: TaskTemplate = serde_json::from_value(immediate.clone()).unwrap(); + assert!(template.check(&project).is_ok()); + + // Evidence checks only the grant deadline and no grant status, so an + // Evidence grant keeps the immediate window at every lifetime. + for lifetime in [ + 1, + TASK_GRANT_LIFETIME_SECONDS, + DEFERRED_TASK_GRANT_LIFETIME_SECONDS, + ] { + let mut deferred = immediate.clone(); + deferred["authorizationMode"] = serde_json::json!("deferred"); + deferred["lifetimeSeconds"] = serde_json::json!(lifetime); + let template: TaskTemplate = serde_json::from_value(deferred).unwrap(); + let findings = template.findings(&project); + assert!( + findings.iter().any(|finding| { + finding.code == "casework.task-template.deferred-evidence" + && finding.pointer == "/authorizationMode" + }), + "deferred Evidence grant accepted at {lifetime} seconds" + ); + assert!(template.check(&project).is_err()); + } + + let mut deferred_breg = immediate.clone(); + deferred_breg["authorizationMode"] = serde_json::json!("deferred"); + deferred_breg["lifetimeSeconds"] = serde_json::json!(DEFERRED_TASK_GRANT_LIFETIME_SECONDS); + deferred_breg["resource"] = serde_json::json!("urn:test:breg"); + deferred_breg["bounds"] = serde_json::json!({ + "type":"breg", "permissions":[{"collection":"records", "operations":["get"]}] + }); + deferred_breg + .as_object_mut() + .unwrap() + .remove("evidenceContext"); + assert!( + serde_json::from_value::(deferred_breg.clone()) + .unwrap() + .check(&project) + .is_ok() + ); + + #[cfg(feature = "schema")] + { + let schema = serde_json::to_value(schemars::schema_for!(TaskTemplate)).unwrap(); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .unwrap(); + assert!(validator.is_valid(&immediate)); + assert!(validator.is_valid(&deferred_breg)); + let mut deferred = immediate.clone(); + deferred["authorizationMode"] = serde_json::json!("deferred"); + assert!(!validator.is_valid(&deferred)); + } + } } /// The exact authorization a human can approve after a current disclosed read. @@ -1147,6 +1361,10 @@ pub struct TaskTemplatePreview { #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_context: Option, pub subjects: BTreeMap, + /// Immediate authorizations last at most fifteen minutes. Deferred is an + /// explicit governed window of at most seven days; assertion TTL stays sixty seconds. + #[serde(default, skip_serializing_if = "TaskAuthorizationMode::is_immediate")] + pub authorization_mode: TaskAuthorizationMode, pub lifetime_seconds: u64, } @@ -1173,6 +1391,10 @@ pub struct TaskGrantView { pub bounds: TaskGrantBounds, #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_context: Option, + /// Immediate authorizations last at most fifteen minutes. Deferred is an + /// explicit governed window of at most seven days; assertion TTL stays sixty seconds. + #[serde(default, skip_serializing_if = "TaskAuthorizationMode::is_immediate")] + pub authorization_mode: TaskAuthorizationMode, pub expires_at: u64, pub invalidated: bool, } diff --git a/crates/registry-casework/migrations/0022_review_assignment_generation.sql b/crates/registry-casework/migrations/0022_review_assignment_generation.sql new file mode 100644 index 0000000000..664fd04734 --- /dev/null +++ b/crates/registry-casework/migrations/0022_review_assignment_generation.sql @@ -0,0 +1,45 @@ +-- Keep immediate grants bounded to fifteen minutes. Longer stored authority +-- requires the explicit governed deferred mode; credentials stay short-lived. +ALTER TABLE casework_task_grants DROP CONSTRAINT casework_task_grants_check; +ALTER TABLE casework_task_grants ADD CONSTRAINT casework_task_grants_authorization_window CHECK ( + expires_at > approved_at + AND COALESCE(record->'template'->>'authorizationMode', 'immediate') IN ('immediate', 'deferred') + AND expires_at <= approved_at + CASE record->'template'->>'authorizationMode' + WHEN 'deferred' THEN interval '604800 seconds' ELSE interval '900 seconds' END +); +ALTER TABLE casework_review_task_grants DROP CONSTRAINT casework_review_task_grants_check; +ALTER TABLE casework_review_task_grants ADD CONSTRAINT casework_review_task_grants_authorization_window CHECK ( + expires_at > approved_at + AND COALESCE(record->'template'->>'authorizationMode', 'immediate') IN ('immediate', 'deferred') + AND expires_at <= approved_at + CASE record->'template'->>'authorizationMode' + WHEN 'deferred' THEN interval '604800 seconds' ELSE interval '900 seconds' END +); + +-- Separate responsibility changes from ordinary draft revisions. A released, +-- reassigned, completed, or queue-moved task cannot revive an old authorization +-- by returning to its former holder before a status check observes the change. +ALTER TABLE casework_review_tasks + ADD COLUMN assignment_generation bigint NOT NULL DEFAULT 1 + CHECK (assignment_generation > 0); + +CREATE FUNCTION casework_review_assignment_generation() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF ROW(NEW.state, NEW.queue_id, NEW.holder_issuer, NEW.holder_subject, + NEW.assignment_owner_issuer, NEW.assignment_owner_subject, + NEW.assignment_kind, NEW.assignment_absence_ids) + IS DISTINCT FROM + ROW(OLD.state, OLD.queue_id, OLD.holder_issuer, OLD.holder_subject, + OLD.assignment_owner_issuer, OLD.assignment_owner_subject, + OLD.assignment_kind, OLD.assignment_absence_ids) THEN + NEW.assignment_generation := OLD.assignment_generation + 1; + ELSE + NEW.assignment_generation := OLD.assignment_generation; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER casework_review_assignment_generation +BEFORE UPDATE ON casework_review_tasks +FOR EACH ROW EXECUTE FUNCTION casework_review_assignment_generation(); diff --git a/crates/registry-casework/src/activation.rs b/crates/registry-casework/src/activation.rs index 977695089e..7bde7e9915 100644 --- a/crates/registry-casework/src/activation.rs +++ b/crates/registry-casework/src/activation.rs @@ -43,7 +43,7 @@ pub use registry_platform_activation::{ /// The triggers the Casework migrations create, as table, trigger, and the /// function in the Casework schema it executes. Any other trigger on a /// Casework table is stray authority in a split-role deployment. -const MIGRATION_TRIGGERS: [KnownTrigger; 2] = [ +const MIGRATION_TRIGGERS: [KnownTrigger; 3] = [ KnownTrigger { relation: "casework_meta", trigger: "casework_task_directory_changed", @@ -54,6 +54,11 @@ const MIGRATION_TRIGGERS: [KnownTrigger; 2] = [ trigger: "casework_task_item_changed", function: "casework_task_item_changed", }, + KnownTrigger { + relation: "casework_review_tasks", + trigger: "casework_review_assignment_generation", + function: "casework_review_assignment_generation", + }, ]; fn activation_layout() -> Layout { diff --git a/crates/registry-casework/src/store.rs b/crates/registry-casework/src/store.rs index a5cb00be6c..58e1d80b0f 100644 --- a/crates/registry-casework/src/store.rs +++ b/crates/registry-casework/src/store.rs @@ -49,7 +49,7 @@ const SOURCE_RECONCILIATION_HEALTH_MIGRATION: &str = include_str!("../migrations/0018_source_reconciliation_health.sql"); const ACTIVATIONS_MIGRATION: &str = include_str!("../migrations/0019_activations.sql"); /// Every schema version in ledger order. -pub(crate) const MIGRATIONS: [(i64, &str); 21] = [ +pub(crate) const MIGRATIONS: [(i64, &str); 22] = [ (1, MIGRATION), (2, HOSTED_MIGRATION), (3, ASSIGNMENT_MIGRATION), @@ -77,6 +77,10 @@ pub(crate) const MIGRATIONS: [(i64, &str); 21] = [ 21, include_str!("../migrations/0021_own_review_decisions.sql"), ), + ( + 22, + include_str!("../migrations/0022_review_assignment_generation.sql"), + ), ]; /// The newest schema version this binary knows how to run against. diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index f44fb28bb6..d1a57af28e 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -431,14 +431,14 @@ impl PostgresStore { task_id: Uuid, template: &TaskTemplate, expected_revision: Option, - ) -> Result, StoreError> { + ) -> Result, StoreError> { let Some(kind) = membership_kind(actor.role) else { return Ok(None); }; let row = transaction .query_opt( "SELECT t.request_id,t.revision,r.subject_source,r.subject_type,r.subject_id, - r.subject_version,r.subject_digest + r.subject_version,r.subject_digest,t.assignment_generation FROM casework_review_tasks t JOIN casework_review_requests r ON r.request_id=t.request_id JOIN casework_queue_service q ON q.queue_id=t.queue_id @@ -479,6 +479,7 @@ impl PostgresStore { digest: ContentDigest::parse(&row.get::<_, String>(6)) .map_err(|_| StoreError::Corrupt)?, }, + row.get(7), )) }) .transpose() @@ -489,7 +490,7 @@ impl PostgresStore { actor: &ActorContext, task_id: Uuid, template: &TaskTemplate, - ) -> Result, StoreError> { + ) -> Result, StoreError> { let mut client = self.client().await?; let transaction = client.transaction().await?; transaction @@ -544,18 +545,19 @@ impl PostgresStore { } // The revision-agnostic eligibility call confirms the caller's current // authority without binding it to the retry's original revision. - let Some((request_id, revision, subject)) = Self::review_task_grant_eligible( - &transaction, - actor, - grant.task_id, - &grant.template, - None, - ) - .await? + let Some((request_id, revision, subject, assignment_generation)) = + Self::review_task_grant_eligible( + &transaction, + actor, + grant.task_id, + &grant.template, + None, + ) + .await? else { return Err(StoreError::Forbidden); }; - let request = json!({ + let mut request = json!({ "taskId": grant.task_id, "requestId": grant.request_id, "taskRevision": grant.task_revision, @@ -566,6 +568,9 @@ impl PostgresStore { "subjects": grant.subjects, "sourceIssuer": grant.source_issuer, }); + if let Some(generation) = grant.assignment_generation { + request["assignmentGeneration"] = json!(generation); + } let hash = Sha256::digest(serde_json::to_vec(&request)?) .iter() .map(|byte| format!("{byte:02x}")) @@ -603,6 +608,8 @@ impl PostgresStore { } if request_id != grant.request_id || revision != grant.task_revision + || (!grant.template.authorization_mode.is_immediate() + && grant.assignment_generation != Some(assignment_generation)) || subject != grant.subject || grant.holder != actor.principal || template_digest(&grant.template)? != grant.template_digest @@ -761,10 +768,18 @@ impl PostgresStore { &actor, grant.task_id, template, - Some(grant.task_revision), + if template.authorization_mode.is_immediate() { + Some(grant.task_revision) + } else { + None + }, ) .await? - .is_some() + .is_some_and(|(_, _, subject, generation)| { + subject == grant.subject + && (template.authorization_mode.is_immediate() + || grant.assignment_generation == Some(generation)) + }) && template_digest(template)? == grant.template_digest; if !eligible { // The first eligibility invalidation is a loss of authority like @@ -1093,6 +1108,16 @@ impl TaskAuthority { if now >= grant_expires_at { return Err(StoreError::Forbidden); } + // Evidence checks only the grant deadline and no grant status, so an + // Evidence grant is never signed under the deferred window. + if !template.authorization_mode.is_immediate() + && matches!( + template.bounds, + registry_casework_core::TaskGrantBounds::Evidence { .. } + ) + { + return Err(StoreError::Forbidden); + } let expires = grant_expires_at.min( now.checked_add(registry_casework_core::TASK_ASSERTION_LIFETIME_SECONDS) .ok_or(StoreError::Invalid)?, @@ -1207,6 +1232,7 @@ impl crate::CaseworkService { bounds: template.bounds.clone(), evidence_context: template.evidence_context.clone(), subjects, + authorization_mode: template.authorization_mode, lifetime_seconds: template.lifetime_seconds, }); } @@ -1366,7 +1392,7 @@ impl crate::CaseworkService { .iter() .filter(|template| !template.review_kinds.is_empty()) { - let Some((request_id, revision, subject)) = self + let Some((request_id, revision, subject, assignment_generation)) = self .store .eligible_review_task_template(actor, task_id, template) .await? @@ -1404,15 +1430,24 @@ impl crate::CaseworkService { let Ok(subjects) = template.disclosed_subjects(&context.values) else { continue; }; - let Some((current_request_id, current_revision, current_subject)) = self + let Some(( + current_request_id, + current_revision, + current_subject, + current_assignment_generation, + )) = self .store .eligible_review_task_template(actor, task_id, template) .await? else { continue; }; - if (current_request_id, current_revision, ¤t_subject) - != (request_id, revision, &subject) + if ( + current_request_id, + current_revision, + ¤t_subject, + current_assignment_generation, + ) != (request_id, revision, &subject, assignment_generation) { continue; } @@ -1429,6 +1464,7 @@ impl crate::CaseworkService { bounds: template.bounds.clone(), evidence_context: template.evidence_context.clone(), subjects, + authorization_mode: template.authorization_mode, lifetime_seconds: template.lifetime_seconds, }); } @@ -1463,7 +1499,7 @@ impl crate::CaseworkService { && template.version == request.template_version }) .ok_or(crate::ServiceError::Forbidden)?; - let Some((request_id, current_revision, subject)) = self + let Some((request_id, current_revision, subject, assignment_generation)) = self .store .eligible_review_task_template(actor, task_id, template) .await? @@ -1502,6 +1538,8 @@ impl crate::CaseworkService { task_id, request_id, task_revision: revision, + assignment_generation: (!template.authorization_mode.is_immediate()) + .then_some(assignment_generation), holder: actor.principal.clone(), template: template.clone(), template_digest: template_digest(template)?, @@ -1548,7 +1586,7 @@ impl crate::CaseworkService { .store .eligible_review_task_template(actor, task_id, template) .await? - .is_some_and(|(_, revision, _)| revision == previews.item_revision) + .is_some_and(|(_, revision, _, _)| revision == previews.item_revision) { eligible = true; break; @@ -1857,6 +1895,7 @@ fn grant_view(stored: &StoredTaskGrant) -> TaskGrantView { purpose: stored.grant.template.purpose.clone(), bounds: stored.grant.template.bounds.clone(), evidence_context: stored.grant.template.evidence_context.clone(), + authorization_mode: stored.grant.template.authorization_mode, expires_at: stored.grant.expires_at, invalidated: stored.invalidated, } @@ -1874,6 +1913,7 @@ fn review_grant_view(stored: &StoredReviewTaskGrant) -> TaskGrantView { purpose: stored.grant.template.purpose.clone(), bounds: stored.grant.template.bounds.clone(), evidence_context: stored.grant.template.evidence_context.clone(), + authorization_mode: stored.grant.template.authorization_mode, expires_at: stored.grant.expires_at, invalidated: stored.invalidated, } @@ -1894,15 +1934,14 @@ mod evidence_assertion_tests { use registry_casework_core::{IssuerPrincipal, SubjectRef, TaskGrantBounds}; use std::collections::BTreeMap; - #[test] - fn task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit() { + fn test_authority() -> TaskAuthority { let mut key = registry_platform_crypto::generate_private_jwk( registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, ) .unwrap(); key.alg = Some("RS256".into()); key.kid = Some("task-authority-key".into()); - let authority = TaskAuthority { + TaskAuthority { config: crate::TaskAuthorityConfig { issuer: "https://casework.test".into(), exchange_audience: "https://issuer.test".into(), @@ -1911,23 +1950,14 @@ mod evidence_assertion_tests { }, key, identifiers: registry_platform_audit::AuditKeyHasher::unkeyed_dev_only(), - }; - let template: TaskTemplate = serde_json::from_value(json!({ - "id":"evidence-check", "version":"1", "label":"Check evidence", - "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", - "itemKinds":["request"], "itemStates":["claimed"], - "agent":{"issuer":"https://issuer.test", "subject":"agent"}, - "client":"evidence-task-agent", "resource":"urn:test:evidence", - "scopes":["evidence:invoke"], "purpose":"fixture-eligibility", - "bounds":{"type":"evidence", "requirement":"urn:test:requirement:adult"}, - "evidenceContext":{"requesterTags":["fixture-agency", "benefits"], "audience":"https://relying.test/procedure"}, - "subjects":{"given_name":"given-name"}, "lifetimeSeconds":900 - })) - .unwrap(); - let grant = TaskGrant { + } + } + + fn evidence_grant(template: serde_json::Value) -> TaskGrant { + TaskGrant { id: Uuid::new_v4(), item_id: Uuid::new_v4(), - template, + template: serde_json::from_value(template).unwrap(), source_issuer: "https://casework.test".into(), approver: IssuerPrincipal { issuer: "https://approver-issuer.test".into(), @@ -1947,7 +1977,44 @@ mod evidence_assertion_tests { subjects: BTreeMap::from([("given_name".into(), json!("Amina"))]), approved_at: 100, expires_at: 1_000, - }; + } + } + + fn evidence_template() -> serde_json::Value { + json!({ + "id":"evidence-check", "version":"1", "label":"Check evidence", + "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", + "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test", "subject":"agent"}, + "client":"evidence-task-agent", "resource":"urn:test:evidence", + "scopes":["evidence:invoke"], "purpose":"fixture-eligibility", + "bounds":{"type":"evidence", "requirement":"urn:test:requirement:adult"}, + "evidenceContext":{"requesterTags":["fixture-agency", "benefits"], "audience":"https://relying.test/procedure"}, + "subjects":{"given_name":"given-name"}, "lifetimeSeconds":900 + }) + } + + #[test] + fn task_assertion_refuses_a_deferred_evidence_grant() { + // Evidence checks only the grant deadline and no grant status, so the + // signer refuses a deferred Evidence grant even if one was stored. + let authority = test_authority(); + let mut template = evidence_template(); + template["authorizationMode"] = json!("deferred"); + let grant = evidence_grant(template); + assert!(matches!( + authority.assertion(&grant, 200), + Err(StoreError::Forbidden) + )); + assert!(authority + .assertion(&evidence_grant(evidence_template()), 200) + .is_ok()); + } + + #[test] + fn task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit() { + let authority = test_authority(); + let grant = evidence_grant(evidence_template()); let response = authority.assertion(&grant, 200).unwrap(); let encoded = response .assertion diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index f48f3c7ca7..3f2fdf2424 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -5,6 +5,7 @@ use axum::{ http::{Request, StatusCode}, Router, }; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use registry_casework_core::*; use registry_platform_config::{SecretProvider, SecretResolver}; @@ -105,8 +106,15 @@ impl SourceAdapter for Source { } else { "synthetic-person" }; + let mut source_binding = binding(); + if self.mode.load(Ordering::SeqCst) == 8 { + source_binding.source_revision = "unrelated-record-revision".into(); + } + if self.mode.load(Ordering::SeqCst) == 9 { + source_binding.version = "materially-changed-proposal".into(); + } Ok(TaskSubjectContext { - binding: binding(), + binding: source_binding, values: std::collections::BTreeMap::from([("person-reference".into(), json!(person))]), }) } @@ -195,7 +203,12 @@ async fn fixture_for_role(lifetime: u64, role: CaseworkRole) -> Fixture { std::env::remove_var(name); let template:TaskTemplate=serde_json::from_value(json!({"id":"summary","version":"1","label":"Prepare summary","eligibleTeams":["team"],"eligibleProfiles":[profile_id],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-summary","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); let review_template:TaskTemplate=serde_json::from_value(json!({"id":"review-summary","version":"1","label":"Prepare review summary","eligibleTeams":["team"],"eligibleProfiles":[profile_id],"source":"source","reviewKinds":["external-review"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-review-summary","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); - let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"project":{"id":"tasks","version":"1"},"accessProfiles":[{"id":profile_id,"principalClaim":"sub","requiredScopes":[scope],"role":profile_id},{"id":"producer","principalClaim":"sub","requiredScopes":["casework:producer"],"role":"requester"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Test source","requests":[{"entity":"request","queue":"review"}]}],"reviewKinds":[{"id":"external-review","version":"1","purpose":"approval","contextStrategy":"source","stages":[{"id":"review","queue":"review","decidingProfiles":[profile_id],"requiredApprovals":1,"excludeInitiator":true,"excludePreviousStageReviewers":false}],"retention":{"terminalDays":30,"accountabilityDays":90},"displaySchema":{"type":"object","additionalProperties":false,"properties":{}},"outcomes":[]}],"reviewProducers":[{"id":"producer","profile":"producer","issuer":ISSUER,"subject":"producer","trustedInitiatorIssuer":ISSUER,"sourceNamespaces":["source"],"kinds":["external-review"],"recoveryDays":30}],"taskTemplates":[template,review_template]})).unwrap(); + let mut project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"project":{"id":"tasks","version":"1"},"accessProfiles":[{"id":profile_id,"principalClaim":"sub","requiredScopes":[scope],"role":profile_id},{"id":"producer","principalClaim":"sub","requiredScopes":["casework:producer"],"role":"requester"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Test source","requests":[{"entity":"request","queue":"review"}]}],"reviewKinds":[{"id":"external-review","version":"1","purpose":"approval","contextStrategy":"source","stages":[{"id":"review","queue":"review","decidingProfiles":[profile_id],"requiredApprovals":1,"excludeInitiator":true,"excludePreviousStageReviewers":false}],"retention":{"terminalDays":30,"accountabilityDays":90},"displaySchema":{"type":"object","additionalProperties":false,"properties":{}},"outcomes":[]}],"reviewProducers":[{"id":"producer","profile":"producer","issuer":ISSUER,"subject":"producer","trustedInitiatorIssuer":ISSUER,"sourceNamespaces":["source"],"kinds":["external-review"],"recoveryDays":30}],"taskTemplates":[template,review_template]})).unwrap(); + if lifetime > TASK_GRANT_LIFETIME_SECONDS { + for template in &mut project.task_templates { + template.authorization_mode = TaskAuthorizationMode::Deferred; + } + } let template = project.task_templates[0].clone(); store .activate_task_templates(&project.task_templates) @@ -1407,6 +1420,83 @@ async fn review_task_grants_bind_holder_revision_subject_and_revocation() { .unwrap(); } +#[tokio::test] +async fn immediate_review_task_grants_refuse_a_changed_request_subject() { + let f = fixture(900).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let resource = token( + "resource", + "breg-status", + "service", + "casework:grants:status", + ); + let (status, grant) = request( + &f, + "POST", + &format!("/v1/review-tasks/{}/task-grants", f.review_task), + &human, + true, + Some(json!({"templateId":"review-summary","templateVersion":"1"})), + Some("review-subject"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let id = Uuid::parse_str(grant["id"].as_str().unwrap()).unwrap(); + let assertion = format!("/v1/task-grants/{id}/assertion"); + assert_eq!( + request(&f, "POST", &assertion, &agent, false, None, None) + .await + .0, + StatusCode::OK + ); + // The task revision an immediate grant pins does not cover the request + // row, so the approved subject is compared on its own. + let db = f.store.client().await.unwrap(); + db.execute( + "UPDATE casework_review_requests r SET subject_version='changed-version' + FROM casework_review_tasks t + WHERE t.request_id=r.request_id AND t.task_id=$1", + &[&f.review_task], + ) + .await + .unwrap(); + assert_eq!( + request(&f, "POST", &assertion, &agent, false, None, None) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + request( + &f, + "GET", + &format!("/v1/task-grants/{id}/status"), + &resource, + false, + None, + None, + ) + .await + .1["active"], + false + ); + let reason: Option = db + .query_one( + "SELECT invalidation_reason FROM casework_review_task_grants WHERE grant_id=$1", + &[&id], + ) + .await + .unwrap() + .get(0); + assert_eq!(reason.as_deref(), Some("eligibility")); + + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + #[tokio::test] async fn retiring_and_reintroducing_a_review_template_cannot_revive_its_grant() { let f = fixture(900).await; @@ -1690,6 +1780,7 @@ async fn review_task_grant_approval_recovers_a_lost_response_after_the_task_revi task_id: f.review_task, request_id, task_revision: 1, + assignment_generation: None, holder: actor.principal.clone(), template: template.clone(), template_digest: template_digest(&template).unwrap(), @@ -1963,6 +2054,7 @@ async fn review_task_grant_database_bound_allows_postgres_jsonb_rendering_overhe task_id: f.review_task, request_id, task_revision: 1, + assignment_generation: None, holder: actor.principal.clone(), template: template.clone(), template_digest: template_digest(&template).unwrap(), @@ -2152,3 +2244,415 @@ async fn status_cannot_outlive_grant_deadline_during_source_read() { .await .unwrap(); } + +/// Move the synthetic approval clock forward without a fifteen-minute sleep. +/// Only fixture data is changed; production renewals cannot mutate these fields. +async fn age_deferred_grant(f: &Fixture, id: Uuid, review: bool, seconds: i64) -> u64 { + let table = if review { + "casework_review_task_grants" + } else { + "casework_task_grants" + }; + let db = f.store.client().await.unwrap(); + let row = db.query_one(&format!( + "UPDATE {table} SET approved_at=approved_at-make_interval(secs=>($2::bigint)::double precision), + expires_at=expires_at-make_interval(secs=>($2::bigint)::double precision), + record=jsonb_set(jsonb_set(record,'{{approvedAt}}',to_jsonb((record->>'approvedAt')::bigint-$2::bigint)), + '{{expiresAt}}',to_jsonb((record->>'expiresAt')::bigint-$2::bigint)) + WHERE grant_id=$1 RETURNING record"), &[&id, &seconds]).await.unwrap(); + row.get::<_, Value>(0)["expiresAt"].as_u64().unwrap() +} + +#[tokio::test] +async fn deferred_grants_resume_after_fifteen_minutes_and_restart_without_widening_authority() { + let mut f = fixture(DEFERRED_TASK_GRANT_LIFETIME_SECONDS).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + for review in [false, true] { + let (path, template) = if review { + ( + format!("/v1/review-tasks/{}/task-grants", f.review_task), + "review-summary", + ) + } else { + (format!("/v1/work-items/{}/task-grants", f.item), "summary") + }; + let approval = json!({"templateId":template,"templateVersion":"1"}); + let (status, grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(approval.clone()), + Some(template), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + assert_eq!(grant["authorizationMode"], "deferred"); + assert_eq!( + request( + &f, + "POST", + &path, + &human, + true, + Some(approval), + Some(template) + ) + .await + .1, + grant + ); + let id = Uuid::parse_str(grant["id"].as_str().unwrap()).unwrap(); + let deadline = age_deferred_grant(&f, id, review, 901).await; + // A fresh service and fresh bootstrap simulate a caller/runtime restart; + // neither retained bearer token nor a second approval is used. + f.app = test_app( + &f.store, + &f.project, + f.mode.clone(), + f.read_started.clone(), + f.read_continue.clone(), + ); + f.mode.store(8, Ordering::SeqCst); // Only unrelated source revision changed. + let assertion_path = format!("/v1/task-grants/{id}/assertion"); + let (status, fresh) = request(&f, "POST", &assertion_path, &agent, false, None, None).await; + assert_eq!(status, StatusCode::OK, "{fresh}"); + assert_eq!(fresh["grantExpiresAt"], deadline); + let now = u64::try_from(Utc::now().timestamp()).unwrap(); + assert!(fresh["expiresAt"].as_u64().unwrap() <= now + 60); + let claims: Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode( + fresh["assertion"] + .as_str() + .unwrap() + .split('.') + .nth(1) + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(claims["registry_grant_exp"], deadline); + assert_eq!(claims["registry_grant_bounds"], grant["bounds"]); + assert_eq!(claims["sub"], grant["agent"]["subject"]); + let wrong_agent = token( + "different-principal", + "agent-client", + "agent", + "casework:grants:assert", + ); + assert_eq!( + request(&f, "POST", &assertion_path, &wrong_agent, false, None, None) + .await + .0, + StatusCode::NOT_FOUND + ); + f.mode.store(9, Ordering::SeqCst); // Exact proposal changed. + assert_eq!( + request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0, + StatusCode::FORBIDDEN + ); + f.mode.store(0, Ordering::SeqCst); + assert_eq!( + request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0, + StatusCode::FORBIDDEN + ); + } + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + +#[tokio::test] +async fn deferred_review_grants_ignore_drafts_but_never_revive_after_responsibility_changes() { + for change in [ + "release_reclaim", + "assignment", + "queue", + "completion", + "generation_missing", + ] { + let f = fixture(DEFERRED_TASK_GRANT_LIFETIME_SECONDS).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let grants = format!("/v1/review-tasks/{}/task-grants", f.review_task); + let (status, grant) = request( + &f, + "POST", + &grants, + &human, + true, + Some(json!({"templateId":"review-summary","templateVersion":"1"})), + Some(change), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let id = Uuid::parse_str(grant["id"].as_str().unwrap()).unwrap(); + let assertion = format!("/v1/task-grants/{id}/assertion"); + let db = f.store.client().await.unwrap(); + let before: i64 = db + .query_one( + "SELECT assignment_generation FROM casework_review_tasks WHERE task_id=$1", + &[&f.review_task], + ) + .await + .unwrap() + .get(0); + // The trigger owns the counter even if a caller tries to overwrite it. + db.execute("UPDATE casework_review_tasks SET revision=revision+1,assignment_generation=99 WHERE task_id=$1",&[&f.review_task]).await.unwrap(); + let after: i64 = db + .query_one( + "SELECT assignment_generation FROM casework_review_tasks WHERE task_id=$1", + &[&f.review_task], + ) + .await + .unwrap() + .get(0); + assert_eq!( + before, after, + "ordinary draft revision keeps responsibility unchanged" + ); + assert_eq!( + request(&f, "POST", &assertion, &agent, false, None, None) + .await + .0, + StatusCode::OK + ); + match change { + "release_reclaim" => { + // No status/assertion observes the interim release. Returning + // to the same holder must still refuse the original approval. + db.execute("UPDATE casework_review_tasks SET state='open',holder_issuer=NULL,holder_subject=NULL,assignment_kind=NULL,assignment_owner_issuer=NULL,assignment_owner_subject=NULL,revision=revision+1 WHERE task_id=$1",&[&f.review_task]).await.unwrap(); + db.execute("UPDATE casework_review_tasks SET state='claimed',holder_issuer=$2,holder_subject='human',assignment_kind='claim',assignment_owner_issuer=$2,assignment_owner_subject='human',revision=revision+1 WHERE task_id=$1",&[&f.review_task,&ISSUER]).await.unwrap(); + } + "assignment" => { + db.execute("UPDATE casework_review_tasks SET holder_subject='other',revision=revision+1 WHERE task_id=$1",&[&f.review_task]).await.unwrap(); + db.execute("UPDATE casework_review_tasks SET holder_subject='human',revision=revision+1 WHERE task_id=$1",&[&f.review_task]).await.unwrap(); + } + "queue" => { + db.execute("UPDATE casework_review_tasks SET queue_id='another-queue',revision=revision+1 WHERE task_id=$1",&[&f.review_task]).await.unwrap(); + } + "completion" => { + db.execute("UPDATE casework_review_tasks SET state='decided',settled_at=now(),revision=revision+1 WHERE task_id=$1",&[&f.review_task]).await.unwrap(); + } + "generation_missing" => { + db.execute("UPDATE casework_review_task_grants SET record=record-'assignmentGeneration' WHERE grant_id=$1",&[&id]).await.unwrap(); + } + _ => unreachable!(), + } + assert_eq!( + request(&f, "POST", &assertion, &agent, false, None, None) + .await + .0, + StatusCode::FORBIDDEN, + "{change}" + ); + let row=db.query_one("SELECT invalidated_at IS NOT NULL FROM casework_review_task_grants WHERE grant_id=$1",&[&id]).await.unwrap(); + assert!(row.get::<_, bool>(0), "{change} is permanently invalidated"); + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn deferred_grants_expire_and_revoke_without_extending_the_approved_window() { + let f = fixture(3600).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let path = format!("/v1/review-tasks/{}/task-grants", f.review_task); + for expire in [false, true] { + let key = if expire { "expired" } else { "revoked" }; + let (status, grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(json!({"templateId":"review-summary","templateVersion":"1"})), + Some(key), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let id = Uuid::parse_str(grant["id"].as_str().unwrap()).unwrap(); + if expire { + age_deferred_grant(&f, id, true, 3601).await; + } else { + let revoke = format!("/v1/review-tasks/{}/task-grants/{id}/revoke", f.review_task); + assert_eq!( + request(&f, "POST", &revoke, &human, true, None, None) + .await + .0, + StatusCode::OK + ); + } + assert_eq!( + request( + &f, + "POST", + &format!("/v1/task-grants/{id}/assertion"), + &agent, + false, + None, + None + ) + .await + .0, + StatusCode::FORBIDDEN + ); + } + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + +#[tokio::test] +async fn deferred_database_window_uses_elapsed_seconds_across_daylight_saving_changes() { + let f = fixture(DEFERRED_TASK_GRANT_LIFETIME_SECONDS).await; + let human = token("human", "human-client", "human", "casework:staff"); + for review in [false, true] { + let (path, template, table) = if review { + ( + format!("/v1/review-tasks/{}/task-grants", f.review_task), + "review-summary", + "casework_review_task_grants", + ) + } else { + ( + format!("/v1/work-items/{}/task-grants", f.item), + "summary", + "casework_task_grants", + ) + }; + let (status, grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(json!({"templateId":template,"templateVersion":"1"})), + Some("elapsed-window"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let id = Uuid::parse_str(grant["id"].as_str().unwrap()).unwrap(); + let mut db = f.store.client().await.unwrap(); + let transaction = db.transaction().await.unwrap(); + transaction + .batch_execute("SET LOCAL TIME ZONE 'America/New_York'") + .await + .unwrap(); + // Check the database boundary alone using synthetic historical dates. + // Seven elapsed days cross spring's shorter and autumn's longer day. + for approved in ["2026-03-05T12:00:00Z", "2026-10-29T12:00:00Z"] { + let approved = DateTime::parse_from_rfc3339(approved) + .unwrap() + .with_timezone(&Utc); + let expires = approved + chrono::Duration::seconds(604800); + transaction + .execute( + &format!("UPDATE {table} SET approved_at=$2,expires_at=$3 WHERE grant_id=$1"), + &[&id, &approved, &expires], + ) + .await + .unwrap(); + transaction + .batch_execute("SAVEPOINT over_window") + .await + .unwrap(); + let too_late = expires + chrono::Duration::seconds(1); + let refusal = transaction + .execute( + &format!("UPDATE {table} SET expires_at=$2 WHERE grant_id=$1"), + &[&id, &too_late], + ) + .await + .unwrap_err(); + assert_eq!( + refusal.code(), + Some(&tokio_postgres::error::SqlState::CHECK_VIOLATION) + ); + transaction + .batch_execute("ROLLBACK TO SAVEPOINT over_window") + .await + .unwrap(); + } + transaction.rollback().await.unwrap(); + } + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + +#[tokio::test] +async fn deferred_work_item_grants_ignore_drafts_but_never_revive_after_release_and_reclaim() { + let f = fixture(DEFERRED_TASK_GRANT_LIFETIME_SECONDS).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let path = format!("/v1/work-items/{}/task-grants", f.item); + let (status, grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(json!({"templateId":"summary","templateVersion":"1"})), + Some("retained-deferred"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let id = Uuid::parse_str(grant["id"].as_str().unwrap()).unwrap(); + let assertion = format!("/v1/task-grants/{id}/assertion"); + let db = f.store.client().await.unwrap(); + db.execute( + "UPDATE casework_items SET revision=revision+1 WHERE item_id=$1", + &[&f.item], + ) + .await + .unwrap(); + assert_eq!( + request(&f, "POST", &assertion, &agent, false, None, None) + .await + .0, + StatusCode::OK, + "a benign revision does not withdraw authority" + ); + // Existing item triggers record definitive loss transactionally, even + // when no assertion/status query observes the release between updates. + db.execute("UPDATE casework_items SET state='open',holder_issuer=NULL,holder_subject=NULL,assignment_owner_issuer=NULL,assignment_owner_subject=NULL,revision=revision+1 WHERE item_id=$1", &[&f.item]).await.unwrap(); + db.execute("UPDATE casework_items SET state='claimed',holder_issuer=$2,holder_subject='human',assignment_owner_issuer=$2,assignment_owner_subject='human',revision=revision+1 WHERE item_id=$1", &[&f.item,&ISSUER]).await.unwrap(); + let row = db + .query_one( + "SELECT invalidated_at IS NOT NULL FROM casework_task_grants WHERE grant_id=$1", + &[&id], + ) + .await + .unwrap(); + assert!( + row.get::<_, bool>(0), + "the existing trigger durably withdrew the original approval" + ); + assert_eq!( + request(&f, "POST", &assertion, &agent, false, None, None) + .await + .0, + StatusCode::FORBIDDEN + ); + let events: i64 = db.query_one("SELECT count(*) FROM casework_history WHERE item_id=$1 AND kind='task-invalidated' AND detail->>'grantId'=$2", &[&f.item,&id.to_string()]).await.unwrap().get(0); + assert_eq!(events, 1, "one permanent invalidation history entry"); + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} diff --git a/crates/registry-casework/tests/activation_postgres.rs b/crates/registry-casework/tests/activation_postgres.rs index 6220a46eeb..c2326ebde7 100644 --- a/crates/registry-casework/tests/activation_postgres.rs +++ b/crates/registry-casework/tests/activation_postgres.rs @@ -421,7 +421,7 @@ async fn plan_against_an_empty_database_writes_nothing() { assert_eq!(plan.database_id_check, DatabaseIdCheck::NotRecorded); assert_eq!(plan.plan_kind, PlanKind::Initial); assert_eq!(plan.schema_version, None); - assert_eq!(plan.pending_schema_versions.len(), 21); + assert_eq!(plan.pending_schema_versions.len(), 22); assert!(plan.changes_pending, "{:?}", plan.refusals); assert_eq!( fixture.relation_count().await, @@ -447,7 +447,7 @@ async fn initial_apply_records_the_package_and_a_previous_package_reapplies_as_a assert_eq!(applied.activation.role_mode, RoleMode::Single); assert_eq!( applied.schema_versions_applied, - (1..=21).collect::>() + (1..=22).collect::>() ); fixture .apply(&candidate(&project, &second, &[])) @@ -1398,13 +1398,20 @@ async fn an_apply_waiting_for_a_runtime_directory_lock_holds_no_migration_lock() fixture .client .batch_execute( - "DROP INDEX casework_review_decision_author_position_idx; \ - ALTER TABLE casework_review_accountability DROP COLUMN outcome; \ - DROP INDEX casework_review_task_queue_position_idx; \ - DROP INDEX casework_review_task_assigned_position_idx; \ - DROP INDEX casework_review_accountability_task_idx; \ - DROP TABLE casework_activations; \ - DELETE FROM casework_schema_migrations WHERE version>=19;", + "DROP INDEX casework_review_decision_author_position_idx; + ALTER TABLE casework_review_accountability DROP COLUMN outcome; + DROP INDEX casework_review_task_queue_position_idx; + DROP INDEX casework_review_task_assigned_position_idx; + DROP INDEX casework_review_accountability_task_idx; + DROP TABLE casework_activations; + DROP TRIGGER casework_review_assignment_generation ON casework_review_tasks; + DROP FUNCTION casework_review_assignment_generation(); + ALTER TABLE casework_review_tasks DROP COLUMN assignment_generation; + ALTER TABLE casework_task_grants DROP CONSTRAINT casework_task_grants_authorization_window; + ALTER TABLE casework_task_grants ADD CONSTRAINT casework_task_grants_check CHECK(expires_at>approved_at AND expires_at<=approved_at+interval '900 seconds'); + ALTER TABLE casework_review_task_grants DROP CONSTRAINT casework_review_task_grants_authorization_window; + ALTER TABLE casework_review_task_grants ADD CONSTRAINT casework_review_task_grants_check CHECK(expires_at>approved_at AND expires_at<=approved_at+interval '900 seconds'); + DELETE FROM casework_schema_migrations WHERE version>=19", ) .await .expect("return the schema to version 18"); @@ -1449,7 +1456,7 @@ async fn an_apply_waiting_for_a_runtime_directory_lock_holds_no_migration_lock() ); let applied = applied.expect("the first apply adopts the migrated database"); assert_eq!(applied.activation.plan_kind, PlanKind::Initial); - assert_eq!(applied.schema_versions_applied, [19, 20, 21]); + assert_eq!(applied.schema_versions_applied, [19, 20, 21, 22]); } /// A runtime role that owns a Casework table can attach a trigger that fires diff --git a/crates/registry-casework/tests/postgres_transactions.rs b/crates/registry-casework/tests/postgres_transactions.rs index 303b370f2f..5170f04837 100644 --- a/crates/registry-casework/tests/postgres_transactions.rs +++ b/crates/registry-casework/tests/postgres_transactions.rs @@ -976,6 +976,27 @@ async fn occurrence_index(client: &tokio_postgres::Client, schema: &str) -> (u32 (row.get(0), row.get(1)) } +async fn review_discovery_indexes( + client: &tokio_postgres::Client, + schema: &str, +) -> Vec<(String, u32)> { + client + .query( + "SELECT c.relname,c.oid,i.indisvalid,i.indisready FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_index i ON i.indexrelid=c.oid WHERE n.nspname=$1 AND c.relname IN ('casework_review_task_queue_position_idx','casework_review_task_assigned_position_idx','casework_review_accountability_task_idx') ORDER BY c.relname", + &[&schema], + ) + .await + .expect("read review discovery indexes") + .into_iter() + .map(|row| { + let name: String = row.get(0); + assert!(row.get::<_, bool>(2), "{name} is valid"); + assert!(row.get::<_, bool>(3), "{name} is ready"); + (name, row.get(1)) + }) + .collect() +} + async fn applied_versions(client: &tokio_postgres::Client) -> Vec { client .query( @@ -994,7 +1015,7 @@ async fn repeated_migration_is_a_ledger_no_op_and_never_drops_the_occurrence_ind let (store, client, schema) = isolated_schema("migrate").await; store.migrate().await.expect("first migration"); let applied = applied_versions(&client).await; - assert_eq!(applied, (1..=21).collect::>()); + assert_eq!(applied, (1..=22).collect::>()); let index = occurrence_index(&client, &schema).await; assert!(index.1, "the occurrence identity index is unique"); @@ -1234,7 +1255,7 @@ const SOURCE_ITEM_MIGRATIONS: [&str; 14] = [ include_str!("../migrations/0014_task_grants.sql"), ]; -async fn establish_source_schema_with_earlier_tables(client: &tokio_postgres::Client) { +async fn establish_source_schema(client: &tokio_postgres::Client) { client .batch_execute( "CREATE TABLE casework_schema_migrations (\ @@ -1246,6 +1267,10 @@ async fn establish_source_schema_with_earlier_tables(client: &tokio_postgres::Cl for (version, migration) in (1_i64..).zip(SOURCE_ITEM_MIGRATIONS) { apply_fixture_migration(client, version, migration).await; } +} + +async fn establish_source_schema_with_earlier_tables(client: &tokio_postgres::Client) { + establish_source_schema(client).await; client.batch_execute( "CREATE TABLE casework_hosted_items(item_id uuid PRIMARY KEY,kind_id text,kind_version text,kind_policy_digest text,kind_policy jsonb,queue_id text,state text,holder_issuer text,holder_subject text,revision bigint,created_at timestamptz,updated_at timestamptz); CREATE TABLE casework_hosted_actor_references(actor_ref text,issuer text,subject text); @@ -1391,7 +1416,7 @@ async fn migration_refuses_unpublished_audit_and_preserves_a_drained_outbox() { .expect("a drained outbox is preserved"); assert_eq!( applied_versions(&client).await, - (1..=21).collect::>() + (1..=22).collect::>() ); let dropped: bool = client .query_one("SELECT to_regclass('casework_audit_outbox') IS NULL", &[]) @@ -1452,10 +1477,112 @@ async fn migration_locks_hosted_work_before_counting_it_for_the_drop() { .await .expect("migration task") .expect("migration completes once the blocker releases the table"); + assert_eq!( + applied_versions(&client).await, + (1..=22).collect::>() + ); +} + +#[tokio::test] +async fn migration_22_preserves_review_discovery_and_adds_deferred_assignment_authority() { + let (store, client, schema) = isolated_schema("deferred_assignment_upgrade").await; + establish_review_discovery_predecessor(&client, 21).await; assert_eq!( applied_versions(&client).await, (1..=21).collect::>() ); + assert!( + matches!( + store.ready().await, + Err(StoreError::SchemaNotCurrent { + applied: Some(21), + required: 22 + }) + ), + "the preceding Casework schema still needs deferred authorization migration 22" + ); + let original_indexes = review_discovery_indexes(&client, &schema).await; + assert_eq!(original_indexes.len(), 3); + let before:bool=client.query_one("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='casework_review_tasks' AND column_name='assignment_generation')",&[]).await.unwrap().get(0); + assert!(!before); + store + .migrate() + .await + .expect("upgrade published discovery schema with deferred authority"); + assert_eq!( + applied_versions(&client).await, + (1..=22).collect::>() + ); + assert_eq!( + review_discovery_indexes(&client, &schema).await, + original_indexes + ); + let generation:bool=client.query_one("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='casework_review_tasks' AND column_name='assignment_generation' AND is_nullable='NO' AND column_default='1') AND EXISTS(SELECT 1 FROM pg_trigger WHERE tgrelid='casework_review_tasks'::regclass AND tgname='casework_review_assignment_generation' AND NOT tgisinternal)",&[]).await.unwrap().get(0); + assert!(generation); + // Copy the actual migrated CHECKs into bounded fixture tables, without + // foreign keys or runtime triggers, and prove both grant windows directly. + for (source,columns,values) in [ + ("casework_task_grants", "grant_id,item_id,approver_issuer,approver_subject,approver_profile,approver_role,idempotency_key,request_hash,record,approved_at,expires_at", "gen_random_uuid(),gen_random_uuid(),'https://synthetic.invalid','owner','staff','staff',gen_random_uuid()::text,repeat('a',64),$1,clock_timestamp(),clock_timestamp()+$2::text::interval"), + ("casework_review_task_grants", "grant_id,task_id,request_id,task_revision,holder_issuer,holder_subject,approver_profile,approver_role,idempotency_key,request_hash,record,approved_at,expires_at", "gen_random_uuid(),gen_random_uuid(),gen_random_uuid(),1,'https://synthetic.invalid','owner','staff','staff',gen_random_uuid()::text,repeat('a',64),$1,clock_timestamp(),clock_timestamp()+$2::text::interval"), + ] { + client.batch_execute(&format!("CREATE TEMP TABLE grant_window_fixture (LIKE {source} INCLUDING CONSTRAINTS INCLUDING DEFAULTS)")).await.unwrap(); + let insert=format!("INSERT INTO grant_window_fixture({columns}) VALUES({values})"); + let immediate=serde_json::json!({"template":{"authorizationMode":"immediate"}}); + let deferred=serde_json::json!({"template":{"authorizationMode":"deferred"}}); + assert!(client.execute(&insert,&[&immediate,&"16 minutes"]).await.is_err(),"immediate credential authority stays bounded"); + client.execute(&insert,&[&deferred,&"16 minutes"]).await.expect("governed deferred authority permits original longer deadline"); + assert!(client.execute(&insert,&[&deferred,&"8 days"]).await.is_err(),"deferred authority stays capped at seven days"); + client.batch_execute("DROP TABLE grant_window_fixture").await.unwrap(); + } + store + .ready() + .await + .expect("combined migration history is current"); + store + .migrate() + .await + .expect("combined history repeats without schema changes"); + assert_eq!( + applied_versions(&client).await, + (1..=22).collect::>() + ); + assert_eq!( + review_discovery_indexes(&client, &schema).await, + original_indexes + ); +} + +// Establish the migration history through `through` rather than removing +// fields from a newer schema. This keeps each upgrade's starting authority +// contract exact. +async fn establish_review_discovery_predecessor(client: &tokio_postgres::Client, through: i64) { + assert!((19..=21).contains(&through)); + establish_source_schema(client).await; + for (version, migration) in [ + (15, include_str!("../migrations/0015_unified_reviews.sql")), + ( + 16, + include_str!("../migrations/0016_occurrence_identity_excludes_superseded.sql"), + ), + (17, include_str!("../migrations/0017_audit_writer.sql")), + ( + 18, + include_str!("../migrations/0018_source_reconciliation_health.sql"), + ), + (19, include_str!("../migrations/0019_activations.sql")), + ( + 20, + include_str!("../migrations/0020_review_task_discovery_indexes.sql"), + ), + ( + 21, + include_str!("../migrations/0021_own_review_decisions.sql"), + ), + ] { + if version <= through { + apply_fixture_migration(client, version, migration).await; + } + } } #[tokio::test] @@ -1481,14 +1608,14 @@ async fn readiness_rejects_an_unmigrated_schema() { store.ready().await, Err(StoreError::SchemaNotCurrent { applied: None, - required: 21 + required: 22 }) ), "a schema without the migration ledger must fail readiness" ); assert_eq!( store.ready().await.unwrap_err().to_string(), - "the Casework database schema is not current: no migration has been applied, and this binary requires version 21; run `caseworkctl plan --runtime-config FILE` then `caseworkctl apply --runtime-config FILE`" + "the Casework database schema is not current: no migration has been applied, and this binary requires version 22; run `caseworkctl plan --runtime-config FILE` then `caseworkctl apply --runtime-config FILE`" ); } @@ -1511,8 +1638,8 @@ async fn readiness_rejects_a_partial_schema_missing_review_tables() { matches!( store.ready().await, Err(StoreError::SchemaNotCurrent { - applied: Some(21), - required: 21 + applied: Some(22), + required: 22 }) ), "a partial migration ledger must fail readiness" @@ -1543,15 +1670,15 @@ async fn readiness_rejects_an_unsupported_migration_version() { matches!( refusal, StoreError::SchemaNewer { - found: 22, - supported: 21 + found: 23, + supported: 22 } ), "a newer schema is not reported as corrupt data: {refusal:?}" ); assert_eq!( refusal.to_string(), - "the Casework database schema version 22 is newer than this binary supports (21); run a casework release that supports it" + "the Casework database schema version 23 is newer than this binary supports (22); run a casework release that supports it" ); } @@ -1580,8 +1707,8 @@ async fn migration_refuses_a_schema_newer_than_this_binary_and_writes_nothing() matches!( refusal, StoreError::SchemaNewer { - found: 22, - supported: 21 + found: 23, + supported: 22 } ), "{refusal:?}" @@ -2671,8 +2798,10 @@ async fn abort_during_commit( let committing: i64 = fixture .client .query_one( - "SELECT count(*) FROM pg_stat_activity - WHERE state='active' AND query='COMMIT' AND wait_event='PgSleep'", + "SELECT count(*) FROM pg_stat_activity a + WHERE state='active' AND query='COMMIT' AND wait_event='PgSleep' + AND EXISTS(SELECT 1 FROM pg_locks l WHERE l.pid=a.pid + AND l.relation='casework_items'::regclass)", &[], ) .await diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 41351c2939..f50a8ae270 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -37,15 +37,21 @@ export interface TaskPermission { collection: string; operations: ReadonlyArray< export interface SchedulingTaskPermission { service: string; location: string; actions: ReadonlyArray } export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } +/** Deferred authority keeps its approved deadline; execution credentials stay short-lived. */ +export type TaskAuthorizationMode = 'immediate' | 'deferred' export interface TaskTemplatePreview { id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger + /** Omitted for the existing immediate mode. */ + authorizationMode?: TaskAuthorizationMode } export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } /** Grant metadata deliberately excludes stored subject values. */ export interface TaskGrantView { id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean + /** Omitted for the existing immediate mode. */ + authorizationMode?: TaskAuthorizationMode } export interface TaskGrantList { grants: ReadonlyArray } export interface TaskGrantRevocation { id: string; invalidated: boolean } diff --git a/crates/registry-stack-client/src/lib.rs b/crates/registry-stack-client/src/lib.rs index ff948d990a..57ba071c27 100644 --- a/crates/registry-stack-client/src/lib.rs +++ b/crates/registry-stack-client/src/lib.rs @@ -62,9 +62,9 @@ pub mod casework { SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, SubmissionDigest, SupervisoryReviewTask, SupervisoryReviewTaskPage, SupervisoryReviewTaskQuery, SupervisoryReviewTaskState, TaskApprovalRequest, - TaskAssertionResponse, TaskGrantBounds, TaskGrantList, TaskGrantRevocation, - TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, TaskPermission, - TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, Uuid, WorkItem, + TaskAssertionResponse, TaskAuthorizationMode, TaskGrantBounds, TaskGrantList, + TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, + TaskPermission, TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, Uuid, WorkItem, WorkItemHistoryQuery, WorkItemPage, WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, }; diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md index 885897237b..dc8592370a 100644 --- a/products/breg/TASK_GRANTS.md +++ b/products/breg/TASK_GRANTS.md @@ -161,7 +161,9 @@ active. Read cursors and representation ETags do not incorporate grant IDs. The bounded status check uses the shared platform exact-status transport and runs while the proposal is locked. Its total five-second bound includes obtaining the status service credential; positive status is never cached. BReg keeps its -own persisted binding and BReg-only bounds validation. Casework revocation +own persisted binding and BReg-only bounds validation. Deferred Casework grants +use the same status contract and original deadline, without changing BReg's +write or human approval permissions. Casework revocation can still occur between that check and the local commit. BReg and Casework do not share a distributed transaction. Proposal-detail erasure also removes its retained task subjects, under the existing operator retention boundary. diff --git a/products/casework/SECURITY-REVIEW-NOTES.md b/products/casework/SECURITY-REVIEW-NOTES.md index df7073c0b8..93b5304d4d 100644 --- a/products/casework/SECURITY-REVIEW-NOTES.md +++ b/products/casework/SECURITY-REVIEW-NOTES.md @@ -83,7 +83,7 @@ two-person rule enforce it on who can read the migration credential. the same version, which verifies the package bytes and rederives the registry revision; Casework takes no crate dependency on the Base Registry Engine. A stale pin is `casework.source-revision.stale`, and a project with - no, several, or an unknown BReg source is refused rather than guessed + no, several, or an unknown Base Registry Engine (BReg) source is refused rather than guessed (`casework.source.none`, `casework.source.ambiguous`, `casework.source.unknown`). - Startup repeats the stranded pinned work comparison read-only, because a @@ -528,6 +528,74 @@ its activation ledger, and its own audit guards are unchanged. decision on a schema floor, because without one an unsupported upgrade from before the audit writer would drop unpublished audit rows silently. +## Deferred bounded task authorization + +The stored grant now distinguishes an immediate window from explicit +`authorizationMode: deferred`, with a selected duration no longer than seven +days. Immediate defaults and serialized template bytes remain unchanged. +Short assertions and execution tokens do not extend the original grant. + +The threat is delayed/restarted work needing repeated approval, or renewal +reviving, widening, or extending authority. Core template validation and the +PostgreSQL authorization-window constraints hold immediate grants to 900 +seconds and deferred grants to 604800 elapsed seconds, independent of database +timezone and daylight saving changes. Casework checks the exact registered +agent, active immutable template, approver eligibility, proposal identity and +subject facts before assertion release, and checks again after source I/O. + +The approver's own source access is observed only at approval, with the +approver's ephemeral credential. Later observations read the source through +the service reader and recheck Casework eligibility, proposal identity, and +subject facts, so a source-side withdrawal of that officer's access alone does +not end a grant before its deadline. Deferred mode stretches that reliance +from 900 seconds to the template's lifetime, at most seven days. This is an +accepted residual: repeating the approver's own source read at a later +observation would require Casework to retain a credential it must not keep. +Revocation, or removal of the officer's Casework eligibility, ends the grant +at its next observation, and `TASK_GRANTS.md` tells operators to use them when +source access is withdrawn and to keep deferred lifetimes no longer than the +work needs. + +Retained work-item grants reuse the existing item and directory invalidation +triggers. A release withdraws authority permanently in its task transaction, +before any later reclaim, while a benign revision alone does not. Assignment +owner and absence metadata alone are not legacy grant eligibility inputs; the +existing contract continues to govern the current holder and template eligibility. + +Deferred review grants bind a monotonic assignment generation maintained by a +task-row trigger. Responsibility changes increment it; draft revision changes +do not. The trigger owns the generation value even on an update attempting to +reset it. Approval captures the counter and rechecks it under the existing task +lock. Release/reclaim or reassignment back cannot revive a grant without a new +approval. A missing deferred counter fails closed. The first observed mismatch +uses the existing transactional invalidation history and audit path. + +Review grant eligibility compares the request's current subject binding with +the approved one for every grant, immediate grants included. The task revision +an immediate grant pins covers the task row and not the request row, so a +subject that differs from the approved one invalidates the grant with reason +`eligibility` even when the task revision is unchanged +(`immediate_review_task_grants_refuse_a_changed_request_subject`). + +Deferred grants cannot carry Evidence bounds (CASEWORK-SEC-27). Evidence checks +the earlier token and grant deadline and no grant status, and its contract that +an assertion stays usable after authority revocation only until the immutable +grant deadline assumes the 900-second immediate window. Core template +validation refuses `authorizationMode: deferred` with `bounds.type: evidence` +as `casework.task-template.deferred-evidence`; `caseworkctl check`, package +activation, and runtime project load all run it, and the generated project +schema carries the same condition. The assertion signer also refuses a stored +deferred Evidence grant, so no Evidence assertion is signed under the deferred +window. `deferred_authorization_cannot_carry_evidence_bounds` and +`task_assertion_refuses_a_deferred_evidence_grant` are the negative tests. + +The core mode/window boundary test and deferred HTTP regressions exercise +restart with simulated elapsed authorization time, unrelated source revision +changes, +material proposal change, principal change, expiry, revocation, drafts, +completion, responsibility transitions, and database timezone boundaries. The simulation ages only synthetic +fixture timestamps; it does not assert a real sixteen-minute wall-clock wait. + ## Exact supervisory request selection diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md index f3d11163ba..30e6e28ce3 100644 --- a/products/casework/TASK_GRANTS.md +++ b/products/casework/TASK_GRANTS.md @@ -21,7 +21,15 @@ Add `taskTemplates` to the Casework project. Each template declares: `{type: scheduling, permissions: [{service: ..., location: ..., actions: [...]}]}`. - `subjects`, mapping token identity keys to governed source logical fields. -- `lifetimeSeconds`, no more than 900 seconds. +- `lifetimeSeconds`, an explicit approved duration. Omitted `authorizationMode` + means `immediate`, with a 900-second ceiling. Opt in with + `authorizationMode: deferred` for a duration of at most 604800 seconds + (seven days). Seven days is an initial hard ceiling, not a default duration. + The preview shows this mode and duration before approval. A deferred grant + cannot carry bounds of type `evidence`: Evidence checks the grant deadline + and no grant status, so an Evidence grant keeps the 900-second immediate + window, and `caseworkctl check` reports the combination as + `casework.task-template.deferred-evidence`. Scopes are immutable approved authorization, not inferred from product operations. The assertion includes those scopes, and stock token exchange only accepts a @@ -74,7 +82,8 @@ unaffected. `statusClients` maps each resource server's service client to one exact resource audience. These clients use `casework:grants:status`. Configure the matching -BREG [`taskGrantStatus`](../breg/TASK_GRANTS.md) entry for governed writes. +Base Registry Engine (BReg) [`taskGrantStatus`](../breg/TASK_GRANTS.md) entry for +governed writes. ## Preview and approve @@ -118,12 +127,64 @@ Revocation does not require holding the task or reading its source. Resource servers call `GET /v1/task-grants/{grantId}/status`; inactive status returns no grant detail. Machine callers cannot substitute resources or subjects. -Holder eligibility loss, retired templates, and definitive proposal or subject -changes permanently invalidate grants. Temporary source failure refuses the -current request without permanent invalidation. A mutable source revision alone -does not change frozen proposal identity. +Retained work-item grants use the existing transactional item and directory +triggers: definitive holder/state/proposal loss invalidates the grant and records +history even without a status observation. Benign revision changes do not. +Retired templates and observed definitive subject changes also invalidate grants. +Deferred review grants pin an assignment generation: release, reassignment, queue movement, absence-cover +change, decision, and completion change it in the same task transaction. A +release followed by reclaim cannot revive the approval even if no status check +observed the intervening release. Immediate review grants keep the exact +ordinary task revision; deferred review grants allow draft revision changes +while the assignment, subject, proposal, and disclosed facts stay unchanged. +Temporary source failure refuses the current request without permanent +invalidation. A mutable source revision alone does not change frozen proposal +identity. + +The approving officer's own source access is checked once, at approval, with +that officer's credential. Later observations read the source with the +configured service reader: they recheck the officer's Casework eligibility, +the proposal, and the disclosed facts, but not whether the source still lets +that officer read the record. A withdrawal of that source access alone leaves +the grant active until its deadline: at most 900 seconds for an immediate +grant, and up to the template's `lifetimeSeconds`, at most seven days, for a +deferred one. When an officer loses source access, also revoke their live +grants or remove their Casework eligibility, which ends each grant at its next +observation, and keep deferred lifetimes no longer than the work needs. Offline reads may continue until the earlier access-token or grant deadline. -BREG checks fresh status before new writes, later approval, and application. -There is no distributed transaction between that check and the resource commit. -Task-bound deferred wallet offers are refused. +BReg checks fresh status before new writes, later approval, and application. +There is no distributed transaction between a status observation and a resource +commit. +Evidence wallet-delivery deferred offers carrying a task grant remain refused. + +## Resume deferred work + +Retain the grant ID, original deadline, and the owning product's logical +operation/idempotency key. After waiting or restarting, authenticate as the +same agent and construct a fresh exchange provider using the original grant +reference and deadline. An assertion refresh is neither a new authorization +nor a new operation. Expiry or cancellation requires an explicit new decision; +there is no fallback to standing service authority. Deferred work reaches BREG +or Scheduling only: deferred grants cannot carry Evidence bounds, because +Evidence accepts a grant until its deadline without checking its status. + +This mode retains the current holder's continuing authority. It does not +survive release, reassignment, or completion of the review task. Keep that task +active while the approved deferred work waits, then complete it when the +workflow no longer needs the authorization. An institutional decision that +survives task completion requires a separately designed policy. + +The grant limits allowed operations, resources, principal, purpose, subject +facts, and deadline. It does not promise one business effect. Each resource +server owns its atomic business limits and duplicate rules; preserve the same +operation key across refresh and restart to recover the same effect. A new key +names a new logical operation, still inside the approved bounds. + +Source proposal version, digest/integrity, binding generation, and exact +subject facts are material. Unrelated source revision churn is not. Temporary +source/status failure refuses the current attempt and can be retried; an +observed definitive change invalidates permanently. Review directory eligibility +is checked at observation, so removing and restoring a membership between +observations is not a historical revocation signal. Use explicit revocation +when authority must stay withdrawn. diff --git a/products/casework/contracts/security-invariant-matrix.yaml b/products/casework/contracts/security-invariant-matrix.yaml index 6e74eef7d9..6a462a0a95 100644 --- a/products/casework/contracts/security-invariant-matrix.yaml +++ b/products/casework/contracts/security-invariant-matrix.yaml @@ -23,7 +23,7 @@ invariants: - {id: CASEWORK-SEC-15, invariants: [11, 18], state: enforced, targetWave: mvp, threat: A stale timer or recompute fires an effect for completed work repeats an effect after restart or lets one review kind consume another kind's subject budget., enforcementPoint: pinned clock calculation and generation with review-kind-bound subject occurrence identity plus transactional source revision and occurrence fences, refusal: Keep concurrent review kinds on separate subject occurrences while continuing one kind across its versions; cancel terminal or superseded clocks and reject stale claims or recompute previews before recording reminders or local reassignment., negativeId: CASEWORK-NEG-15, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: subject_clocks_are_independent_across_concurrent_review_kinds}} - {id: CASEWORK-SEC-16, invariants: [1, 3, 17, 18], state: enforced, targetWave: mvp, threat: A producer or former team member records an outcome or replays a protected response; cancellation and a final decision both settle one request; a caller uses a selected profile ownership filter or cursor to cross into another profile's authority; a Staff or Administrator profile reads the supervisory task surface; one reviewer receives another reviewer's decision receipt; or a non-exact receiver response marks completion delivered., enforcementPoint: pinned unified review policy with exact producer admission; one request-row transaction fence for terminal settlement; exact-role membership and selected-profile checks against the addressed pinned stage before mutation replay disclosure; ownership filtering before pagination; exact canonical request selection before pagination and current Supervisor membership and served-queue checks on the supervisory surface; exact issuer-qualified decision authorship before own-history pagination and on a single-task receipt with current pinned deciding profile membership served queue source visibility and retention; and an empty 204 completion acknowledgement., refusal: Commit one cancellation-or-decision winner with one result and terminal event; require the selected Supervisor role and supervisor membership for assignment and supervisory discovery; require a pinned deciding profile with its matching membership for reviewer lists plus mutations and cached response replay; return an own decision receipt only to its exact retained decision author and never treat prior holding as authorship; and retry any completion response other than an empty 204 under the bounded attempt policy., negativeId: CASEWORK-NEG-16, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: cancellation_and_final_decision_commit_exactly_one_terminal_result}} - {id: CASEWORK-SEC-17, invariants: [6, 9, 17, 18], state: enforced, targetWave: mvp, threat: Retained payloads or cached replay responses disclose erased source or review data or allow rediscovery to restore it., enforcementPoint: explicit source erasure and unified review retention with transactional tombstones and bounded cursor cleanup, refusal: Preserve live recovery before erasure and refuse expired replay without returning retained payloads or rehydrating erased source items., negativeId: CASEWORK-NEG-17, negativeTest: {path: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}} - - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, closed Evidence, BREG, or Scheduling bounds, a keyed pseudonymous approver claim, standard token exchange, and a process-boundary proof that the exact exchanged Scheduling bearer passes Scheduling's real authenticator, refusal: Refuse bootstrap and status clients at the source human profile, malformed product bounds at template activation, and revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} + - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority or a release and reclaim revives a deferred grant., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, explicit immediate or bounded deferred stored authorization plus an assignment-generation fence independent of draft revisions and closed Evidence, BREG, or Scheduling bounds, a keyed pseudonymous approver claim, standard token exchange, and a process-boundary proof that the exact exchanged Scheduling bearer passes Scheduling's real authenticator, refusal: Refuse bootstrap and status clients at the source human profile, malformed product bounds at template activation, and revoked grants before a new BREG write while retaining the original grant deadline across restart; refuse deferred grants after responsibility changes even if the original holder later returns., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/http_tests.rs, name: deferred_review_grants_ignore_drafts_but_never_revive_after_responsibility_changes}} - {id: CASEWORK-SEC-19, invariants: [1, 9, 17, 18], state: enforced, targetWave: mvp, threat: A producer narrows a field the review kind never declared or a decision carries a result outside the pinned schema or request constraints., enforcementPoint: unified review create and decide validation against the pinned kind result schema and stored constraints with bounded instance paths, refusal: Accept a result only inside the pinned schema and constraints and bind changed constraints to a conflicting submission digest., negativeId: CASEWORK-NEG-19, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: prior_stage_identity_and_invalid_structured_result_emit_nothing}} - {id: CASEWORK-SEC-20, invariants: [9, 17], state: enforced, targetWave: mvp, threat: An own review decision receipt history entry result private draft replay payload constraint or clock occurrence outlives terminal retention; or submitted context or structured result survives as content inside accountability., enforcementPoint: bounded lock-skipping unified review retention erases own receipts discovery rows and private non-accountability payloads at terminalDays while retaining only minimized accountability including exact selected outcome and the existing pinned policy snapshot through accountabilityDays anchored to terminal settlement, refusal: Scrub private content own receipts discovery rows and replay responses at terminalDays then delete review-owned tombstones and minimized accountability at accountabilityDays., negativeId: CASEWORK-NEG-20, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: terminal_retention_scrubs_private_work_and_expires_owned_idempotency}} - {id: CASEWORK-SEC-21, invariants: [6, 17, 18], state: enforced, targetWave: mvp, threat: Supervisory discovery releases review payload or decision content or raw reviewer identity or a private decision reason or retained selected outcome is released after accountability expiry or without a durable record of the disclosure; or current policy edits relabel an earlier decision., enforcementPoint: the supervisory list returns only task and request identifiers queue revision a holder-free lifecycle state and an optional accountability event identifier after current Supervisor queue and source checks; the audit writer accepts the separate accountability-read request entry before one PostgreSQL transaction checks live retention and current Supervisor queue authority and then accepts the response entry before protected fields are returned; the decision transaction retains the exact outcome code in existing accountability and its label resolves only from the immutable pinned request policy without reading result or submitted payloads, refusal: Return a neutral empty exact request lookup for unknown unauthorized concealed or expired work; omit review context eligible profiles holder identity decision receipts reasons notes and producer results from supervisory discovery; omit a legacy non-approval receipt when its exact selection was erased before upgrade rather than infer approval; return absence for expired or unauthorized accountability and return service.unavailable without protected fields when the audit writer refuses either entry., negativeId: CASEWORK-NEG-21, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: accountability_read_requires_live_retention_and_a_committed_audit}} @@ -32,3 +32,4 @@ invariants: - {id: CASEWORK-SEC-24, invariants: [6, 17, 18], state: enforced, targetWave: mvp, threat: A package is activated without a durable audit record or the operator's change reference is disclosed through the ledger or the audit trail., enforcementPoint: caseworkctl apply appends the casework-activation-audit/v1 request entry before it opens the database transaction and the response entry after commit; the operator reference is stored and audited only as a keyed hash scoped by the activation id, refusal: Write nothing to the database and exit 3 when the audit destination refuses the request entry; report casework.activation.applied-unaudited with exit 3 when only the response entry is refused after commit; never store or audit the operator reference text., negativeId: CASEWORK-NEG-24, negativeTest: {path: crates/registry-casework/tests/activation_postgres.rs, name: a_refused_audit_request_leaves_the_database_untouched}} - {id: CASEWORK-SEC-25, invariants: [17, 18], state: enforced, targetWave: mvp, threat: An apply against the wrong database a concurrent apply or an apply racing runtime transactions leaves a partial activation strands pinned work or deadlocks the runtime while holding migration locks., enforcementPoint: one apply transaction under the migration advisory lock takes the runtime-order row locks before any migration DDL then checks the recorded databaseId and the pinned-work comparison before it migrates registers generations activates templates grants and records the activation, refusal: Refuse a databaseId mismatch without naming either value and refuse stranded pinned work unless the exact package digest is acknowledged; a concurrent apply waits for the lock and then plans against the committed state., negativeId: CASEWORK-NEG-25, negativeTest: {path: crates/registry-casework/tests/activation_postgres.rs, name: a_database_id_mismatch_is_refused_before_any_change}} - {id: CASEWORK-SEC-26, invariants: [3, 4], state: enforced, targetWave: mvp, threat: A package keeps serving work against a Base Registry Engine source whose compiled contract changed after the package pinned its registry revision so that items actions and projections follow a source contract the package was not authored for., enforcementPoint: runtime startup reads each source's served registry revision and compares it with the sourceRevision the package pins; caseworkctl check --against-breg-package verifies the BReg package through bregctl of the same version and compares the registry revision it rederives with each BReg source description's pin, refusal: Refuse to start on a pin the source no longer serves naming caseworkctl check PROJECT --against-breg-package DIR --source-id ID then the repin through caseworkctl source add --apply package plan and apply; check reports casework.source-revision.stale with the same repin and never guesses between sources; an unpinned source or one unreachable at startup is not refused by this check., negativeId: CASEWORK-NEG-26, negativeTest: {path: crates/registry-casework/src/runtime.rs, name: startup_refuses_a_pinned_source_revision_the_source_no_longer_serves}} + - {id: CASEWORK-SEC-27, invariants: [3, 18], state: enforced, targetWave: contextual-authorization, threat: A deferred grant with Evidence bounds outlives the immediate window Evidence relies on because Evidence checks only the earlier token and grant deadline and no grant status., enforcementPoint: core task-template validation run by caseworkctl check and package activation and runtime project load plus the generated project schema and the task assertion signer, refusal: Refuse authorizationMode deferred with bounds of type evidence as casework.task-template.deferred-evidence at /authorizationMode and refuse to sign an assertion for a stored deferred Evidence grant so every Evidence grant keeps the 900-second immediate window., negativeId: CASEWORK-NEG-27, negativeTest: {path: crates/registry-casework-core/src/task_grant.rs, name: deferred_authorization_cannot_carry_evidence_bounds}} diff --git a/products/casework/contracts/security-test-traceability.yaml b/products/casework/contracts/security-test-traceability.yaml index 0350129045..17d04773d4 100644 --- a/products/casework/contracts/security-test-traceability.yaml +++ b/products/casework/contracts/security-test-traceability.yaml @@ -19,7 +19,7 @@ entries: - {id: CASEWORK-SEC-15, tests: [{file: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}, {file: crates/registry-casework/tests/review_postgres.rs, name: subject_clock_pauses_and_continues_across_review_rounds}, {file: crates/registry-casework/tests/review_postgres.rs, name: subject_clocks_are_independent_across_concurrent_review_kinds}, {file: crates/registry-casework/tests/review_postgres.rs, name: review_activity_clock_recovers_after_holiday_publication_and_applies_effects_once}]} - {id: CASEWORK-SEC-16, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: recovery_pins_policy_and_terminal_settlement_emits_atomically}, {file: crates/registry-casework/tests/review_postgres.rs, name: concurrent_round_creation_and_duplicate_vote_leave_one_current_state}, {file: crates/registry-casework/tests/review_postgres.rs, name: cancellation_and_final_decision_commit_exactly_one_terminal_result}, {file: crates/registry-casework/tests/review_postgres.rs, name: delegation_requires_the_selected_profile_in_pinned_deciding_profiles}, {file: crates/registry-casework/tests/review_postgres.rs, name: delegation_requires_membership_matching_the_selected_role_in_a_mixed_stage}, {file: crates/registry-casework/tests/review_postgres.rs, name: assignment_requires_a_selected_supervisor_profile_despite_directory_membership}, {file: crates/registry-casework/tests/review_postgres.rs, name: reviewer_mutation_replays_require_current_exact_role_authority}, {file: crates/registry-casework/tests/review_postgres.rs, name: assignment_and_delegation_replay_use_the_addressed_pinned_stage}, {file: crates/registry-casework/tests/review_http.rs, name: requester_history_and_notes_require_the_admitted_producer_id_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: standalone_structured_answer_can_be_claimed_decided_and_polled_over_http}, {file: crates/registry-casework/tests/review_payment_fixture_postgres.rs, name: payment_http_push_and_feed_recovery_preserve_receiver_and_release_boundaries}, {file: crates/registry-casework/src/runtime.rs, name: review_completion_delivery_is_minimal_authenticated_and_stable_after_lost_ack}, {file: crates/registry-casework/src/runtime.rs, name: review_completion_presents_its_secret_in_a_configured_header_without_authorization}, {file: crates/registry-casework/tests/breg_review_journey.rs, name: breg_submitter_cannot_claim_or_decide_their_own_excluded_review}, {file: crates/registry-casework/tests/review_http.rs, name: an_initiator_reads_only_the_requester_visible_history_of_their_own_request_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: a_reviewer_confirms_from_the_task_read_whether_they_decided_it_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: reviewer_ownership_filters_precede_pagination_and_survive_state_changes_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: supervisors_discover_operational_reviews_without_decision_eligibility_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: decision_receipts_disclose_only_the_callers_retained_pinned_outcome_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: supervisory_request_lookup_precedes_pagination_and_conceals_inaccessible_requests_over_http}, {file: crates/registry-casework/tests/review_postgres.rs, name: supervisory_request_lookup_binds_scan_checkpoints_and_conceals_bounded_source_candidates}, {file: crates/registry-casework/tests/review_postgres.rs, name: own_decisions_filter_authors_scope_and_continue_past_hidden_source_candidates}, {file: crates/registry-casework/tests/review_http.rs, name: own_decision_discovery_and_audited_outcomes_are_minimal_over_http}]} - {id: CASEWORK-SEC-17, tests: [{file: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}, {file: crates/registry-casework/tests/review_postgres.rs, name: recovery_pins_policy_and_terminal_settlement_emits_atomically}, {file: crates/registry-casework/tests/review_payment_fixture_postgres.rs, name: payment_http_push_and_feed_recovery_preserve_receiver_and_release_boundaries}]} - - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: retiring_and_reintroducing_a_review_template_cannot_revive_its_grant}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-casework/src/task_grants.rs, name: governed_scheduling_bounds_are_the_platform_verifiers_wire_contract}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} + - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework-core/src/task_grant.rs, name: deferred_authorization_requires_explicit_mode_and_bounded_duration}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: deferred_database_window_uses_elapsed_seconds_across_daylight_saving_changes}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: deferred_work_item_grants_ignore_drafts_but_never_revive_after_release_and_reclaim}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: deferred_grants_resume_after_fifteen_minutes_and_restart_without_widening_authority}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: deferred_review_grants_ignore_drafts_but_never_revive_after_responsibility_changes}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: deferred_grants_expire_and_revoke_without_extending_the_approved_window}, {file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: retiring_and_reintroducing_a_review_template_cannot_revive_its_grant}, {file: crates/registry-casework/src/task_grants/http_tests.rs, name: immediate_review_task_grants_refuse_a_changed_request_subject}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-casework/src/task_grants.rs, name: governed_scheduling_bounds_are_the_platform_verifiers_wire_contract}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} - {id: CASEWORK-SEC-19, tests: [{file: crates/registry-casework-core/src/review.rs, name: structured_answers_reuse_required_result_and_schema_rules}, {file: crates/registry-casework/tests/review_postgres.rs, name: prior_stage_identity_and_invalid_structured_result_emit_nothing}, {file: crates/registry-casework/tests/review_http.rs, name: standalone_structured_answer_can_be_claimed_decided_and_polled_over_http}]} - {id: CASEWORK-SEC-20, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: terminal_retention_scrubs_private_work_and_expires_owned_idempotency}, {file: crates/registry-casework/tests/review_postgres.rs, name: retention_cleanup_skips_locked_rows_and_processes_one_bounded_batch}, {file: crates/registry-casework/tests/review_postgres.rs, name: canonical_json_bounds_allow_safe_postgresql_expansion_for_context_and_drafts}, {file: crates/registry-casework/tests/review_postgres.rs, name: an_initiator_learns_their_own_result_expired_before_and_after_erasure}, {file: crates/registry-casework/tests/review_postgres.rs, name: terminal_retention_erases_every_clock_occurrence_bound_to_an_expired_round}, {file: crates/registry-casework/tests/review_postgres.rs, name: a_subject_clock_continues_inside_the_result_window_and_restarts_after_it}, {file: crates/registry-casework/tests/review_postgres.rs, name: a_subject_clock_restarts_when_the_next_round_arrives_after_its_bound_round_expired}, {file: crates/registry-casework/tests/review_http.rs, name: decision_receipts_disclose_only_the_callers_retained_pinned_outcome_over_http}, {file: crates/registry-casework/tests/review_postgres.rs, name: accountability_receipts_pin_selected_outcomes_through_independent_retention}, {file: crates/registry-casework/tests/review_http.rs, name: own_decision_discovery_and_audited_outcomes_are_minimal_over_http}]} - {id: CASEWORK-SEC-21, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: accountability_read_requires_live_retention_and_a_committed_audit}, {file: crates/registry-casework/tests/review_http.rs, name: supervisors_discover_operational_reviews_without_decision_eligibility_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: supervisory_request_lookup_precedes_pagination_and_conceals_inaccessible_requests_over_http}, {file: crates/registry-casework/tests/review_postgres.rs, name: supervisory_request_lookup_binds_scan_checkpoints_and_conceals_bounded_source_candidates}, {file: crates/registry-casework/tests/review_postgres.rs, name: accountability_receipts_pin_selected_outcomes_through_independent_retention}, {file: crates/registry-casework/tests/review_http.rs, name: own_decision_discovery_and_audited_outcomes_are_minimal_over_http}]} @@ -28,3 +28,4 @@ entries: - {id: CASEWORK-SEC-24, tests: [{file: crates/registry-casework/tests/activation_postgres.rs, name: a_refused_audit_request_leaves_the_database_untouched}, {file: crates/registry-casework/tests/activation_postgres.rs, name: a_refused_audit_response_after_commit_reports_the_activation_applied_but_unaudited}, {file: crates/registry-casework/tests/activation_postgres.rs, name: the_same_operator_reference_in_two_activations_is_stored_under_different_hashes}, {file: crates/registry-caseworkctl/src/lib.rs, name: an_activation_audit_failure_is_operational_and_an_unaudited_commit_says_it_applied}]} - {id: CASEWORK-SEC-25, tests: [{file: crates/registry-casework/tests/activation_postgres.rs, name: a_database_id_mismatch_is_refused_before_any_change}, {file: crates/registry-casework/tests/activation_postgres.rs, name: a_concurrent_apply_waits_for_the_migration_lock}, {file: crates/registry-casework/tests/activation_postgres.rs, name: an_apply_waiting_for_a_runtime_directory_lock_holds_no_migration_lock}, {file: crates/registry-casework/tests/activation_postgres.rs, name: stranded_work_is_refused_until_the_exact_package_is_acknowledged}, {file: crates/registry-casework/tests/review_postgres.rs, name: activation_plan_refuses_a_package_that_drops_the_producer_of_an_in_flight_review}, {file: crates/registry-casework/tests/activation_postgres.rs, name: reapplying_the_active_package_is_refused_and_writes_nothing}]} - {id: CASEWORK-SEC-26, tests: [{file: crates/registry-casework/src/runtime.rs, name: startup_refuses_a_pinned_source_revision_the_source_no_longer_serves}, {file: crates/registry-casework/src/runtime.rs, name: startup_accepts_a_current_pin_an_unpinned_source_and_an_unreachable_source}, {file: crates/registry-casework-breg/tests/source_boundary.rs, name: source_revision_pin_reports_the_pinned_and_the_served_registry_revision}, {file: crates/registry-caseworkctl/src/cli_contract_tests.rs, name: check_against_a_breg_package_refuses_a_stale_pin_naming_the_check_and_the_repin}, {file: crates/registry-caseworkctl/src/cli_contract_tests.rs, name: check_against_a_breg_package_reports_a_pin_the_package_rederives_as_current}, {file: crates/registry-caseworkctl/src/cli_contract_tests.rs, name: check_against_a_breg_package_selects_one_source_and_never_guesses}, {file: crates/registry-caseworkctl/src/cli_contract_tests.rs, name: check_against_a_breg_package_carries_the_bregctl_refusal_and_its_release}]} + - {id: CASEWORK-SEC-27, tests: [{file: crates/registry-casework-core/src/task_grant.rs, name: deferred_authorization_cannot_carry_evidence_bounds}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_refuses_a_deferred_evidence_grant}]} diff --git a/products/casework/generated/project/project.schema.json b/products/casework/generated/project/project.schema.json index 7118a48279..69ee9d64c6 100644 --- a/products/casework/generated/project/project.schema.json +++ b/products/casework/generated/project/project.schema.json @@ -825,6 +825,13 @@ ], "type": "string" }, + "TaskAuthorizationMode": { + "enum": [ + "immediate", + "deferred" + ], + "type": "string" + }, "TaskGrantBounds": { "description": "The product-specific authority a task grant carries, chosen by its `type`\nmember. The shared reader's union helper decodes it so every error inside\na variant keeps its position (CFG-SCHEMA-8); the wire form is unchanged.", "oneOf": [ @@ -908,10 +915,67 @@ }, "TaskTemplate": { "additionalProperties": false, + "allOf": [ + { + "else": { + "properties": { + "lifetimeSeconds": { + "maximum": 900 + } + } + }, + "if": { + "properties": { + "authorizationMode": { + "const": "deferred" + } + }, + "required": [ + "authorizationMode" + ] + }, + "then": { + "properties": { + "lifetimeSeconds": { + "maximum": 604800 + } + } + } + }, + { + "if": { + "properties": { + "authorizationMode": { + "const": "deferred" + } + }, + "required": [ + "authorizationMode" + ] + }, + "then": { + "properties": { + "bounds": { + "properties": { + "type": { + "not": { + "const": "evidence" + } + } + } + } + } + } + } + ], "properties": { "agent": { "$ref": "#/$defs/TemplateAgent" }, + "authorizationMode": { + "$ref": "#/$defs/TaskAuthorizationMode", + "description": "Immediate authorizations last at most fifteen minutes. Deferred is an\nexplicit governed window of at most seven days; assertion TTL stays sixty seconds." + }, "bounds": { "$ref": "#/$defs/TaskGrantBounds" }, @@ -955,7 +1019,7 @@ }, "lifetimeSeconds": { "format": "uint64", - "maximum": 900, + "maximum": 604800, "minimum": 1, "type": "integer" }, diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index dbea12945e..2985ed5181 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -5866,6 +5866,15 @@ "agent": { "$ref": "#/components/schemas/IssuerPrincipal" }, + "authorizationMode": { + "default": "immediate", + "description": "Immediate keeps the 900-second ceiling. Deferred is an explicit bounded authorization window of at most seven days; credentials remain short-lived.", + "enum": [ + "immediate", + "deferred" + ], + "type": "string" + }, "bounds": { "$ref": "#/components/schemas/TaskGrantBounds" }, @@ -5964,10 +5973,47 @@ }, "TaskTemplate": { "additionalProperties": false, + "allOf": [ + { + "else": { + "properties": { + "lifetimeSeconds": { + "maximum": 900 + } + } + }, + "if": { + "properties": { + "authorizationMode": { + "const": "deferred" + } + }, + "required": [ + "authorizationMode" + ] + }, + "then": { + "properties": { + "lifetimeSeconds": { + "maximum": 604800 + } + } + } + } + ], "properties": { "agent": { "$ref": "#/components/schemas/IssuerPrincipal" }, + "authorizationMode": { + "default": "immediate", + "description": "Immediate keeps the 900-second ceiling. Deferred is an explicit bounded authorization window of at most seven days; credentials remain short-lived.", + "enum": [ + "immediate", + "deferred" + ], + "type": "string" + }, "bounds": { "$ref": "#/components/schemas/TaskGrantBounds" }, @@ -6024,7 +6070,7 @@ "type": "string" }, "lifetimeSeconds": { - "maximum": 900, + "maximum": 604800, "minimum": 1, "type": "integer" }, @@ -6106,6 +6152,15 @@ "agent": { "$ref": "#/components/schemas/IssuerPrincipal" }, + "authorizationMode": { + "default": "immediate", + "description": "Immediate keeps the 900-second ceiling. Deferred is an explicit bounded authorization window of at most seven days; credentials remain short-lived.", + "enum": [ + "immediate", + "deferred" + ], + "type": "string" + }, "bounds": { "$ref": "#/components/schemas/TaskGrantBounds" }, @@ -6128,7 +6183,7 @@ "type": "string" }, "lifetimeSeconds": { - "maximum": 900, + "maximum": 604800, "minimum": 1, "type": "integer" }, diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index 5352bae77c..95b1bf70c6 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -1335,21 +1335,25 @@ def task_schemas() -> dict: scheduling_permission = obj({"service":grant_identifier, "location":grant_identifier, "actions":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":{"type":"string", "minLength":1, "maxLength":128, "pattern":r"^[a-z][a-z0-9._:-]*$"}}}, ["service","location","actions"]) common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "scopes":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[\x21\x23-\x29\x2b-\x5b\x5d-\x7e]+$"}}, "purpose":text, "bounds":ref("TaskGrantBounds")} evidence_context = obj({"requesterTags":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[a-z][a-z0-9._-]*$"}}, "audience":{"type":"string","format":"uri","minLength":1,"maxLength":4096}}, ["requesterTags","audience"]) - preview = {"id":text, "version":text, "label":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} + preview = {"id":text, "version":text, "label":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":604800}} + preview["authorizationMode"] = {"type":"string", "enum":["immediate","deferred"], "default":"immediate", "description":"Immediate keeps the 900-second ceiling. Deferred is an explicit bounded authorization window of at most seven days; credentials remain short-lived."} template = {**preview, "eligibleTeams":array(text), "eligibleProfiles":array(text), "source":text, "reviewKinds":array(text), "itemKinds":array(text), "itemStates":{"type":"array","items":{"enum":["claimed","waiting-applicant","waiting-application"]}}} template["subjects"] = {"type":"object", "minProperties":1, "maxProperties":32, "additionalProperties":text} + template_schema = obj(template,[name for name in template if name not in ("evidenceContext", "authorizationMode")]) + template_schema["allOf"] = [{"if":{"properties":{"authorizationMode":{"const":"deferred"}},"required":["authorizationMode"]},"then":{"properties":{"lifetimeSeconds":{"maximum":604800}}},"else":{"properties":{"lifetimeSeconds":{"maximum":900}}}}] view = {"id":uuid, "templateId":text, "templateVersion":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "expiresAt":number, "invalidated":{"type":"boolean"}} + view["authorizationMode"] = preview["authorizationMode"] details = {"grantId":uuid, "sourceIssuer":text, "principal":text, "client":text, "resource":text, "purpose":text, "bounds":ref("TaskGrantBounds"), "subjects":subjects, "expiresAt":number} return { "EvidenceRequesterContext":evidence_context, - "TaskTemplate":obj(template,[name for name in template if name != "evidenceContext"]), - "TaskTemplatePreview":obj(preview,[name for name in preview if name != "evidenceContext"]), + "TaskTemplate":template_schema, + "TaskTemplatePreview":obj(preview,[name for name in preview if name not in ("evidenceContext", "authorizationMode")]), "TaskTemplatePreviews":obj({"itemRevision":number,"templates":array(ref("TaskTemplatePreview"))},["itemRevision","templates"]), "TaskPermission":permission, "SchedulingTaskPermission":scheduling_permission, "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"]), obj({"type":{"const":"scheduling"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("SchedulingTaskPermission")}},["type","permissions"])]}, "TaskApprovalRequest":obj({"templateId":text,"templateVersion":text},["templateId","templateVersion"]), - "TaskGrantView":obj(view,[name for name in view if name != "evidenceContext"]), + "TaskGrantView":obj(view,[name for name in view if name not in ("evidenceContext", "authorizationMode")]), "TaskGrantList":obj({"grants":{"type":"array","maxItems":128,"items":ref("TaskGrantView")}},["grants"]), "TaskGrantRevocation":obj({"id":uuid,"invalidated":{"type":"boolean"}},["id","invalidated"]), "TaskAssertionResponse":obj({"assertion":{"type":"string","description":"Sensitive short-lived credential. Do not log or persist."},"expiresAt":number,"grantExpiresAt":number},["assertion","expiresAt","grantExpiresAt"]), diff --git a/products/casework/scripts/test_generate_openapi.py b/products/casework/scripts/test_generate_openapi.py index a4dba43296..b8627f924f 100644 --- a/products/casework/scripts/test_generate_openapi.py +++ b/products/casework/scripts/test_generate_openapi.py @@ -60,6 +60,23 @@ def setUpClass(cls) -> None: cls.contract = GENERATOR.load_rust_contract(ROOT) cls.openapi = GENERATOR.document(cls.contract) + def test_deferred_templates_keep_immediate_default_and_explicit_window_ceiling(self) -> None: + schemas = self.openapi["components"]["schemas"] + template = schemas["TaskTemplate"] + mode = template["properties"]["authorizationMode"] + self.assertEqual(["immediate", "deferred"], mode["enum"]) + self.assertEqual("immediate", mode["default"]) + self.assertNotIn("authorizationMode", template["required"]) + window = template["allOf"][0] + self.assertEqual(["authorizationMode"], window["if"]["required"]) + self.assertEqual(900, window["else"]["properties"]["lifetimeSeconds"]["maximum"]) + self.assertEqual(604800, window["then"]["properties"]["lifetimeSeconds"]["maximum"]) + for name in ("TaskTemplatePreview", "TaskGrantView"): + self.assertEqual(mode, schemas[name]["properties"]["authorizationMode"]) + self.assertNotIn("authorizationMode", schemas[name]["required"]) + self.assertNotIn("assignmentGeneration", schemas["TaskGrantView"]["properties"]) + self.assertNotIn("authorizationMode", schemas["TaskApprovalRequest"]["properties"]) + def test_every_operation_has_its_exact_rust_owned_response_contract(self) -> None: framework = set(self.contract["frameworkProblems"]) signatures = set() diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index f870660dc5..854a0e0c0a 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -3579,7 +3579,7 @@ }, "artifact": { "path": "products/casework/generated/project/project.schema.json", - "sha256": "80f46c0a724cc9c5fe23ff079dea7b1627e44367237d1e8d3362ce124bafe2ab", + "sha256": "35075a1ccd1b0cda4846749bd48d154a2db5159d67da7cf77838122db1592412", "mediaType": "application/schema+json" } }, From df51ac516a0a841c21c56ca7e06b394f9dc9d3dc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 10:29:40 +0700 Subject: [PATCH 06/11] feat(scheduling): check fresh task-grant status before capacity work An optional taskGrantStatus registry makes Scheduling obtain a fresh exact status answer from the grant's Casework authority, through the shared platform transport, before every capacity commitment and idempotent replay, with no fallback once configured. Without it, only a grant with at most 900 seconds remaining is accepted. The capacity transaction rechecks the earlier token and grant deadline after lock waits. SCHEDULING-SEC-03 becomes enforced and the SCHEDULING-DEF-01 deferral is closed. Signed-off-by: Jeremi Joslin --- AGENTS.md | 6 +- crates/registry-scheduling/src/auth.rs | 45 +- crates/registry-scheduling/src/config.rs | 595 ++++++++++++++++- crates/registry-scheduling/src/runtime.rs | 28 +- crates/registry-scheduling/src/schema.rs | 30 + crates/registry-scheduling/src/service.rs | 376 ++++++++++- .../tests/operational_logging.rs | 1 + .../tests/postgres_commitments.rs | 625 +++++++++++++++++- .../tests/activation_postgres.rs | 1 + .../tests/intents_postgres.rs | 1 + .../tests/records_apply_postgres.rs | 3 + .../reference/apis/registry-scheduling.mdx | 56 +- .../reference/scheduling-configuration.mdx | 12 +- docs/site/src/data/scheduling-api.yaml | 4 +- products/casework/SECURITY-REVIEW-NOTES.md | 9 + products/casework/TASK_GRANTS.md | 11 +- .../identifiers/generated/catalog.v1.json | 4 +- products/platform/config-formats.yaml | 4 +- .../scripts/check-config-conformance.py | 1 + products/scheduling/RUNTIME-CONFIG.md | 79 +++ products/scheduling/SECURITY-REVIEW-NOTES.md | 151 +++-- products/scheduling/TASK_GRANTS.md | 89 ++- .../contracts/security-invariant-matrix.yaml | 68 +- .../contracts/security-test-traceability.yaml | 15 + products/scheduling/demo/support/demo.py | 8 +- products/scheduling/demo/support/test_demo.py | 7 +- .../runtime.example.yaml | 15 + .../runtime.example.yaml | 15 + .../generated/runtime/runtime.schema.json | 73 ++ 29 files changed, 2140 insertions(+), 192 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3718f0078f..fbc7cd6434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,11 @@ assertion as an ordinary relying party through `registry-evidence-client` and already does. Adding such a crate relaxes this sentence here and in the gate, in the same change; until then the sentence stands as written and no local exception may be taken to it. The runtime, the source-neutral core, the client, -and adopter tooling stay on the strict side of it in every case. +and adopter tooling stay on the strict side of it in every case. Checking a +task grant's current status at the operator-configured task authority is not +such a reach: the runtime calls that endpoint over HTTP through the +authority-neutral status client in `registry-platform-oidc`, a protocol call +and not a crate dependency, so the gate is unchanged by it. Registry Messaging is implemented by `registry-messaging`, `registry-messaging-core`, `registry-messagingctl`, and its Rust client crate. diff --git a/crates/registry-scheduling/src/auth.rs b/crates/registry-scheduling/src/auth.rs index fc7bc03cc5..9acce06607 100644 --- a/crates/registry-scheduling/src/auth.rs +++ b/crates/registry-scheduling/src/auth.rs @@ -21,6 +21,7 @@ //! partial grant claims is a malformed credential, refused here rather than //! half-trusted later. +use std::collections::BTreeMap; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -29,6 +30,7 @@ use registry_platform_oidc::{ actor_kind, grant_claims, ClaimNames, JwksFetcher, OidcError, TokenVerifier, TokenVerifierConfig, ASSERTION_ISSUER_CLAIM, }; +use serde_json::Value; use thiserror::Error; use crate::config::OidcConfig; @@ -126,6 +128,22 @@ impl SchedulingAuthenticator { .verify_context(&verified, &self.audience) .map_err(|_| AuthenticationError::Profile)?; } + // Casework exchanges preserve approved subjects in the signed + // `identity` object. Keep only an object-shaped value. The shared + // binding validates its field and value bounds before any use. The + // offline path that `unchecked-final-fifteen-minutes` selects does not + // require this member, so absence or an older shape remains a + // status-binding miss rather than changing bearer admission; a + // service that lists status authorities fails that miss closed before + // capacity is touched. + let task_grant_subjects = grant.as_ref().and_then(|_| { + verified + .claims + .extra + .get("identity") + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + }); let issuer = verified .claims .iss @@ -138,12 +156,19 @@ impl SchedulingAuthenticator { .clone() .filter(|subject| !subject.is_empty()) .ok_or(AuthenticationError::Claims)?; + let credential_exp_unix = verified + .claims + .exp + .and_then(|value| u64::try_from(value).ok()) + .ok_or(AuthenticationError::Claims)?; Ok(( Caller { actor_kind: kind.as_str().to_owned(), issuer, subject, + credential_exp_unix, grant, + task_grant_subjects, }, verified.scopes, )) @@ -328,7 +353,7 @@ mod tests { json!({ "registry_actor_kind": "agent", "registry_purpose": "registry-update", - "registry_grant_id": "grant-1", + "registry_grant_id": "11111111-1111-4111-8111-111111111111", "registry_grant_source_issuer": "https://authority.test", "registry_grant_client": CLIENT, "registry_grant_resource": resource, @@ -341,6 +366,7 @@ mod tests { "actions": ["hold.create", "appointment.create"], }], }, + "identity": {"subject_reference": "synthetic-subject"}, "registry_approver": "approver-1", }) } @@ -449,6 +475,23 @@ mod tests { .any(|action| action == "hold.create")); } + #[tokio::test] + async fn an_offline_path_grant_need_not_carry_status_binding_members() { + let mut grant = grant_claims_value(AUDIENCE); + grant["registry_grant_id"] = json!("grant-1"); + grant + .as_object_mut() + .expect("grant claims are an object") + .remove("identity"); + let credential = token(merged("scheduling-read", grant)); + let caller = authenticator() + .authenticate_mutate(&credential) + .await + .expect("the offline grant still reaches the service"); + assert!(caller.grant.is_some()); + assert!(caller.task_grant_subjects.is_none()); + } + #[tokio::test] async fn a_grant_naming_another_resource_is_a_profile_refusal() { let credential = token(merged( diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index a2aeb1058a..ea0ae7a9bd 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -24,8 +24,10 @@ use registry_platform_config::{ pub use registry_platform_config::{ AuditKeyConfig, DatabaseConfig, EnvironmentSecretProviderConfig, FileSecretProviderConfig, JwksSource, ListenerNetworkExposure, OidcClientsConfig, OidcIssuerConfig, PackageConfig, - PrivateListenerConfig as ListenerConfig, SecretProvidersConfig, TlsTermination, + PrivateListenerConfig as ListenerConfig, SecretProvidersConfig, TaskGrantStatusConfig, + TlsTermination, }; +use registry_platform_oidc::task_grant::{TaskGrantStatusEndpoint, TaskGrantStatusRegistry}; use registry_platform_oidc::{ access_token_typ_set, fetch_discovery, parse_static_jwks, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, TokenVerifierConfig, @@ -128,6 +130,18 @@ pub struct RuntimeConfig { pub secret_providers: SecretProvidersConfig, pub database: DatabaseConfig, pub authentication: AuthenticationConfig, + /// How a task grant is checked before capacity work: a list of the task + /// authorities whose fresh grant status every commitment and replay + /// checks, or `unchecked-final-fifteen-minutes` to accept only a grant + /// with at most fifteen minutes left, without a revocation check. + // Required. Optional here only so that a missing member is refused with + // a diagnostic naming both choices; the generated schema lists it as + // required. + #[cfg_attr( + feature = "schema", + schemars(required, with = "AuthoredTaskGrantStatus") + )] + pub task_grant_status: Option, pub audit: AuditConfig, #[serde(default)] pub destinations: DestinationsConfig, @@ -137,6 +151,127 @@ pub struct RuntimeConfig { /// The longest `identity.databaseId` accepted, in bytes. pub const MAX_DATABASE_ID_BYTES: usize = 256; +/// How a deployment checks a task grant before capacity work, written as +/// `taskGrantStatus` (CFG-EMPTY-2): the keyword +/// `unchecked-final-fifteen-minutes`, or a list of at least one task +/// authority. An empty list is refused, because it reads as both "check no +/// authority" and "check every authority". +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TaskGrantStatus { + /// Check no grant status: accept only a grant with at most fifteen + /// minutes left at request entry, with no revocation check. + UncheckedFinalFifteenMinutes, + /// Check fresh grant status with the listed task authorities for every + /// commitment and replay. + Authorities(Vec), +} + +const UNCHECKED_FINAL_FIFTEEN_MINUTES: &str = "unchecked-final-fifteen-minutes"; + +fn task_grant_status_refused() -> E { + Invalid::expected( + "unchecked-final-fifteen-minutes, or a list of at least one task authority", + "List the task authorities whose grant status Scheduling checks, or write unchecked-final-fifteen-minutes to accept only a grant with at most fifteen minutes left, without a revocation check.", + ) + .into_error() +} + +impl<'de> Deserialize<'de> for TaskGrantStatus { + fn deserialize>(deserializer: D) -> Result { + Ok(match AuthoredTaskGrantStatus::deserialize(deserializer)? { + AuthoredTaskGrantStatus::Unchecked(UncheckedFinalFifteenMinutes) => { + Self::UncheckedFinalFifteenMinutes + } + AuthoredTaskGrantStatus::Authorities(TaskGrantStatusAuthorities(authorities)) => { + Self::Authorities(authorities) + } + }) + } +} + +/// The authored form of `taskGrantStatus`, chosen by node kind +/// (CFG-SCHEMA-8). +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "schema", schemars(untagged, rename = "TaskGrantStatus"))] +enum AuthoredTaskGrantStatus { + /// Accept only a grant with at most fifteen minutes left at request + /// entry, with no revocation check. + Unchecked(UncheckedFinalFifteenMinutes), + /// Check fresh grant status with these task authorities for every + /// commitment and replay. + Authorities(TaskGrantStatusAuthorities), +} + +registry_platform_yaml::shape_union!(AuthoredTaskGrantStatus { + scalar => Unchecked, + list => Authorities, +}); + +/// The keyword `unchecked-final-fifteen-minutes`. +struct UncheckedFinalFifteenMinutes; + +impl<'de> Deserialize<'de> for UncheckedFinalFifteenMinutes { + fn deserialize>(deserializer: D) -> Result { + if String::deserialize(deserializer)? == UNCHECKED_FINAL_FIFTEEN_MINUTES { + Ok(Self) + } else { + Err(task_grant_status_refused()) + } + } +} + +#[cfg(feature = "schema")] +impl schemars::JsonSchema for UncheckedFinalFifteenMinutes { + fn inline_schema() -> bool { + true + } + + fn schema_name() -> std::borrow::Cow<'static, str> { + "UncheckedFinalFifteenMinutes".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({"type": "string", "const": UNCHECKED_FINAL_FIFTEEN_MINUTES}) + } +} + +/// At least one task authority. +struct TaskGrantStatusAuthorities(Vec); + +impl<'de> Deserialize<'de> for TaskGrantStatusAuthorities { + fn deserialize>(deserializer: D) -> Result { + let authorities = Vec::::deserialize(deserializer)?; + if authorities.is_empty() { + return Err(task_grant_status_refused()); + } + Ok(Self(authorities)) + } +} + +#[cfg(feature = "schema")] +impl schemars::JsonSchema for TaskGrantStatusAuthorities { + fn inline_schema() -> bool { + true + } + + fn schema_name() -> std::borrow::Cow<'static, str> { + "TaskGrantStatusAuthorities".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let mut schema = generator.subschema_for::>(); + schema.insert("minItems".to_owned(), 1.into()); + schema + } +} + +/// The shared status transport's endpoints for `configs`, in order. +pub fn task_grant_status_endpoints( + configs: &[TaskGrantStatusConfig], +) -> Vec { + configs.iter().map(TaskGrantStatusEndpoint::from).collect() +} + /// The `identity.databaseId` grammar as a JSON Schema pattern: no control /// character, and no leading or trailing Unicode whitespace. The schema's /// `maxLength` counts characters, so the byte bound @@ -561,6 +696,15 @@ impl RuntimeConfig { .map_or("", |identity| identity.database_id.as_str()) } + /// The task authorities whose grant status this deployment checks, empty + /// when it states no list. + pub fn task_grant_status_authorities(&self) -> &[TaskGrantStatusConfig] { + match &self.task_grant_status { + Some(TaskGrantStatus::Authorities(authorities)) => authorities, + Some(TaskGrantStatus::UncheckedFinalFifteenMinutes) | None => &[], + } + } + /// Return the digest of the verified package this deployment serves. pub fn package_digest(&self) -> Result { Ok(verify_scheduling_package(&self.package)? @@ -666,6 +810,18 @@ impl RuntimeConfig { document_ref, )); } + for (index, status) in self.task_grant_status_authorities().iter().enumerate() { + references.push(( + format!("taskGrantStatus.{index}.privateKeyRef"), + status.private_key_ref.as_str(), + )); + if let Some(reference) = &status.ca_bundle_ref { + references.push(( + format!("taskGrantStatus.{index}.caBundleRef"), + reference.as_str(), + )); + } + } if let Some(reference) = self .destinations .reminders @@ -758,6 +914,25 @@ impl RuntimeConfig { } } + fn check_task_grant_status(&self, findings: &mut Vec) { + let authorities = match &self.task_grant_status { + None => { + findings.push(RuntimeConfigError::MissingTaskGrantStatus); + return; + } + Some(TaskGrantStatus::UncheckedFinalFifteenMinutes) => return, + Some(TaskGrantStatus::Authorities(authorities)) => authorities, + }; + if TaskGrantStatusRegistry::validate_configuration( + &task_grant_status_endpoints(authorities), + &self.authentication.oidc.provider.audience, + ) + .is_err() + { + findings.push(RuntimeConfigError::InvalidTaskGrantStatus); + } + } + fn check_database(&self, findings: &mut Vec) { if self.database.runtime_url_ref.is_empty() || self.database.migration_url_ref.is_empty() { findings.push(RuntimeConfigError::InvalidDatabaseReference); @@ -888,6 +1063,7 @@ const FILE_RULES: &[FileRule] = &[ RuntimeConfig::check_secret_references, RuntimeConfig::check_listeners, RuntimeConfig::check_oidc, + RuntimeConfig::check_task_grant_status, RuntimeConfig::check_database, RuntimeConfig::check_destinations, ]; @@ -1091,6 +1267,7 @@ fn reads_deferred_value( ) -> bool { let reads: &[&str] = match finding { RuntimeConfigError::InvalidListener => &["/listener"], + RuntimeConfigError::InvalidTaskGrantStatus => &["/authentication/oidc/audience"], RuntimeConfigError::Block(error) if error.field().starts_with("authentication.oidc.") => { &["/listener/tlsTermination"] } @@ -1122,10 +1299,16 @@ fn stand_in_for(pointer: &str) -> &'static str { ["package", "expectedDigest"] => { "sha256:0000000000000000000000000000000000000000000000000000000000000000" } - ["authentication", "oidc", "issuer"] + ["taskGrantStatus", _, "statusPath"] => "/{grantId}", + ["taskGrantStatus", _, "baseUrl"] + | ["taskGrantStatus", _, "tokenEndpoint"] + | ["authentication", "oidc", "issuer"] | ["authentication", "oidc", "jwksSource", "uri"] | ["destinations", "reminders", "url"] | ["destinations", "hooks", _, "url"] => "https://deferred.invalid", + ["taskGrantStatus", _, "sourceIssuer"] + | ["taskGrantStatus", _, "caseworkResource"] + | ["taskGrantStatus", _, "clientAssertionAudience"] => "urn:deferred:status", _ => DEFAULT_STAND_IN, } } @@ -1190,6 +1373,12 @@ pub enum RuntimeConfigError { "authentication.oidc.allowedClients must name every client the deployment admits; unrestricted would admit every client the issuer verifies" )] AllowedClientsRequired, + #[error( + "the runtime configuration must state how a task grant is checked before capacity work; add taskGrantStatus with the task authorities whose grant status Scheduling checks, or unchecked-final-fifteen-minutes" + )] + MissingTaskGrantStatus, + #[error("taskGrantStatus must bind at most 32 unique absolute source issuers, safe service/token URLs, non-empty client and assertion audience, an absolute Casework resource, one status scope token, and a status path with one {{grantId}} segment")] + InvalidTaskGrantStatus, #[error( "database.runtimeUrlRef and database.migrationUrlRef must be non-empty secret references" )] @@ -1242,6 +1431,7 @@ impl RuntimeConfigError { Self::InvalidDestinationUrl { path, .. } => path, Self::Oidc => "authentication.oidc", Self::AllowedClientsRequired => "authentication.oidc.allowedClients", + Self::MissingTaskGrantStatus | Self::InvalidTaskGrantStatus => "taskGrantStatus", Self::OidcJwksSecret(_) => "authentication.oidc.jwksSource.documentRef", Self::InvalidListener => "listener.bind", Self::InvalidDatabaseReference => "database", @@ -1296,6 +1486,8 @@ impl RuntimeConfigError { Self::InvalidListener => "scheduling.runtime.invalid-listener", Self::InvalidOidcClaim { .. } => "scheduling.runtime.invalid-oidc-claim", Self::AllowedClientsRequired => "scheduling.runtime.allowed-clients-required", + Self::MissingTaskGrantStatus => "scheduling.runtime.missing-task-grant-status", + Self::InvalidTaskGrantStatus => "scheduling.runtime.invalid-task-grant-status", Self::InvalidDatabaseReference => "scheduling.runtime.invalid-database-reference", Self::InvalidAuditDestination(_) => "scheduling.runtime.invalid-audit", Self::InvalidDestinationUrl { .. } => "scheduling.runtime.invalid-destination-url", @@ -1341,6 +1533,12 @@ impl RuntimeConfigError { Self::InvalidOidcClaim { .. } => { "Name a non-empty value, with explainScope distinct from readsScope, or remove the member to use its default." } + Self::MissingTaskGrantStatus => { + "Add taskGrantStatus listing the task authorities whose grant status Scheduling checks, or write taskGrantStatus: unchecked-final-fifteen-minutes to accept only a grant with at most fifteen minutes left, without a revocation check." + } + Self::InvalidTaskGrantStatus => { + "Configure at most 32 distinct absolute source issuers with safe service/token URLs, non-empty client and assertion audience, an absolute Casework resource, one statusScope token, and a statusPath that starts with / and holds exactly one {grantId} segment, then retry." + } Self::AllowedClientsRequired => { "List every client identifier this deployment admits in authentication.oidc.allowedClients, then retry." } @@ -1629,6 +1827,7 @@ holdPolicy: "audience": "urn:example:scheduling", "allowedClients": ["scheduling-booking-agent"] }}, + "taskGrantStatus": "unchecked-final-fifteen-minutes", "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:file/audit"}, "destinations": {}, "retention": {"attemptReceiptRetentionDays": 7} @@ -1710,6 +1909,244 @@ holdPolicy: assert_eq!(&*policy.project.id, "registry-updates"); } + #[test] + fn offline_check_refuses_task_status_structures_before_activation() { + let root = canonical_tempdir(); + let package = root.path().join("package"); + write_policy(&package); + let status = serde_json::json!({ + "sourceIssuer":"https://casework.example.test/task-authority", + "baseUrl":"https://casework.example.test", + "tokenEndpoint":"https://identity.example.test/oauth2/token", + "clientAssertionAudience":"https://identity.example.test", + "clientId":"scheduling-task-status", + "privateKeyRef":"secret:file/missing-status-key", + "caseworkResource":"urn:example:casework", + "caBundleRef":"secret:file/missing-root-ca" + }); + for (field, value) in [ + ("sourceIssuer", "relative-issuer"), + ("sourceIssuer", "https://user@casework.test"), + ("sourceIssuer", "https://casework.test/#fragment"), + ("baseUrl", "not-a-url"), + ("baseUrl", "http://casework.test"), + ("baseUrl", "https://user@casework.test"), + ("baseUrl", "https://casework.test/#fragment"), + ("tokenEndpoint", "not-a-url"), + ("tokenEndpoint", "http://identity.test/token"), + ("tokenEndpoint", "https://user@identity.test/token"), + ("tokenEndpoint", "https://identity.test/token#fragment"), + ("clientId", ""), + ("clientId", " "), + ("clientAssertionAudience", ""), + ("caseworkResource", "relative-resource"), + ("statusScope", "two scopes"), + ("statusPath", "/v1/task-grants/status"), + ("statusPath", "relative/{grantId}"), + ] { + let mut entry = status.clone(); + entry[field] = serde_json::json!(value); + let mut document = operator_value(&package, "development-loopback"); + document["taskGrantStatus"] = serde_json::json!([entry]); + let error = RuntimeConfig::load(write_operator(root.path(), document)) + .expect_err(&format!("offline check must refuse {field}")); + assert!(error.pointer().starts_with("/taskGrantStatus"), "{error:?}"); + if !value.trim().is_empty() { + assert!( + !error.to_string().contains(value), + "diagnostic must not echo refused configuration" + ); + } + } + for count in [2, 33] { + let mut document = operator_value(&package, "development-loopback"); + let entries: Vec<_> = (0..count) + .map(|index| { + let mut entry = status.clone(); + if count == 33 { + entry["sourceIssuer"] = + serde_json::json!(format!("urn:casework:source:{index}")); + } + entry + }) + .collect(); + document["taskGrantStatus"] = serde_json::json!(entries); + assert_eq!( + RuntimeConfig::load(write_operator(root.path(), document)) + .unwrap_err() + .pointer(), + "/taskGrantStatus" + ); + } + assert!( + !root.path().join("secrets").exists(), + "offline checks never resolve the missing signer or CA" + ); + } + + #[test] + fn offline_check_accepts_bounded_status_registry_without_secrets_or_network() { + let root = canonical_tempdir(); + let package = root.path().join("package"); + write_policy(&package); + let mut document = operator_value(&package, "development-loopback"); + document["taskGrantStatus"] = serde_json::json!((0..32) + .map(|index| serde_json::json!({ + "sourceIssuer":format!("urn:casework:source:{index}"), + "baseUrl":"https://nonexistent-status.invalid/path", + "tokenEndpoint":"http://127.0.0.1:1/oauth2/token", + "clientAssertionAudience":"urn:identity:test", + "clientId":"scheduling-task-status", + "privateKeyRef":"secret:file/missing-status-key", + "caseworkResource":"urn:example:casework", + "caBundleRef":"secret:file/missing-root-ca" + })) + .collect::>()); + let config = RuntimeConfig::load(write_operator(root.path(), document)).unwrap(); + assert_eq!(config.task_grant_status_authorities().len(), 32); + assert!(!root.path().join("secrets").exists()); + } + + #[test] + fn cfg_check_1_status_structure_is_positioned_without_package_or_secrets() { + let root = canonical_tempdir(); + let mut document = + operator_value(&root.path().join("missing-package"), "development-loopback"); + document["taskGrantStatus"] = serde_json::json!([{ + "sourceIssuer": "relative-issuer", + "baseUrl": "https://status.invalid", + "tokenEndpoint": "https://identity.invalid/token", + "clientAssertionAudience": "urn:identity:test", + "clientId": "status-client", + "privateKeyRef": "secret:file/missing-status-key", + "caseworkResource": "urn:casework:test" + }]); + let path = write_operator(root.path(), document); + let check = check_runtime(&path, None, false); + assert!(!check.unavailable); + let [diagnostic] = check.diagnostics.as_slice() else { + panic!("one positioned status finding: {:?}", check.diagnostics); + }; + assert_eq!( + diagnostic.code, + "scheduling.runtime.invalid-task-grant-status" + ); + assert_eq!(diagnostic.path, "/taskGrantStatus"); + assert!(diagnostic.source.as_ref().unwrap().line.is_some()); + assert!(!diagnostic.message.contains("relative-issuer")); + assert!(!root.path().join("missing-package").exists()); + assert!(!root.path().join("secrets").exists()); + } + + #[test] + fn cfg_check_1_deferred_status_values_leave_unrelated_rules_checked() { + let root = canonical_tempdir(); + let mut document = + operator_value(&root.path().join("missing-package"), "development-loopback"); + document["identity"]["databaseId"] = serde_json::json!(" padded "); + document["taskGrantStatus"] = serde_json::json!([{ + "sourceIssuer": "${SCHEDULING_STATUS_CHECK_UNSET_SOURCE}", + "baseUrl": "${SCHEDULING_STATUS_CHECK_UNSET_BASE}", + "tokenEndpoint": "${SCHEDULING_STATUS_CHECK_UNSET_TOKEN}", + "clientAssertionAudience": "${SCHEDULING_STATUS_CHECK_UNSET_AUDIENCE}", + "clientId": "status-client", + "privateKeyRef": "secret:file/missing-status-key", + "caseworkResource": "${SCHEDULING_STATUS_CHECK_UNSET_RESOURCE}", + "statusScope": "${SCHEDULING_STATUS_CHECK_UNSET_SCOPE}", + "statusPath": "${SCHEDULING_STATUS_CHECK_UNSET_PATH}" + }]); + let path = write_operator(root.path(), document); + let check = check_runtime(&path, None, false); + let found: Vec<_> = check + .diagnostics + .iter() + .map(|d| (d.code.as_str(), d.path.as_str())) + .collect(); + assert_eq!( + found, + [( + "scheduling.runtime.invalid-database-id", + "/identity/databaseId" + )] + ); + assert!(!root.path().join("secrets").exists()); + } + + #[test] + fn a_task_grant_status_authority_loads_with_literal_secret_references() { + let root = canonical_tempdir(); + let package = root.path().join("package"); + write_policy(&package); + let mut document = operator_value(&package, "development-loopback"); + document["taskGrantStatus"] = serde_json::json!([{ + "sourceIssuer": "https://casework.example.test/task-authority", + "baseUrl": "https://casework.example.test", + "tokenEndpoint": "https://identity.example.test/oauth2/token", + "clientAssertionAudience": "https://identity.example.test", + "clientId": "scheduling-task-status", + "privateKeyRef": "secret:file/task-status-private-jwk", + "caseworkResource": "urn:example:casework", + "caBundleRef": "secret:file/institution-root-ca" + }]); + let config = RuntimeConfig::load(write_operator(root.path(), document)) + .expect("the status authority configuration is accepted"); + assert_eq!(config.task_grant_status_authorities().len(), 1); + let status = &config.task_grant_status_authorities()[0]; + assert_eq!( + status.source_issuer.as_str(), + "https://casework.example.test/task-authority" + ); + assert_eq!( + status.private_key_ref.as_str(), + "secret:file/task-status-private-jwk" + ); + assert_eq!( + status.ca_bundle_ref.as_ref().map(SecretReference::as_str), + Some("secret:file/institution-root-ca") + ); + } + + #[test] + fn status_scope_and_path_default_to_casework_and_accept_operator_values() { + let root = canonical_tempdir(); + let package = root.path().join("package"); + write_policy(&package); + let status = serde_json::json!({ + "sourceIssuer": "https://casework.example.test/task-authority", + "baseUrl": "https://casework.example.test", + "tokenEndpoint": "https://identity.example.test/oauth2/token", + "clientAssertionAudience": "https://identity.example.test", + "clientId": "scheduling-task-status", + "privateKeyRef": "secret:file/task-status-private-jwk", + "caseworkResource": "urn:example:casework" + }); + let load = |entry: serde_json::Value| { + let mut document = operator_value(&package, "development-loopback"); + document["taskGrantStatus"] = serde_json::json!([entry]); + RuntimeConfig::load(write_operator(root.path(), document)) + .expect("the status authority configuration is accepted") + }; + + let defaults = load(status.clone()); + let status_config = &defaults.task_grant_status_authorities()[0]; + assert_eq!( + status_config.status_scope.as_str(), + "casework:grants:status" + ); + assert_eq!( + status_config.status_path, + "/v1/task-grants/{grantId}/status" + ); + + let mut entry = status; + entry["statusScope"] = serde_json::json!("grants:status"); + entry["statusPath"] = serde_json::json!("/api/grants/{grantId}"); + let configured = load(entry); + let status_config = &configured.task_grant_status_authorities()[0]; + assert_eq!(status_config.status_scope.as_str(), "grants:status"); + assert_eq!(status_config.status_path, "/api/grants/{grantId}"); + } + #[test] fn a_missing_identity_block_is_refused_with_the_key_to_add() { let root = canonical_tempdir(); @@ -1730,6 +2167,96 @@ holdPolicy: ); } + #[test] + fn a_missing_task_grant_status_is_refused_naming_both_choices() { + let root = canonical_tempdir(); + let (_, mut document) = development(root.path()); + document.as_object_mut().unwrap().remove("taskGrantStatus"); + let error = RuntimeConfig::load(write_operator(root.path(), document)) + .expect_err("a configuration without taskGrantStatus is refused"); + assert_eq!( + refusal(&error), + ( + "scheduling.runtime.missing-task-grant-status".to_owned(), + "/taskGrantStatus".to_owned() + ) + ); + let action = error.suggested_action(); + assert!(action.contains("listing the task authorities"), "{action}"); + assert!( + action.contains("taskGrantStatus: unchecked-final-fifteen-minutes"), + "{action}" + ); + assert!(action.contains("without a revocation check"), "{action}"); + } + + #[test] + fn cfg_check_1_a_missing_task_grant_status_is_located_at_the_file() { + let root = canonical_tempdir(); + let mut document = operator_value(&root.path().join("package"), "development-loopback"); + document.as_object_mut().unwrap().remove("taskGrantStatus"); + let path = write_operator(root.path(), document); + let check = check_runtime(&path, None, false); + let [diagnostic] = check.diagnostics.as_slice() else { + panic!("one missing-member finding: {:?}", check.diagnostics); + }; + assert_eq!( + diagnostic.code, + "scheduling.runtime.missing-task-grant-status" + ); + assert_eq!(diagnostic.path, "/taskGrantStatus"); + assert_eq!(diagnostic.source.as_ref().unwrap().line, Some(1)); + let action = &diagnostic.suggested_action; + assert!( + action.contains("unchecked-final-fifteen-minutes"), + "{action}" + ); + } + + #[test] + fn an_empty_or_unknown_task_grant_status_is_refused_naming_both_choices() { + let root = canonical_tempdir(); + let (_, base) = development(root.path()); + for value in [ + serde_json::json!([]), + serde_json::json!("unchecked"), + serde_json::json!("unrestricted"), + serde_json::json!("Unchecked-Final-Fifteen-Minutes"), + ] { + let mut document = base.clone(); + document["taskGrantStatus"] = value.clone(); + let error = parse_runtime(&document) + .expect_err(&format!("taskGrantStatus: {value} is refused")); + assert_eq!(error.pointer(), "/taskGrantStatus", "{value}: {error}"); + assert_eq!(error.code(), "config.invalid-value", "{value}: {error}"); + assert!( + error.to_string().contains( + "unchecked-final-fifteen-minutes, or a list of at least one task authority" + ), + "{value}: {error}" + ); + let action = error.suggested_action(); + assert!(action.contains("List the task authorities"), "{action}"); + assert!( + action.contains("write unchecked-final-fifteen-minutes"), + "{action}" + ); + } + } + + #[test] + fn the_unchecked_keyword_selects_the_offline_path() { + let root = canonical_tempdir(); + let (_, document) = development(root.path()); + let config = RuntimeConfig::load(write_operator(root.path(), document)) + .expect("the keyword is accepted"); + assert_eq!( + config.task_grant_status, + Some(TaskGrantStatus::UncheckedFinalFifteenMinutes) + ); + assert!(config.task_grant_status_authorities().is_empty()); + } + #[test] fn identity_database_id_is_a_bounded_trimmed_printable_value() { let root = canonical_tempdir(); @@ -2735,6 +3262,70 @@ holdPolicy: assert!(!human.contains("canary-"), "{human}"); } + /// A `taskGrantStatus` entry is the platform's shared status block, so + /// every unknown key inside one is reported at its own line and column, + /// and the refusal never repeats the value. + #[test] + fn unknown_keys_inside_a_task_grant_status_entry_are_reported_with_positions() { + let root = canonical_tempdir(); + let mut document = operator_value(&root.path().join("package"), "development-loopback"); + document["taskGrantStatus"] = serde_json::json!([{ + "sourceIssuer": "urn:casework:source", + "baseUrl": "https://status.example.test", + "tokenEndpoint": "https://identity.example.test/token", + "clientAssertionAudience": "urn:identity:test", + "clientId": "scheduling-status", + "privateKeyRef": "secret:file/status-key", + "caseworkResource": "urn:casework:test", + "statusScopes": "canary-a", + "scopes": "canary-b" + }]); + let text = serde_norway::to_string(&document).unwrap(); + let error = RuntimeConfig::loader() + .parse_str::(&text, |_| None) + .expect_err("unknown keys inside a status entry are refused"); + let position_of = |key: &str| { + text.lines() + .enumerate() + .find_map(|(index, line)| { + line.find(&format!("{key}: canary-")) + .map(|column| (index + 1, column + 1)) + }) + .expect("the fixture holds the key") + }; + let reported: Vec<(String, String, Option, Option)> = error + .diagnostics() + .iter() + .map(|diagnostic| { + let source = diagnostic + .source + .as_ref() + .expect("a reader diagnostic has a source"); + ( + diagnostic.code.clone(), + diagnostic.path.clone(), + source.line, + source.column, + ) + }) + .collect(); + for (path, key) in [ + ("/taskGrantStatus/0/statusScopes", "statusScopes"), + ("/taskGrantStatus/0/scopes", "scopes"), + ] { + let (line, column) = position_of(key); + let expected = ( + "config.unknown-key".to_owned(), + path.to_owned(), + Some(line), + Some(column), + ); + assert!(reported.contains(&expected), "{expected:?} in {reported:?}"); + } + let human = error.to_string(); + assert!(!human.contains("canary-"), "{human}"); + } + #[test] fn an_explicit_null_is_refused_where_an_omitted_member_would_load() { let root = canonical_tempdir(); diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 0f55018232..bddb50fdfd 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -34,6 +34,7 @@ use registry_platform_httputil::destination::{ DestinationAuthorizationValue, DestinationBodyTemplate, DestinationMethod, DestinationProfile, FixedDestinationPolicy, }; +use registry_platform_oidc::task_grant::TaskGrantStatusRegistry; use thiserror::Error; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -42,7 +43,9 @@ use url::Url; use crate::audit::SchedulingAudit; use crate::auth::SchedulingAuthenticator; -use crate::config::{ReminderDestinationConfig, RuntimeConfig, RuntimeConfigError}; +use crate::config::{ + ReminderDestinationConfig, RuntimeConfig, RuntimeConfigError, TaskGrantStatus, +}; use crate::hooks::{ActivatedHooks, HookActivationError, HookRuntimeIdentity}; use crate::http::{router, HttpState}; use crate::service::SchedulingService; @@ -240,6 +243,24 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> None => None, }; + // A deployment that lists status authorities resolves every commitment + // through that exact registry. An unlisted source therefore fails closed + // instead of falling back to the short-lived offline path, which only + // `unchecked-final-fifteen-minutes` selects. Loading refuses a file that + // states neither, so an absent member is never read as the offline path. + let task_grant_status = match &config.task_grant_status { + Some(TaskGrantStatus::UncheckedFinalFifteenMinutes) => None, + Some(TaskGrantStatus::Authorities(authorities)) => Some(Arc::new( + TaskGrantStatusRegistry::activate( + &crate::config::task_grant_status_endpoints(authorities), + &config.authentication.oidc.provider.audience, + &secrets, + ) + .map_err(|_| RuntimeError::TaskGrantStatus)?, + )), + None => return Err(RuntimeError::TaskGrantStatus), + }; + let service = Arc::new( SchedulingService::new( store.clone(), @@ -251,7 +272,8 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> audit, config.retention.attempt_receipt_retention_days, ) - .with_hooks(hooks), + .with_hooks(hooks) + .with_task_grant_status(task_grant_status), ); let (worker_stopped, worker_stops) = mpsc::channel(WORKER_STOP_CAPACITY); @@ -397,6 +419,8 @@ pub enum RuntimeError { Listen(#[from] std::io::Error), #[error("the Scheduling reminder destination is invalid: {0}")] Destination(String), + #[error("the Scheduling task-grant status configuration is invalid")] + TaskGrantStatus, #[error(transparent)] HookActivation(#[from] HookActivationError), #[error("the Scheduling retained hook delivery bindings are unavailable")] diff --git a/crates/registry-scheduling/src/schema.rs b/crates/registry-scheduling/src/schema.rs index e10f4a6bc8..6e3fd3a927 100644 --- a/crates/registry-scheduling/src/schema.rs +++ b/crates/registry-scheduling/src/schema.rs @@ -212,6 +212,36 @@ mod tests { .contains(&Value::from("identity"))); } + #[test] + fn the_schema_states_both_task_grant_status_choices_and_requires_one() { + let documents = runtime_documents().unwrap(); + let document: Value = serde_json::from_str(&documents[RUNTIME_SCHEMA_FILE]).unwrap(); + // A missing member is refused at load (CFG-EMPTY-3), so the document + // requires it and declares no default. + assert!(document["required"] + .as_array() + .unwrap() + .contains(&Value::from("taskGrantStatus"))); + let member = &document["properties"]["taskGrantStatus"]; + assert!(member.get("default").is_none(), "{member}"); + let forms = member["anyOf"].as_array().expect("two forms by node kind"); + assert_eq!(forms.len(), 2, "{member}"); + let keyword = forms + .iter() + .find(|form| form["type"] == "string") + .expect("a keyword form"); + assert_eq!(keyword["const"], "unchecked-final-fifteen-minutes"); + let list = forms + .iter() + .find(|form| form["type"] == "array") + .expect("a list form"); + assert_eq!( + list["minItems"], 1, + "an empty list is refused (CFG-EMPTY-2)" + ); + assert_eq!(list["items"]["$ref"], "#/$defs/TaskGrantStatusConfig"); + } + #[test] fn the_schema_states_the_bounds_the_runtime_enforces() { let documents = runtime_documents().unwrap(); diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index a8d42ce735..9823271907 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -21,7 +21,10 @@ use registry_platform_audit::{ }; use registry_platform_calendar::CalendarInterval; use registry_platform_canonical_json::canonicalize_json; -use registry_platform_oidc::GrantClaims; +use registry_platform_oidc::{ + task_grant::{TaskGrantError, TaskGrantStatusBinding, TaskGrantStatusRegistry}, + GrantClaims, +}; use registry_scheduling_core::{ admission_request_hash, evaluate_exact_time_admission, evaluate_window_admission, location_closure_intervals, location_open_intervals, type_uri, AdmissionRequest, @@ -36,6 +39,7 @@ use registry_scheduling_core::{ use serde_json::{json, Value}; use sha2::{Digest as _, Sha256}; use std::borrow::Cow; +use std::sync::Arc; use uuid::Uuid; use crate::audit::{request_record, unfinished_record, with_event_id, SchedulingAudit}; @@ -65,6 +69,12 @@ pub const MAXIMUM_PAGE_LIMIT: usize = 200; /// a wider ask is served from the first 62 days and continues by cursor. const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62; +/// The most remaining lifetime the offline path accepts, where no status +/// authority is asked. Only `taskGrantStatus: unchecked-final-fifteen-minutes` +/// selects that path; a deployment that lists status authorities never takes +/// it, for any source, including an unlisted one. +const OFFLINE_TASK_GRANT_REMAINING_SECONDS: u64 = 900; + /// The audit reference class for a booking caller, hashed over the verified /// issuer and subject pair. History and audit carry this pseudonym, never the /// raw identity; ownership of a claim is decided on the verified pair instead, @@ -89,8 +99,16 @@ pub struct Caller { /// is retained. History and audit carry the pseudonym instead. pub issuer: String, pub subject: String, + /// The verified access token's own deadline. Capacity transactions use + /// the earlier of this and the grant deadline, bounding the interval + /// between fresh status and commit even for a long-lived grant. + pub credential_exp_unix: u64, /// The verified task grant, when the token carried one. pub grant: Option, + /// Subject claims copied only from the verified credential. A task token + /// may omit them; a configured status registry then refuses the + /// missing exact binding before a capacity transaction opens. + pub task_grant_subjects: Option>, } impl Caller { @@ -167,6 +185,7 @@ pub struct SchedulingService { audit: SchedulingAudit, attempt_receipt_days: i64, hooks: Option, + task_grant_status: Option>, } impl SchedulingService { @@ -191,6 +210,7 @@ impl SchedulingService { audit, attempt_receipt_days: i64::from(attempt_receipt_days), hooks: None, + task_grant_status: None, } } @@ -200,6 +220,12 @@ impl SchedulingService { self } + #[must_use] + pub fn with_task_grant_status(mut self, status: Option>) -> Self { + self.task_grant_status = status; + self + } + fn revision(&self) -> u64 { self.policy_revision } @@ -612,7 +638,7 @@ impl SchedulingService { facts_revision, )?; let (correlation, _request) = self - .audit_request(caller, &grant, HOLD_CREATE_ACTION) + .audit_request(caller, &grant, HOLD_CREATE_ACTION, now) .await?; let outcome = self .store @@ -672,7 +698,7 @@ impl SchedulingService { let commitment = self.commitment(caller, &actor, &grant, &release_key, &request_hash, now, 0)?; let (correlation, _request) = self - .audit_request(caller, &grant, HOLD_RELEASE_ACTION) + .audit_request(caller, &grant, HOLD_RELEASE_ACTION, now) .await?; let outcome = self.store.release_hold(hold_id, commitment).await; self.commitment_outcome( @@ -753,7 +779,7 @@ impl SchedulingService { facts_revision, )?; let (correlation, _request) = self - .audit_request(caller, &grant, APPOINTMENT_CREATE_ACTION) + .audit_request(caller, &grant, APPOINTMENT_CREATE_ACTION, now) .await?; let outcome = self .store @@ -799,7 +825,7 @@ impl SchedulingService { facts_revision, )?; let (correlation, _request) = self - .audit_request(caller, &grant, APPOINTMENT_CREATE_ACTION) + .audit_request(caller, &grant, APPOINTMENT_CREATE_ACTION, now) .await?; let outcome = self .store @@ -939,7 +965,7 @@ impl SchedulingService { facts_revision, )?; let (correlation, _request) = self - .audit_request(caller, &grant, APPOINTMENT_RESCHEDULE_ACTION) + .audit_request(caller, &grant, APPOINTMENT_RESCHEDULE_ACTION, now) .await?; let outcome = self .store @@ -1005,7 +1031,7 @@ impl SchedulingService { 0, )?; let (correlation, _request) = self - .audit_request(caller, &grant, APPOINTMENT_CANCEL_ACTION) + .audit_request(caller, &grant, APPOINTMENT_CANCEL_ACTION, now) .await?; let outcome = self .store @@ -1293,6 +1319,57 @@ impl SchedulingService { }) } + /// Establish that this exact grant is current immediately before the + /// request audit and capacity transaction. No database lock is held while + /// the remote authority is called. A configured registry is authoritative + /// for every source, so an unknown source is a refusal and never reaches + /// the bounded offline path. + async fn require_current_task_grant( + &self, + caller: &Caller, + grant: &GrantClaims, + operation: &str, + now: DateTime, + ) -> Result<(), ServiceError> { + let now = u64::try_from(now.timestamp()).map_err(|_| { + ServiceError::internal("the task-grant clock observation is not representable") + })?; + let verdict = task_grant_verdict( + self.task_grant_status.as_deref(), + caller.task_grant_subjects.clone(), + grant, + now, + ) + .await; + match verdict { + Ok(()) => Ok(()), + Err(TaskGrantError::Refused) => { + self.record_refusal( + Uuid::new_v4(), + audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + operation, + AuthorizationOutcome::Denied, + "authorization.refused", + ), + ) + .await?; + Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)) + } + Err(TaskGrantError::Unavailable) => { + tracing::warn!("the task-grant authority could not establish current status"); + Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)) + } + Err(TaskGrantError::Configuration) => { + tracing::error!("the activated task-grant status registry became unusable"); + Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)) + } + } + } + /// Append the `request` entry of one commitment and return the /// correlation its `response` entry will carry, with the handle that owes /// it. It names the caller, the grant, and the operation the capacity @@ -1306,7 +1383,10 @@ impl SchedulingService { caller: &Caller, grant: &GrantClaims, operation: &str, + now: DateTime, ) -> Result<(Uuid, AuditRequest), ServiceError> { + self.require_current_task_grant(caller, grant, operation, now) + .await?; let record = audit_record( &self.hasher, &self.scheduling_id, @@ -1474,7 +1554,7 @@ impl SchedulingService { idempotency_key, request_hash, attempt_expires_at, - grant_exp_unix: Some(grant.exp()), + grant_exp_unix: Some(grant.exp().min(caller.credential_exp_unix)), hooks: self.hooks.as_ref(), }) } @@ -1713,6 +1793,32 @@ impl SchedulingService { } } +/// Decide whether one verified task grant is current at `now`, in Unix +/// seconds. Listed status authorities are authoritative for every source: a +/// grant without the signed subjects its status binding needs, or from a +/// source no authority is listed for, is refused and never reaches the +/// offline path. Without them, the offline path accepts a grant only while it +/// is unexpired with at most fifteen minutes left, and asks no authority. +async fn task_grant_verdict( + status: Option<&TaskGrantStatusRegistry>, + subjects: Option>, + grant: &GrantClaims, + now: u64, +) -> Result<(), TaskGrantError> { + match status { + Some(status) => match subjects + .and_then(|subjects| TaskGrantStatusBinding::from_verified(grant, subjects).ok()) + { + Some(binding) => status.check(&binding).await, + None => Err(TaskGrantError::Refused), + }, + None if grant.exp() > now && grant.exp() - now <= OFFLINE_TASK_GRANT_REMAINING_SECONDS => { + Ok(()) + } + None => Err(TaskGrantError::Refused), + } +} + /// The minted-claim projection each commitment answer carries. pub trait FromMinted { fn from_minted(outcome: CommitOutcome) -> Self; @@ -2964,4 +3070,258 @@ mod tests { } )); } + + const STATUS_GRANT_ID: &str = "11111111-1111-4111-8111-111111111111"; + const STATUS_SOURCE: &str = "https://authority.test"; + const STATUS_RESOURCE: &str = "urn:registry-scheduling:test"; + + fn unix_now() -> u64 { + u64::try_from(Utc::now().timestamp()).expect("a representable clock") + } + + fn status_bounds() -> Value { + json!({"type": "scheduling", "permissions": [{ + "service": "registry-update", + "location": "north-counter", + "actions": ["appointment.create"], + }]}) + } + + /// One task grant as authentication hands it to the service, read at + /// `read_at` so a test can decide it at a later instant. + fn verified_grant(source_issuer: &str, expires_at: u64, read_at: u64) -> GrantClaims { + let claims: registry_platform_oidc::Claims = serde_json::from_value(json!({ + "sub": "principal-agent", + "exp": expires_at, + "registry_actor_kind": "agent", + "registry_purpose": "registry-update", + "registry_grant_id": STATUS_GRANT_ID, + "registry_grant_source_issuer": source_issuer, + "registry_grant_client": "task-agent", + "registry_grant_resource": STATUS_RESOURCE, + "registry_grant_exp": expires_at, + "registry_grant_bounds": status_bounds(), + "registry_approver": "approver-1", + })) + .expect("the grant claims"); + registry_platform_oidc::grant_claims( + &claims, + ®istry_platform_oidc::ClaimNames::default(), + read_at, + ) + .expect("a complete grant") + .expect("a grant is present") + } + + fn status_subjects() -> std::collections::BTreeMap { + std::collections::BTreeMap::from([( + "subject_reference".to_owned(), + json!("synthetic-subject"), + )]) + } + + fn active_answer(grant: &GrantClaims) -> Value { + json!({"active": true, "grant": { + "grantId": STATUS_GRANT_ID, + "sourceIssuer": STATUS_SOURCE, + "principal": "principal-agent", + "client": "task-agent", + "resource": STATUS_RESOURCE, + "purpose": "registry-update", + "bounds": status_bounds(), + "subjects": {"subject_reference": "synthetic-subject"}, + "expiresAt": grant.exp(), + }}) + } + + /// A status registry that lists only `STATUS_SOURCE`, backed by a mock + /// authority that issues a service credential and answers every status + /// call with `response`. + async fn listed_authority( + response: wiremock::ResponseTemplate, + ) -> (wiremock::MockServer, TaskGrantStatusRegistry) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "synthetic-status-service", + "token_type": "Bearer", + "expires_in": 300, + "scope": "casework:grants:status" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v1/task-grants/{STATUS_GRANT_ID}/status"))) + .respond_with(response) + .mount(&server) + .await; + + let secret_name = + format!("SCHEDULING_VERDICT_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .expect("generate the status client key"); + key.alg = Some("RS256".into()); + let mut private = serde_json::to_value(&key).expect("serialize the status client key"); + for (name, value) in [ + ("d", &key.d), + ("p", &key.p), + ("q", &key.q), + ("dp", &key.dp), + ("dq", &key.dq), + ("qi", &key.qi), + ] { + if let Some(value) = value { + private[name] = Value::String(value.clone()); + } + } + std::env::set_var( + &secret_name, + serde_json::to_string(&private).expect("encode the status client key"), + ); + let secrets = registry_platform_config::SecretResolver::new( + [registry_platform_config::SecretProvider::Environment], + "", + ) + .expect("the status client secret resolver"); + let base = server.uri(); + let status: crate::config::TaskGrantStatusConfig = serde_json::from_value(json!({ + "sourceIssuer": STATUS_SOURCE, + "baseUrl": base, + "tokenEndpoint": format!("{base}/token"), + "clientAssertionAudience": base, + "clientId": "scheduling-status", + "privateKeyRef": format!("secret:env/{secret_name}"), + "caseworkResource": "urn:casework:test" + })) + .expect("the status authority configuration"); + let registry = TaskGrantStatusRegistry::activate( + &crate::config::task_grant_status_endpoints(&[status]), + STATUS_RESOURCE, + &secrets, + ) + .expect("activate the task-grant status registry"); + (server, registry) + } + + async fn status_calls(server: &wiremock::MockServer) -> usize { + server + .received_requests() + .await + .expect("recorded authority calls") + .into_iter() + .filter(|request| request.url.path().ends_with("/status")) + .count() + } + + #[tokio::test] + async fn the_offline_path_accepts_only_an_unexpired_grant_with_at_most_fifteen_minutes_left() { + let now = u64::try_from(at(9, 0).timestamp()).expect("a representable instant"); + for (case, expires_at, expected) in [ + ("one second left", now + 1, Ok(())), + ("exactly fifteen minutes left", now + 900, Ok(())), + ( + "one second more than fifteen minutes left", + now + 901, + Err(TaskGrantError::Refused), + ), + ("an hour left", now + 3_600, Err(TaskGrantError::Refused)), + ( + "expiring at request entry", + now, + Err(TaskGrantError::Refused), + ), + ("expired", now - 60, Err(TaskGrantError::Refused)), + ] { + let grant = verified_grant(STATUS_SOURCE, expires_at, now - 600); + for subjects in [None, Some(status_subjects())] { + assert_eq!( + task_grant_verdict(None, subjects, &grant, now).await, + expected, + "{case}" + ); + } + } + } + + #[tokio::test] + async fn listed_authorities_refuse_an_unlisted_source_and_an_unbound_grant_without_the_offline_path( + ) { + let now = unix_now(); + let (server, registry) = listed_authority( + wiremock::ResponseTemplate::new(200).set_body_json(json!({"active": false})), + ) + .await; + // Ten minutes left would pass the offline path; a listed registry + // never offers it. + let unlisted = verified_grant("https://other-authority.test", now + 600, now); + assert_eq!( + task_grant_verdict(Some(®istry), Some(status_subjects()), &unlisted, now).await, + Err(TaskGrantError::Refused), + "an unlisted source is refused" + ); + let unbound = verified_grant(STATUS_SOURCE, now + 600, now); + assert_eq!( + task_grant_verdict(Some(®istry), None, &unbound, now).await, + Err(TaskGrantError::Refused), + "a grant without signed subjects is refused" + ); + assert_eq!( + task_grant_verdict( + Some(®istry), + Some(std::collections::BTreeMap::new()), + &unbound, + now + ) + .await, + Err(TaskGrantError::Refused), + "a grant with an empty subject set is refused" + ); + assert_eq!( + status_calls(&server).await, + 0, + "no refusal above reaches the authority" + ); + } + + #[tokio::test] + async fn a_listed_authority_answer_decides_the_grant() { + let now = unix_now(); + // An hour left: the listed path is not bounded to fifteen minutes. + let grant = verified_grant(STATUS_SOURCE, now + 3_600, now); + for (case, response, expected) in [ + ( + "active", + wiremock::ResponseTemplate::new(200).set_body_json(active_answer(&grant)), + Ok(()), + ), + ( + "inactive", + wiremock::ResponseTemplate::new(200).set_body_json(json!({"active": false})), + Err(TaskGrantError::Refused), + ), + ( + "unavailable", + wiremock::ResponseTemplate::new(503), + Err(TaskGrantError::Unavailable), + ), + ] { + let (server, registry) = listed_authority(response).await; + assert_eq!( + task_grant_verdict(Some(®istry), Some(status_subjects()), &grant, now).await, + expected, + "{case}" + ); + assert_eq!( + status_calls(&server).await, + 1, + "{case}: one fresh status call" + ); + } + } } diff --git a/crates/registry-scheduling/tests/operational_logging.rs b/crates/registry-scheduling/tests/operational_logging.rs index 24d43282f1..6a6315dc89 100644 --- a/crates/registry-scheduling/tests/operational_logging.rs +++ b/crates/registry-scheduling/tests/operational_logging.rs @@ -49,6 +49,7 @@ fn serve_with_stdout_audit(rust_log: Option<&str>) -> std::process::Output { "audience": "urn:example:scheduling", "allowedClients": ["scheduling-test-client"] }}, + "taskGrantStatus": "unchecked-final-fifteen-minutes", "audit": {"destination": "stdout", "hashKeyRef": "secret:env/SCHEDULING_TEST_AUDIT_KEY"}, "destinations": {}, "retention": {"attemptReceiptRetentionDays": 7} diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 17b927807f..ed13ffcf90 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -22,7 +22,10 @@ use registry_platform_audit::{AuditHashSecret, AuditKeyHasher}; use registry_platform_config::{SecretProvider, SecretResolver}; use registry_platform_hooks::delivery::DeliveryOutcome; use registry_platform_hooks::{EnvelopeLimits, HookEnvelope, HookHandlerSource}; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_platform_oidc::{ + task_grant::{TaskGrantStatusEndpoint, TaskGrantStatusRegistry}, + JwksFetcher, JwksFetcherConfig, TokenVerifierConfig, +}; use registry_scheduling::audit::{AuditCapture, SchedulingAudit, SCHEDULING_AUDIT_SCHEMA}; use registry_scheduling::auth::SchedulingAuthenticator; use registry_scheduling::config::{ @@ -286,6 +289,15 @@ async fn fixture_publishing_with_hook_url( policy: SchedulingPolicy, pool_ids: &[String], hook_url: &str, +) -> Fixture { + fixture_publishing_with_hook_and_status(policy, pool_ids, hook_url, None).await +} + +async fn fixture_publishing_with_hook_and_status( + policy: SchedulingPolicy, + pool_ids: &[String], + hook_url: &str, + task_grant_status: Option>, ) -> Fixture { let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); @@ -405,7 +417,8 @@ async fn fixture_publishing_with_hook_url( audit.clone(), 7, ) - .with_hooks(hooks.clone()), + .with_hooks(hooks.clone()) + .with_task_grant_status(task_grant_status), ); let http = router(HttpState { service, @@ -427,6 +440,98 @@ async fn fixture_publishing_with_hook_url( } } +fn exact_task_status_grant() -> Value { + json!({ + "grantId": "11111111-1111-4111-8111-111111111111", + "sourceIssuer": "https://authority.test", + "principal": "principal-agent", + "client": CLIENT, + "resource": AUDIENCE, + "purpose": "registry-update", + "bounds": grant_claims()["registry_grant_bounds"].clone(), + "subjects": {"subject_reference": "synthetic-subject"}, + "expiresAt": pinned_now().timestamp() + 600, + }) +} + +async fn activated_task_status( + response: ResponseTemplate, +) -> (MockServer, Arc) { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "synthetic-status-service", + "token_type": "Bearer", + "expires_in": 300, + "scope": "casework:grants:status" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/v1/task-grants/11111111-1111-4111-8111-111111111111/status", + )) + .respond_with(response) + .mount(&server) + .await; + + let secret_name = format!("SCHEDULING_STATUS_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .expect("generate the status client key"); + key.alg = Some("RS256".into()); + let mut private = serde_json::to_value(&key).expect("serialize the status client key"); + for (name, value) in [ + ("d", &key.d), + ("p", &key.p), + ("q", &key.q), + ("dp", &key.dp), + ("dq", &key.dq), + ("qi", &key.qi), + ] { + if let Some(value) = value { + private[name] = Value::String(value.clone()); + } + } + std::env::set_var( + &secret_name, + serde_json::to_string(&private).expect("encode the status client key"), + ); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp") + .expect("the status client secret resolver"); + let base = server.uri(); + let status: registry_scheduling::config::TaskGrantStatusConfig = + serde_json::from_value(json!({ + "sourceIssuer": "https://authority.test", + "baseUrl": base, + "tokenEndpoint": format!("{base}/token"), + "clientAssertionAudience": base, + "clientId": "scheduling-status", + "privateKeyRef": format!("secret:env/{secret_name}"), + "caseworkResource": "urn:casework:test" + })) + .expect("the status authority configuration"); + let registry = TaskGrantStatusRegistry::activate( + &[TaskGrantStatusEndpoint::from(&status)], + AUDIENCE, + &secrets, + ) + .expect("activate the task-grant status registry"); + (server, Arc::new(registry)) +} + +async fn fixture_with_task_status(status: Arc) -> Fixture { + fixture_publishing_with_hook_and_status( + read_policy(POLICY).expect("the scheduling test policy"), + &["north-counter".into(), "two-counter".into()], + "http://127.0.0.1:9/scheduling-hooks", + Some(status), + ) + .await +} + fn authenticator() -> SchedulingAuthenticator { let oidc = OidcConfig { provider: OidcIssuerConfig { @@ -558,7 +663,9 @@ fn token(mut claims: Value) -> String { object.entry("iss").or_insert(json!(ISSUER)); object.entry("aud").or_insert(json!(AUDIENCE)); object.entry("iat").or_insert(json!(now)); - object.entry("exp").or_insert(json!(now + 300)); + object + .entry("exp") + .or_insert(json!(pinned_now().timestamp() + 300)); let mut header = Header::new(Algorithm::HS256); header.kid = Some("test".to_owned()); header.typ = Some("at+jwt".to_owned()); @@ -580,7 +687,7 @@ fn grant_claims() -> Value { json!({ "registry_actor_kind": "agent", "registry_purpose": "registry-update", - "registry_grant_id": "grant-1", + "registry_grant_id": "11111111-1111-4111-8111-111111111111", "registry_grant_source_issuer": "https://authority.test", "registry_grant_client": CLIENT, "registry_grant_resource": AUDIENCE, @@ -603,6 +710,7 @@ fn grant_claims() -> Value { "actions": ["appointment.create"], }], }, + "identity": {"subject_reference": "synthetic-subject"}, "registry_approver": "approver-1", }) } @@ -611,6 +719,54 @@ fn agent_token() -> String { agent_token_for("principal-agent") } +fn agent_token_with_grant_exp(grant_exp: i64) -> String { + let mut claims = json!({ + "sub": "principal-agent", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + let mut grant = grant_claims(); + grant["registry_grant_exp"] = json!(grant_exp); + claims + .as_object_mut() + .expect("claims are an object") + .extend( + grant + .as_object() + .expect("grant claims are an object") + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +fn agent_token_without_status_binding() -> String { + let mut grant = grant_claims(); + grant["registry_grant_id"] = json!("grant-1"); + grant + .as_object_mut() + .expect("grant claims are an object") + .remove("identity"); + let mut claims = json!({ + "sub": "principal-agent", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + claims + .as_object_mut() + .expect("claims are an object") + .extend( + grant + .as_object() + .expect("grant claims are an object") + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + /// A second booking agent, identical in scope and grant and different only in /// subject, so what it may see is decided by ownership alone. fn agent_token_for(subject: &str) -> String { @@ -2718,7 +2874,15 @@ async fn a_hold_that_waited_for_the_supply_lock_keeps_its_whole_ttl() { let fx = fixture().await; let slot = first_slot(&fx, OFFERING, 90, 200).await; let body = admission(&fx, OFFERING, slot); - let (http, agent, store) = (fx.http.clone(), fx.agent.clone(), fx.store.clone()); + // This success case keeps both the access token and grant live after the + // five-minute lock wait. The fixture's default token expires at that exact + // boundary and belongs to the separate credential-expiry refusal tests. + let mut claims = grant_claims(); + claims["sub"] = json!("principal-agent"); + claims["azp"] = json!(CLIENT); + claims["registry_scopes"] = json!("scheduling-read scheduling-explain"); + claims["exp"] = json!(pinned_now().timestamp() + 600); + let (http, agent, store) = (fx.http.clone(), token(claims), fx.store.clone()); let mut admin = fx.admin; // Stand in for a commitment mid-evaluation on the same pool. @@ -2773,7 +2937,7 @@ async fn a_hold_that_waited_for_the_supply_lock_keeps_its_whole_ttl() { } // Five minutes pass on the store's clock while the create waits: half - // the authored TTL, and still inside the test grant's ten minutes. + // the authored TTL, and still inside both authority deadlines. let decided = DateTime::from_timestamp(entered.timestamp() + 300, 0).expect("an instant"); store.pin_clock(Arc::new(move || decided)); holder.commit().await.expect("the stand-in commits"); @@ -4458,6 +4622,7 @@ fn sealed_deployment( "audience": AUDIENCE, "allowedClients": [CLIENT], }}, + "taskGrantStatus": "unchecked-final-fifteen-minutes", "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:env/UNUSED"}, "destinations": {}, "retention": {"attemptReceiptRetentionDays": 7}, @@ -7073,6 +7238,371 @@ async fn a_reschedule_and_a_cancellation_commit_exactly_one_transition() { ); } +#[tokio::test] +async fn configured_task_status_is_fresh_for_a_capacity_commit_and_its_replay() { + let response = ResponseTemplate::new(200) + .set_body_json(json!({"active": true, "grant": exact_task_status_grant()})); + let (authority, status) = activated_task_status(response).await; + let fx = fixture_with_task_status(status).await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let (first_status, first) = fx + .post("/v1/appointments", &fx.agent, "status-replay", body.clone()) + .await; + assert_eq!(first_status, StatusCode::CREATED, "{first}"); + let (replay_status, replay) = fx + .post("/v1/appointments", &fx.agent, "status-replay", body) + .await; + assert_eq!(replay_status, StatusCode::CREATED, "{replay}"); + assert_eq!(replay["appointmentId"], first["appointmentId"]); + let other = first_slot(&fx, OFFERING, 300, 440).await; + let (unbound_status, unbound_problem) = fx + .post( + "/v1/appointments", + &agent_token_without_status_binding(), + "configured-requires-binding", + json!({"hold": null, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(unbound_status, StatusCode::FORBIDDEN, "{unbound_problem}"); + let status_calls = authority + .received_requests() + .await + .expect("recorded authority calls") + .into_iter() + .filter(|request| request.url.path().ends_with("/status")) + .count(); + assert_eq!( + status_calls, 2, + "the replay checks again and a missing binding never reaches the authority" + ); + let bookings: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("count bookings") + .get(0); + assert_eq!(bookings, 1, "the replay does not duplicate capacity"); +} + +#[tokio::test] +async fn inactive_mismatched_and_unavailable_task_status_commit_no_capacity() { + for (case, response, expected_status) in [ + ( + "inactive", + ResponseTemplate::new(200).set_body_json(json!({"active": false})), + StatusCode::FORBIDDEN, + ), + ( + "mismatched", + ResponseTemplate::new(200).set_body_json({ + let mut grant = exact_task_status_grant(); + grant["bounds"]["permissions"][0]["service"] = json!("another-service"); + json!({"active": true, "grant": grant}) + }), + StatusCode::FORBIDDEN, + ), + ( + "unavailable", + ResponseTemplate::new(503), + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + "status-service-unauthenticated", + ResponseTemplate::new(401), + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + "status-service-unauthorized", + ResponseTemplate::new(403), + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + "grant-concealed-or-missing", + ResponseTemplate::new(404), + StatusCode::FORBIDDEN, + ), + ] { + let (_authority, status) = activated_task_status(response).await; + let fx = fixture_with_task_status(status).await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (actual, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + case, + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(actual, expected_status, "{case}: {problem}"); + let bookings: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("count bookings") + .get(0); + assert_eq!(bookings, 0, "{case} status commits no capacity"); + } +} + +/// Every transactional business effect of a commitment: the claims, their +/// history, the outbox, and the successful attempt receipts. A refusal may add +/// its own refused receipt, which this snapshot leaves out. +const BUSINESS_STATE: &str = "SELECT jsonb_build_array( \ + (SELECT jsonb_agg(to_jsonb(c) ORDER BY claim_id) FROM scheduling_claims c), \ + (SELECT jsonb_agg(to_jsonb(h) ORDER BY event_id) FROM scheduling_history h), \ + (SELECT jsonb_agg(to_jsonb(o) ORDER BY outbox_id) FROM scheduling_outbox o), \ + (SELECT count(*) FROM scheduling_attempts WHERE status_code BETWEEN 200 AND 299))"; + +const TASK_STATUS_PATH: &str = "/v1/task-grants/11111111-1111-4111-8111-111111111111/status"; + +async fn task_status_calls(authority: &MockServer) -> usize { + authority + .received_requests() + .await + .expect("recorded authority calls") + .into_iter() + .filter(|request| request.url.path().ends_with("/status")) + .count() +} + +/// SEC-03. Each capacity mutation asks the listed authority before its +/// transaction opens: an inactive answer refuses it with +/// `operation.not-authorized`, an unavailable authority with +/// `service.unavailable`, and neither changes a claim, its history, the +/// outbox, or a successful receipt. +#[tokio::test] +async fn inactive_and_unavailable_task_status_refuse_every_mutation_without_changing_capacity() { + for (answer, response, expected_status, expected_code) in [ + ( + "inactive", + ResponseTemplate::new(200).set_body_json(json!({"active": false})), + StatusCode::FORBIDDEN, + "operation.not-authorized", + ), + ( + "unavailable", + ResponseTemplate::new(503), + StatusCode::SERVICE_UNAVAILABLE, + "service.unavailable", + ), + ] { + for operation in ["hold", "release", "confirm", "reschedule", "cancel"] { + let case = format!("{operation} with {answer} status"); + let (authority, status) = activated_task_status(response.clone()).await; + // The commitment that sets the case up sees an active answer; the + // mutation under test sees the case's answer. + let setup_calls: usize = if operation == "hold" { 0 } else { 1 }; + if setup_calls > 0 { + Mock::given(method("GET")) + .and(path(TASK_STATUS_PATH)) + .respond_with( + ResponseTemplate::new(200).set_body_json( + json!({"active": true, "grant": exact_task_status_grant()}), + ), + ) + .up_to_n_times(1) + .with_priority(1) + .mount(&authority) + .await; + } + let fx = fixture_with_task_status(status).await; + let slot = first_slot(&fx, OFFERING, 480, 620).await; + let (verb, uri, body) = match operation { + "hold" => ( + "POST", + "/v1/holds".to_owned(), + Some(admission(&fx, OFFERING, slot)), + ), + "confirm" | "release" => { + let (created, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "setup", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(created, StatusCode::CREATED, "{case}: {hold}"); + let id = hold["holdId"].as_str().expect("a hold identifier"); + if operation == "confirm" { + ( + "POST", + "/v1/appointments".to_owned(), + Some(json!({"hold": id, "admission": null})), + ) + } else { + ("DELETE", format!("/v1/holds/{id}"), None) + } + } + "reschedule" | "cancel" => { + let (id, revision) = booked(&fx, 300, 440, "setup").await; + if operation == "reschedule" { + ( + "POST", + format!("/v1/appointments/{id}/reschedule"), + Some(json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, slot), + })), + ) + } else { + ( + "POST", + format!("/v1/appointments/{id}/cancel"), + Some(json!({"observedRevision": revision, "reason": null})), + ) + } + } + _ => unreachable!(), + }; + let before: Value = fx + .admin + .query_one(BUSINESS_STATE, &[]) + .await + .expect("snapshot before the mutation") + .get(0); + let (actual, problem) = fx + .request(verb, &uri, &fx.agent, Some("status-refused"), body) + .await; + assert_eq!(actual, expected_status, "{case}: {problem}"); + assert_eq!(problem["code"], expected_code, "{case}"); + assert_eq!( + task_status_calls(&authority).await, + setup_calls + 1, + "{case}: the mutation asked the authority once" + ); + let after: Value = fx + .admin + .query_one(BUSINESS_STATE, &[]) + .await + .expect("snapshot after the refusal") + .get(0); + assert_eq!(after, before, "{case} changes no capacity"); + } + } +} + +/// A grant signed by a source issuer the listed authorities do not name. +fn agent_token_from_source(source_issuer: &str) -> String { + let mut grant = grant_claims(); + grant["registry_grant_source_issuer"] = json!(source_issuer); + let mut claims = json!({ + "sub": "principal-agent", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + claims + .as_object_mut() + .expect("claims are an object") + .extend( + grant + .as_object() + .expect("grant claims are an object") + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// SEC-03. Listed authorities are authoritative for every source. A grant +/// from a source none of them names is refused with +/// `operation.not-authorized` without a status call, although its ten +/// minutes left would pass the offline path, which books the same grant. +#[tokio::test] +async fn an_unlisted_source_is_refused_and_never_reaches_the_offline_path() { + let unlisted = agent_token_from_source("https://other-authority.test"); + let (authority, status) = activated_task_status( + ResponseTemplate::new(200) + .set_body_json(json!({"active": true, "grant": exact_task_status_grant()})), + ) + .await; + let fx = fixture_with_task_status(status).await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let before: Value = fx + .admin + .query_one(BUSINESS_STATE, &[]) + .await + .expect("snapshot before the commitment") + .get(0); + let (refused, problem) = fx + .post( + "/v1/appointments", + &unlisted, + "unlisted-source", + body.clone(), + ) + .await; + assert_eq!(refused, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); + assert_eq!( + task_status_calls(&authority).await, + 0, + "an unlisted source never reaches an authority" + ); + let after: Value = fx + .admin + .query_one(BUSINESS_STATE, &[]) + .await + .expect("snapshot after the refusal") + .get(0); + assert_eq!(after, before, "the refusal changes no capacity"); + + let offline = fixture().await; + let slot = first_slot(&offline, OFFERING, 90, 200).await; + let (booked, appointment) = offline + .post( + "/v1/appointments", + &unlisted, + "unlisted-source-offline", + json!({"hold": null, "admission": admission(&offline, OFFERING, slot)}), + ) + .await; + assert_eq!( + booked, + StatusCode::CREATED, + "the same grant books on the offline path: {appointment}" + ); +} + +/// SEC-03. On the offline path that `unchecked-final-fifteen-minutes` +/// selects, a grant with one second more than fifteen minutes left is refused +/// and one with exactly fifteen minutes left books. +#[tokio::test] +async fn the_offline_path_accepts_at_most_fifteen_minutes_of_remaining_lifetime() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let past_the_cap = agent_token_with_grant_exp(pinned_now().timestamp() + 901); + let (status, problem) = fx + .post( + "/v1/appointments", + &past_the_cap, + "past-the-cap", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + + let at_the_cap = agent_token_with_grant_exp(pinned_now().timestamp() + 900); + let (status, booked) = fx + .post( + "/v1/appointments", + &at_the_cap, + "at-the-cap", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{booked}"); +} + /// SEC-03. The task grant is re-checked against a fresh observation of the /// clock immediately before the final write: a grant that still stands at /// the door and lapses before the commit books nothing. @@ -7124,6 +7654,83 @@ async fn a_grant_that_lapses_before_the_commit_never_books() { ); } +/// A deferred grant may outlive the exchanged access token that carried it. +/// The capacity transaction uses the earlier deadline, so a request that +/// passed fresh status and then waited on supply cannot commit after that +/// execution credential expires. +#[tokio::test] +async fn a_commitment_blocked_past_its_access_token_deadline_never_books() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let http = fx.http.clone(); + let agent = fx.agent.clone(); + let store = fx.store.clone(); + let mut admin = fx.admin; + let holder = admin + .transaction() + .await + .expect("the stand-in capacity transaction opens"); + holder + .execute("SELECT supply_id FROM scheduling_supply FOR UPDATE", &[]) + .await + .expect("the stand-in holds every supply anchor"); + let holder_pid: i32 = holder + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("the stand-in backend") + .get(0); + let commitment = tokio::spawn(send( + http, + "POST".into(), + "/v1/appointments".into(), + agent, + Some("credential-expired-on-lock".into()), + Some(body), + )); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let waiting: i64 = holder + .query_one( + "SELECT count(DISTINCT pid) FROM pg_locks WHERE $1=ANY(pg_blocking_pids(pid))", + &[&holder_pid], + ) + .await + .expect("read the waiting commitment") + .get(0); + if waiting > 0 { + break; + } + assert!( + !commitment.is_finished(), + "the commitment stopped before the lock" + ); + assert!( + std::time::Instant::now() < deadline, + "the commitment never waited on the supply lock" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let after_credential_expiry = pinned_now() + TimeDelta::seconds(301); + store.pin_clock(Arc::new(move || after_credential_expiry)); + holder.rollback().await.expect("release the capacity lock"); + let (status, problem) = commitment.await.expect("the commitment answers"); + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); + let bookings: i64 = admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("count bookings") + .get(0); + assert_eq!( + bookings, 0, + "the expired execution credential commits nothing" + ); +} + /// Every mutation must roll back its tentative writes when authorization /// expires after its admission-time check but before the final commit check. #[tokio::test] @@ -7197,11 +7804,7 @@ async fn every_mutation_rechecks_expiry_after_its_writes() { }; // Compare all transactional business effects, not just the status. // A refusal may add its own refused receipt. - let state = "SELECT jsonb_build_array( \ - (SELECT jsonb_agg(to_jsonb(c) ORDER BY claim_id) FROM scheduling_claims c), \ - (SELECT jsonb_agg(to_jsonb(h) ORDER BY event_id) FROM scheduling_history h), \ - (SELECT jsonb_agg(to_jsonb(o) ORDER BY outbox_id) FROM scheduling_outbox o), \ - (SELECT count(*) FROM scheduling_attempts WHERE status_code BETWEEN 200 AND 299))"; + let state = BUSINESS_STATE; let before: Value = fx .admin .query_one(state, &[]) diff --git a/crates/registry-schedulingctl/tests/activation_postgres.rs b/crates/registry-schedulingctl/tests/activation_postgres.rs index 3bd81a8e8a..cadfdf0906 100644 --- a/crates/registry-schedulingctl/tests/activation_postgres.rs +++ b/crates/registry-schedulingctl/tests/activation_postgres.rs @@ -251,6 +251,7 @@ impl Deployment { database:\n runtimeUrlRef: secret:env/{runtime}\n\ \x20 migrationUrlRef: secret:env/{migration}\n\ \x20 testOnlyPlaintext: true\n\ + taskGrantStatus: unchecked-final-fifteen-minutes\n\ audit:\n path: {audit}\n hashKeyRef: secret:env/{audit_key}\n\ retention:\n attemptReceiptRetentionDays: 2\n", package = package.display(), diff --git a/crates/registry-schedulingctl/tests/intents_postgres.rs b/crates/registry-schedulingctl/tests/intents_postgres.rs index bdf487c53b..124cbe69ec 100644 --- a/crates/registry-schedulingctl/tests/intents_postgres.rs +++ b/crates/registry-schedulingctl/tests/intents_postgres.rs @@ -174,6 +174,7 @@ async fn intents_lists_local_and_failed_oldest_due_first_and_respects_limit() { database:\n runtimeUrlRef: secret:env/SCHEDULING_INTENTS_TEST_DATABASE\n\ \x20 migrationUrlRef: secret:env/SCHEDULING_INTENTS_TEST_DATABASE\n\ \x20 testOnlyPlaintext: true\n\ + taskGrantStatus: unchecked-final-fifteen-minutes\n\ audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_INTENTS_TEST_AUDIT\n\ retention:\n attemptReceiptRetentionDays: 2\n", project = project.display(), diff --git a/crates/registry-schedulingctl/tests/records_apply_postgres.rs b/crates/registry-schedulingctl/tests/records_apply_postgres.rs index faadd98b88..72e3640d0f 100644 --- a/crates/registry-schedulingctl/tests/records_apply_postgres.rs +++ b/crates/registry-schedulingctl/tests/records_apply_postgres.rs @@ -231,6 +231,7 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_TEST_DATABASE\n\ \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_TEST_DATABASE\n\ \x20 testOnlyPlaintext: true\n\ + taskGrantStatus: unchecked-final-fifteen-minutes\n\ audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_TEST_AUDIT\n\ retention:\n attemptReceiptRetentionDays: 2\n", project = project.display(), @@ -391,6 +392,7 @@ async fn records_apply_rejects_a_different_deployment_identity_without_writing() database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_IDENTITY_DATABASE\n\ \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_IDENTITY_DATABASE\n\ \x20 testOnlyPlaintext: true\n\ + taskGrantStatus: unchecked-final-fifteen-minutes\n\ audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_IDENTITY_AUDIT\n\ retention:\n attemptReceiptRetentionDays: 2\n", project = project.display(), @@ -576,6 +578,7 @@ async fn records_apply_refuses_a_database_where_this_package_is_not_active() { database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_INACTIVE_DATABASE\n\ \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_INACTIVE_DATABASE\n\ \x20 testOnlyPlaintext: true\n\ + taskGrantStatus: unchecked-final-fifteen-minutes\n\ audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_INACTIVE_AUDIT\n\ retention:\n attemptReceiptRetentionDays: 2\n", project = project.display(), diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 448164f906..1af5d5e602 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -183,25 +183,39 @@ and the grant's client must equal the client the deployment verified. APPOINTMENT_CANCEL_ACTION; crates/registry-platform-oidc/src/authorization_claims.rs, SchedulingPermission. */} -The grant's bounds are matched once, at the service, before the capacity transaction opens. A grant -that does not cover the request answers `operation.not-authorized` and nothing is written. Inside the -transaction, immediately before the claim commits, exactly one fact is read again: the grant's own -expiry, against the clock that commitment is decided under. A grant that expired between the door and -the commit therefore cannot take capacity, and the attempt answers `operation.not-authorized` like any -other authority refusal. - -The bounds themselves are not read again at that point, and Scheduling does not ask the issuer whether -the grant is still active, so a grant revoked or narrowed at the issuer after its token was minted keeps -the authority its token carries until the token or the grant deadline passes. Casework holds every -grant it mints to at most 900 seconds; Scheduling verifies a grant's deadline but does not cap it, so -hold grants from any other authority to deadlines as short. Casework answers a grant's current status -at `GET /v1/task-grants/{grantId}/status`, but asking it would put Casework's availability inside every -capacity transaction. Checking grant status there is a recorded deferral, revisited when an adopter -needs a revocation to take effect faster than a grant expires. - -{/* Evidence: products/scheduling/contracts/security-invariant-matrix.yaml, SCHEDULING-DEF-01 and - SCHEDULING-SEC-03; crates/registry-casework-core/src/task_grant.rs, TASK_GRANT_LIFETIME_SECONDS; - products/casework/TASK_GRANTS.md, "Revocation and status". */} +The grant's bounds are matched at the service before the capacity transaction opens. Every runtime file +states `taskGrantStatus`, and a file that omits it is refused at startup with +`scheduling.runtime.missing-task-grant-status`. When `taskGrantStatus` lists task authorities, Scheduling +also obtains a fresh exact answer from the authority selected by the signed source issuer before every +commitment and idempotent receipt replay. The authority must say the grant is active and reproduce its +exact identity, bounds, signed subjects, and deadline. An inactive, +mismatched, unlisted, or incompletely bound grant answers `operation.not-authorized`; an authority or service +credential outage, including an unusable authority response, answers `service.unavailable`. Positive answers +are not cached, and the complete attempt is limited to ten seconds. No capacity lock is held during the outbound call. +This is a fresh observation rather than a distributed transaction: withdrawal after a positive answer can race +the in-flight local commit. + +Inside the transaction, after lock waits and immediately before commit, Scheduling checks the earlier of +the verified access-token and grant deadlines. A credential that expires after the status answer cannot +take capacity through a delayed transaction. The earlier deadline bounds the status-to-commit race but does +not make revocation atomic across products. A runtime that writes +`taskGrantStatus: unchecked-final-fifteen-minutes` takes the bounded offline path instead: it asks no +authority, performs no revocation check, and accepts only grants with no more than 900 seconds remaining. +A list of authorities disables that path for every source, so a grant from an unlisted source is refused +even with less than 15 minutes left. Because the token does not name the original authorization mode, the +final 15 minutes of a deferred grant can use the offline path on a deployment that writes the keyword; +deployments accepting deferred grants should list their task authorities. + +{/* Evidence: products/scheduling/contracts/security-invariant-matrix.yaml, SCHEDULING-SEC-03; + crates/registry-scheduling/src/config.rs, TaskGrantStatus and + a_missing_task_grant_status_is_refused_naming_both_choices; + crates/registry-scheduling/src/service.rs, require_current_task_grant(), task_grant_verdict(), + audit_request(), and commitment(); crates/registry-scheduling/tests/postgres_commitments.rs, + configured_task_status_is_fresh_for_a_capacity_commit_and_its_replay, + inactive_mismatched_and_unavailable_task_status_commit_no_capacity, + an_unlisted_source_is_refused_and_never_reaches_the_offline_path, + the_offline_path_accepts_at_most_fifteen_minutes_of_remaining_lifetime, and + a_commitment_blocked_past_its_access_token_deadline_never_books. */} A commitment writes a request audit entry, schema `registry-scheduling-audit/v1`, before the capacity transaction opens, and a response entry with the same correlation after the transaction commits or @@ -211,8 +225,8 @@ the call answers `service.unavailable`; when it refuses the response entry of a the call answers `service.unavailable` with the commitment in place. A permission the service refuses before any commitment is reached is one response entry with a correlation of its own. -{/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current() checks grant_exp_unix and - nothing else; crates/registry-scheduling/src/service.rs, require_permission(), audit_request(), +{/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current(); + crates/registry-scheduling/src/service.rs, require_permission(), audit_request(), and problem_of(); crates/registry-scheduling/src/audit.rs, SCHEDULING_AUDIT_SCHEMA; products/scheduling/contracts/security-invariant-matrix.yaml, SCHEDULING-SEC-03. */} diff --git a/docs/site/src/content/docs/reference/scheduling-configuration.mdx b/docs/site/src/content/docs/reference/scheduling-configuration.mdx index fba5fbc83a..4783ef1702 100644 --- a/docs/site/src/content/docs/reference/scheduling-configuration.mdx +++ b/docs/site/src/content/docs/reference/scheduling-configuration.mdx @@ -30,7 +30,8 @@ Descriptions appear where the Rust definition supplies them. `scheduling.yaml` is the authored policy: services, locations, offerings, and hooks. `records.yaml` holds the runtime records the policy names by identifier, such as resource pools and published windows. Files under `fixtures/` drive local checks. `runtime.yaml` is the operator's -deployment file: listener, database, package, identity, audit, retention, and hook destinations. +deployment file: listener, database, package, identity, task-grant status, audit, retention, and hook +destinations. A schema-valid document still needs the checks `schedulingctl` runs, because cross-field rules and references between files cannot all be expressed by these tables. @@ -78,8 +79,13 @@ schedulingctl test ./scheduling passing `check` ends with `0 errors, 0 warnings in 5 files`, and a passing `test` reports `Offline synthetic fixtures passed.` with `productionClosure: false`: the replay proves the policy against its fixtures, not source reachability or deployment readiness. Copy `scheduling/runtime.example.yaml` -to `scheduling/runtime.yaml` and set its absolute paths and secret references before you check it -with `schedulingctl check ./scheduling --runtime-config ./scheduling/runtime.yaml`. +to `scheduling/runtime.yaml`, set its absolute paths and secret references, and keep or replace its +`taskGrantStatus` before you check it with +`schedulingctl check ./scheduling --runtime-config ./scheduling/runtime.yaml`. The example writes +`taskGrantStatus: unchecked-final-fifteen-minutes`, which accepts only a task grant with at most 900 +seconds left and checks no revocation; a deployment that accepts deferred grants lists its task +authorities instead, as the [Scheduling API reference](../apis/registry-scheduling/#task-grants) +describes. A runtime file without `taskGrantStatus` is refused. ## Check commands diff --git a/docs/site/src/data/scheduling-api.yaml b/docs/site/src/data/scheduling-api.yaml index 8f741e3952..515b79bf60 100644 --- a/docs/site/src/data/scheduling-api.yaml +++ b/docs/site/src/data/scheduling-api.yaml @@ -17,7 +17,7 @@ - ['`idempotency.expired`', '410', 'The stored response for this idempotency key has expired. For an appointment command, read the appointment by its external reference or its identifier to learn the original outcome before choosing a new key. A hold cannot be read and expires on its own, so start a new request.'] - ['`idempotency.key-reused`', '409', 'An idempotency key was presented for a different request.'] - ['`location.closed`', '409', 'A closure covers the requested start. Choose a start outside the closure.'] - - ['`operation.not-authorized`', '403', 'The caller''s task grant does not name this operation, or the deployment''s client does not match the grant''s client.'] + - ['`operation.not-authorized`', '403', 'The caller''s task grant does not name this operation, the deployment''s client does not match the grant''s client, or configured fresh status says the exact grant is inactive, mismatched, unlisted, or incompletely bound.'] - ['`party.capacity-inadequate`', '422', 'The party is larger than the offering can ever serve, or its size falls outside the published units.'] - ['`policy.changed`', '412', 'The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.'] - ['`precondition.failed`', '412', 'Reserved: no runtime path answers it. A stale revision answers `policy.changed` or `revision.mismatch` instead.'] @@ -33,4 +33,4 @@ - ['`resource.unavailable`', '409', 'Every capable member is unavailable for this interval. The separately authorized explain path reports this code; the public path answers `capacity.exhausted`.'] - ['`revision.mismatch`', '412', 'The window revision changed before the request committed. Reload the catalogue and try again.'] - ['`schedule.unpublished`', '422', 'No published schedule serves that start. Choose a start on the published grid.'] - - ['`service.unavailable`', '503', 'Scheduling cannot decide the request for now: its storage did not answer or did not acknowledge a commit, its audit destination refused the entry, or the OIDC token verifier reached no verdict because the issuer''s discovery document or key set could not be fetched or used. The response carries `Retry-After`. A commitment may already be in place: retry it with the same idempotency key after the service recovers.'] + - ['`service.unavailable`', '503', 'Scheduling cannot decide the request for now: its storage did not answer or did not acknowledge a commit, its audit destination refused the entry, the OIDC token verifier reached no verdict, or a configured task-grant authority, its response, or its service credential was unavailable or unusable. The response carries `Retry-After`. A commitment may already be in place: retry it with the same idempotency key after the service recovers.'] diff --git a/products/casework/SECURITY-REVIEW-NOTES.md b/products/casework/SECURITY-REVIEW-NOTES.md index 93b5304d4d..70db697eba 100644 --- a/products/casework/SECURITY-REVIEW-NOTES.md +++ b/products/casework/SECURITY-REVIEW-NOTES.md @@ -596,6 +596,15 @@ material proposal change, principal change, expiry, revocation, drafts, completion, responsibility transitions, and database timezone boundaries. The simulation ages only synthetic fixture timestamps; it does not assert a real sixteen-minute wall-clock wait. +Scheduling uses the shared platform exact-status transport before capacity +work and caps in-flight execution at the earlier token/grant deadline. The +resource servers still own their own mutations and atomic duplicate/business +limits. There is no distributed transaction between status observation and +resource commit. Temporary status/source failure refuses the current attempt; +review membership eligibility is checked when observed. Explicit revocation is +required when a withdrawal must remain effective after eligibility is restored +between observations. + ## Exact supervisory request selection diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md index 30e6e28ce3..c91cc85766 100644 --- a/products/casework/TASK_GRANTS.md +++ b/products/casework/TASK_GRANTS.md @@ -154,8 +154,17 @@ observation, and keep deferred lifetimes no longer than the work needs. Offline reads may continue until the earlier access-token or grant deadline. BReg checks fresh status before new writes, later approval, and application. +Scheduling deployments using deferred work configure `taskGrantStatus` and +check fresh exact status before every capacity command and receipt replay. +A Scheduling deployment that instead writes +`taskGrantStatus: unchecked-final-fifteen-minutes` permits only authorization +with at most 900 seconds remaining; the final fifteen minutes of a deferred +grant can also use this offline path, with that bounded stale-authority window. +A configured registry never falls back when status is unavailable or inactive. There is no distributed transaction between a status observation and a resource -commit. +commit. Scheduling also rechecks the earlier execution-token and grant expiry +under its capacity locks, so waiting for a lock cannot carry an expired execution +credential into a later commit. Evidence wallet-delivery deferred offers carrying a task grant remain refused. ## Resume deferred work diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 854a0e0c0a..bce1656d06 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -4421,11 +4421,11 @@ "description": "Registry Scheduling runtime configuration JSON Schema.", "source": { "path": "crates/registry-scheduling/src/schema.rs", - "sha256": "a6c31be400401f6f7878e9a4156bbad5a28e5d5c5a4ecfdf00002391ce1fb65f" + "sha256": "cfddc0600ff8303c2f3d89815c888ac9ddac3124de47d8c232e51c0caa7ba92d" }, "artifact": { "path": "products/scheduling/generated/runtime/runtime.schema.json", - "sha256": "115e58ff8bc7cc92f029a1d814760246933d54bc58f660553b246fa190bcea5a", + "sha256": "4925c4eb0cc9e80b946306aeace68105caf73c619a7b371d6740cbe2ea1bdb57", "mediaType": "application/schema+json" } }, diff --git a/products/platform/config-formats.yaml b/products/platform/config-formats.yaml index c7fb3a21a2..63c7d52c51 100644 --- a/products/platform/config-formats.yaml +++ b/products/platform/config-formats.yaml @@ -2269,7 +2269,9 @@ formats: whenOmitted: open - pointer: /package/expectedDigest whenOmitted: open - restrictingMembers: [/authentication/oidc/allowedClients, /authentication/oidc/assertionIssuers] + - pointer: /taskGrantStatus + whenOmitted: refused + restrictingMembers: [/authentication/oidc/allowedClients, /authentication/oidc/assertionIssuers, /taskGrantStatus] - id: scheduling/ctl-report product: scheduling title: schedulingctl --format json report diff --git a/products/platform/scripts/check-config-conformance.py b/products/platform/scripts/check-config-conformance.py index 589cc9d707..a247c401fb 100755 --- a/products/platform/scripts/check-config-conformance.py +++ b/products/platform/scripts/check-config-conformance.py @@ -364,6 +364,7 @@ class Row: "PrivateListenerConfig", "SecretProvidersConfig", "SecretReference", + "TaskGrantStatusConfig", "TlsTermination", ), reference_refusal=TestRef( diff --git a/products/scheduling/RUNTIME-CONFIG.md b/products/scheduling/RUNTIME-CONFIG.md index de102523c3..ec7e31218c 100644 --- a/products/scheduling/RUNTIME-CONFIG.md +++ b/products/scheduling/RUNTIME-CONFIG.md @@ -167,6 +167,9 @@ admit every client the issuer verifies, including an application in the same realm that has nothing to do with booking, is a startup refusal in every mode (`scheduling.runtime.allowed-clients-required`), because a development file is copied toward production. +The native Casework exchange example uses the issuer-compatible +`scheduling:read` handle and therefore explicitly sets +`readsScope: scheduling:read`; the compatibility default remains unchanged. `assertionIssuers` maps a client id to the assertion authorities that client may exchange a subject token from. A deployment that performs no token exchange @@ -178,6 +181,82 @@ of that client's declared authorities, so an assertion minted by an unrelated authority the issuer happens to federate cannot become a booking credential here. +`taskGrantStatus` is a required top-level member that decides how Scheduling +learns whether a task grant is still current. It takes one of two forms, and +every runtime file states which; a file that omits it is refused at startup +with `scheduling.runtime.missing-task-grant-status`, and an empty list is +refused naming both choices. + +The keyword `unchecked-final-fifteen-minutes` selects the offline path. No +task authority is asked and no revocation is observed: Scheduling accepts a +grant only while it is unexpired with at most 900 seconds remaining at request +entry, and refuses any grant with more time left as `operation.not-authorized`. + +```yaml +taskGrantStatus: unchecked-final-fifteen-minutes +``` + +A list of Casework task authorities makes a fresh status answer mandatory. +Write it for every deployment that accepts deferred grants: + +```yaml +taskGrantStatus: + - sourceIssuer: https://casework.example.test/task-authority + baseUrl: https://casework.example.test + tokenEndpoint: https://identity.example.test/realms/registry/protocol/openid-connect/token + clientAssertionAudience: https://identity.example.test/realms/registry + clientId: scheduling-task-status + privateKeyRef: secret:file/scheduling-task-status-private-jwk + caseworkResource: urn:example:casework + caBundleRef: secret:file/institution-root-ca +``` + +Offline runtime checks require one to 32 entries with distinct absolute +`sourceIssuer` identifiers, safe service and token URLs, non-empty client and +assertion audience, an absolute Casework resource, one status scope token, and +a status path with one `{grantId}` segment. They validate secret +reference structure without resolving the private key or CA bundle or contacting +an endpoint. Activation repeats the structural check and verifies key and CA +contents. + +`sourceIssuer` must exactly equal the source issuer in the signed task grant. +`baseUrl` selects that authority's HTTPS API. `tokenEndpoint`, +`clientAssertionAudience`, `clientId`, and `privateKeyRef` configure the +private-key JWT client credential used to obtain a token for +`caseworkResource` with the `statusScope` scope. The private key is a JWK +whose `kid` selects its registered public key. `caBundleRef` is optional and +applies a private CA bundle to both outbound HTTPS connections. + +`statusScope` and `statusPath` are optional. Omitted, Scheduling requests the +`casework:grants:status` scope and calls Casework's +`/v1/task-grants/{grantId}/status` route under `baseUrl`. Set them only for a +task authority that answers under another scope or route: `statusScope` is one +scope token, and `statusPath` starts with `/` and holds exactly one whole +`{grantId}` segment. + +With the list, a fresh exact status answer is mandatory for every capacity +commitment and idempotent receipt replay, whatever time the grant has left. +The authority must return the signed grant's exact identity and bounds and say +it is active. An inactive, mismatched, unlisted, or incompletely bound grant +answers `operation.not-authorized`; an authority or credential outage, +including an unusable authority response, answers `service.unavailable`. +Positive answers are not cached. The full attempt is +limited to ten seconds and completes before the capacity transaction opens, so +no PostgreSQL capacity lock is held during the outbound call. Duplicate source +issuers or invalid endpoints, credentials, or secret references refuse startup. +The positive answer is an observation rather than a distributed transaction: +a withdrawal after the answer can race the in-flight Scheduling commit. + +The list disables the offline path for every source: a grant from a source +issuer no entry names is refused, even with less than 15 minutes left. Because +the signed token does not identify whether its grant was originally immediate +or deferred, a deployment that writes `unchecked-final-fifteen-minutes` lets +the final 15 minutes of a deferred grant through without asking whether it was +withdrawn. On either path, the capacity transaction checks the +earlier of the verified access token deadline and the grant deadline again +after lock waits and immediately before commit. That earlier deadline bounds +the status-to-commit race; it does not make revocation atomic across products. + Scopes authorize reads only. Every commitment takes its authority from a task grant instead, which [TASK_GRANTS.md](TASK_GRANTS.md) documents. diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md index 330be59c5f..caa090a3b6 100644 --- a/products/scheduling/SECURITY-REVIEW-NOTES.md +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -39,18 +39,16 @@ audit entries. The threats this surface answers: for explanations, and requires a complete scheduling grant (service, location, actions) for every commitment. A partially formed grant set is refused as a credentials problem, never honoured partially. -2. **A token that was valid at the door commits after its grant lapsed.** - The grant's expiry is re-checked inside the capacity transaction, - immediately before the claim commits, against a fresh observation of - the store's clock taken after every lock wait, not against the - request's own entry time: a transaction delayed by contention cannot - carry a lapsed grant to a commit. The full bounds are matched once - at the service before the transaction opens; re-reading them inside - would only defend against a grant revoked or narrowed at the issuer, - and the runtime does not ask the issuer for a grant's status (matrix - deferral SCHEDULING-DEF-01, recorded in the published reference and - reasoned in "Grant status stays out of the capacity transaction" - below). +2. **A grant already invalid at the fresh status observation, or an expired + credential, commits capacity.** A runtime configured with task-grant + authorities obtains a fresh exact status before every capacity transaction + and receipt replay. The outbound call completes before database locks are + taken. Inside the transaction, the earlier of the verified access-token and + grant deadlines is re-checked after every lock wait and immediately before + commit. Every runtime file states `taskGrantStatus`, and a file that omits + it is refused at startup; a runtime that writes + `unchecked-final-fifteen-minutes` admits only the bounded offline path for + a grant with at most 900 seconds left. 3. **A deployment is reached over a public interface by accident.** The listener must bind a private address or an explicitly declared container network; anything else refuses to start. @@ -765,58 +763,79 @@ and the removed-key refusals remain. Installation neither creates nor drops an audit outbox, and refuses an existing outbox with unpublished records. v0.40.0 does not upgrade v0.39.0 state in place; apply to a new database. -## Grant status stays out of the capacity transaction - -Casework answers a resource server's question about a task grant's -current status at `GET /v1/task-grants/{grantId}/status`, and the Base -Registry Engine (BReg) asks it before each task-granted write. Scheduling -does not ask, and SCHEDULING-DEF-01 stays deferred with its reason -restated. This is an authorization decision, so it is recorded here; the -runtime and its tests are unchanged. - -**Threat.** A grant revoked at Casework, or invalidated there because its -holder lost eligibility or its template retired, still commits capacity at -Scheduling until its deadline passes: the token the agent already holds -carries the bounds, and Scheduling never asks whether the grant is still -active. - -**Decision.** Keep the deferral. A grant Casework mints lives at most 900 -seconds from its approval (`TASK_GRANT_LIFETIME_SECONDS` in -`crates/registry-casework-core/src/task_grant.rs`, which every task -template is held to), and Scheduling re-checks the grant's expiry inside -the capacity transaction, after every lock wait and immediately before -the claim commits (SCHEDULING-SEC-03). The exposure is therefore the rest -of one short deadline. A status call would put Casework's availability -inside every capacity transaction: each commitment would hold its -capacity locks while it waits on another product, and would have to -refuse while that product is unreachable, because answering without the -status would make the check decorative. BReg accepts that cost for most -of its writes: it asks inside the write's transaction, waits at most five -seconds, and refuses the write when no answer arrives in that time. For a -change-request apply and an Evidence-guarded action it asks before the -transaction opens and re-checks only the grant's expiry inside it, which -keeps the locks free but leaves the window between the answer and the -commit, so that shape narrows the exposure without closing it. - -**Revisit trigger.** An adopter needs a revocation to take effect faster -than a grant expires. Closing the deferral then adds a Scheduling adapter -over `registry-casework-client`, which relaxes the MVP dependency sentence -in `AGENTS.md` and `products/scheduling/scripts/check_dependency_direction.py` -in the same change, and promotes SCHEDULING-DEF-01 to an enforced row with -the negative test that earns it. - -**Tests.** None change. `a_grant_that_lapses_before_the_commit_never_books` -and `every_mutation_rechecks_expiry_after_its_writes` (SCHEDULING-SEC-03, -`crates/registry-scheduling/tests/postgres_commitments.rs`) remain the -proof that the compensating expiry re-check holds. - -**Accepted residual.** The 900-second ceiling is Casework's, not -Scheduling's. Scheduling verifies a grant's deadline but does not cap how -far ahead it lies, and its token verifier sets no maximum token lifetime, -so a deployment that accepts grants minted by another authority takes -that authority's grant lifetime as its revocation window. Holding that -authority to deadlines no longer than Casework's is the deployment's -responsibility; Scheduling does not enforce it. +## Fresh task-grant status stays outside the capacity transaction + +Casework answers a resource server's question about a task grant's current +status at `GET /v1/task-grants/{grantId}/status`. Scheduling now uses the +shared platform status transport before each capacity commitment and each +idempotent receipt replay when `taskGrantStatus` lists authorities. This keeps +Scheduling independent of Casework's implementation crate while sharing the +same exact wire contract with BReg. + +**Threat.** A grant revoked at Casework, invalidated by a holder reassignment, +or changed at the authority could otherwise keep committing capacity through +an already issued access token. A request could also receive an active status +answer and then wait on a capacity lock until its short execution credential +expired. + +**Decision.** The authority must answer with `active: true` and reproduce the +exact grant id, source issuer, principal, client, resource, purpose, bounds, +signed subject values, and deadline. A mismatch, inactive response, incomplete +signed binding, or unlisted source is an authorization refusal. An authority +or service credential outage, including a malformed authority response, is a +service outage. Positive answers are never cached, and the complete attempt, +including acquisition of the service credential, has a ten-second limit. + +The status call completes before the capacity transaction opens, so it holds no +capacity lock while waiting on another product. The transaction then checks +the earlier of the verified access-token and grant deadlines after every lock +wait and immediately before commit. This bounds the interval between status +and commit by the short execution credential, even when the grant remains live +for days. It does not create a distributed transaction: a withdrawal after a +positive status answer can still race the in-flight local commit. + +`taskGrantStatus` is required, so no runtime reaches the offline path by +omission: a file without it is refused at startup with +`scheduling.runtime.missing-task-grant-status`, and an empty list is refused +naming both choices. The keyword `unchecked-final-fifteen-minutes` selects the +bounded offline path, which accepts a grant only when it has no more than 900 +seconds remaining at request entry and performs no revocation check. A list of +authorities disables that path for all grants, including sources that are not +listed. Deployments that accept deferred grants must list their authorities. + +**Tests.** `configured_task_status_is_fresh_for_a_capacity_commit_and_its_replay` +proves that an initial capacity claim and its duplicate operation each obtain +a fresh status while the ledger records one effect. +`inactive_mismatched_and_unavailable_task_status_commit_no_capacity` covers +closed refusals and authority outage, and +`inactive_and_unavailable_task_status_refuse_every_mutation_without_changing_capacity` +extends them to hold create, hold release, confirmation from a hold, +reschedule, and cancel. +`an_unlisted_source_is_refused_and_never_reaches_the_offline_path` refuses a +grant from an unlisted source with less than 15 minutes left. +`the_offline_path_accepts_at_most_fifteen_minutes_of_remaining_lifetime` +pins the 900-second offline path at both sides of its bound, and +`a_commitment_blocked_past_its_access_token_deadline_never_books` holds a real +PostgreSQL capacity lock until the execution credential has expired. All are in +`crates/registry-scheduling/tests/postgres_commitments.rs` and carry +SCHEDULING-SEC-03. The database-free unit tests in +`crates/registry-scheduling/src/service.rs` +(`the_offline_path_accepts_only_an_unexpired_grant_with_at_most_fifteen_minutes_left`, +`listed_authorities_refuse_an_unlisted_source_and_an_unbound_grant_without_the_offline_path`, +and `a_listed_authority_answer_decides_the_grant`) pin the same decision +without a database. In `crates/registry-scheduling/src/config.rs`, +`a_missing_task_grant_status_is_refused_naming_both_choices` and +`an_empty_or_unknown_task_grant_status_is_refused_naming_both_choices` refuse +an absent member, an empty list, and an unknown keyword, and +`the_unchecked_keyword_selects_the_offline_path` reads the keyword. + +**Accepted residual.** The signed token does not identify whether Casework +issued the grant in immediate or deferred mode. On a deployment that writes +`taskGrantStatus: unchecked-final-fifteen-minutes`, the final 15 minutes of an +originally deferred grant therefore qualify for the bounded offline path, with +no revocation check. Omission does not reach that path: it is refused at +startup. Operators who accept deferred grants close the residual by listing at +least one status authority, which makes status mandatory for every source. ## A spent key forgets its caller @@ -947,9 +966,7 @@ provided. ## Known deferrals -The matrix records four deferrals with their compensating controls. -SCHEDULING-DEF-01 is stated in threat 2 above and reasoned in "Grant -status stays out of the capacity transaction"; the other three are +The matrix records three deferrals with their compensating controls. They are restated here as the index the matrix's `recordedIn` points at: - **SCHEDULING-DEF-04, the channel is not bound to the verified caller.** diff --git a/products/scheduling/TASK_GRANTS.md b/products/scheduling/TASK_GRANTS.md index 98b6c0971d..f0450853c0 100644 --- a/products/scheduling/TASK_GRANTS.md +++ b/products/scheduling/TASK_GRANTS.md @@ -84,24 +84,45 @@ or location never covers a narrower one, and no rule maps one location onto another. A grant that does not cover the request answers `operation.not-authorized` and nothing is written. -The bounds are matched once, at the service, before the capacity transaction -opens. Inside the transaction, immediately before the claim commits, exactly -one fact is read again: the grant's own expiry, against the clock that -commitment is decided under. A grant that expired between the door and the -commit cannot take capacity, and the attempt answers -`operation.not-authorized` like any other authority refusal. - -The bounds themselves are not read again at that point, and Scheduling does -not ask the issuer whether the grant is still active, so a grant revoked or -narrowed at the issuer after its token was minted keeps the authority its -token carries until the token or the grant deadline passes. Casework holds -every grant it mints to at most 900 seconds; Scheduling verifies a grant's -deadline but does not cap it, so hold grants from any other authority to -deadlines as short. Casework answers a grant's current status at -`GET /v1/task-grants/{grantId}/status`, but asking it would put Casework's -availability inside every capacity transaction. Checking grant status there -is a recorded deferral, revisited when an adopter needs a revocation to take -effect faster than a grant expires. +The bounds are matched at the service before the capacity transaction opens. +Every runtime file states `taskGrantStatus`, either as a list of task +authorities or as the keyword `unchecked-final-fifteen-minutes`; a file that +omits it is refused at startup with +`scheduling.runtime.missing-task-grant-status`. When `taskGrantStatus` lists +authorities, Scheduling first obtains a fresh answer from the authority +selected by the signed grant's source issuer. The answer +must say the grant is active and must reproduce the exact grant id, source +issuer, principal, client, resource, purpose, bounds, signed subject values, +and deadline. Scheduling does not cache a positive answer. It checks again for +an idempotent receipt replay, so revocation also closes that path. + +The complete status attempt, including acquisition of Scheduling's service +credential, is limited to ten seconds. An inactive, unlisted, mismatched, or +incompletely bound grant answers `operation.not-authorized`. An authority or +service credential outage, including an unusable authority response, answers +`service.unavailable`. HTTP 401 or 403 from the status endpoint indicates +Scheduling's service credential was rejected, so the observation is unavailable; +it does not establish inactive task authority. HTTP 404 remains a grant refusal. +The status call happens +before the capacity transaction opens, so no capacity lock is held while +Scheduling waits on another product. The answer is a fresh observation, not a +distributed transaction with the authority: a withdrawal after a positive +answer can race the in-flight local commit. + +Inside the transaction, after every lock wait and immediately before commit, +Scheduling checks the earlier of the verified access token deadline and the +grant deadline against the transaction clock. A credential that expires after +the status answer cannot take capacity through a delayed transaction. + +`taskGrantStatus: unchecked-final-fifteen-minutes` selects the offline path: +no authority is asked, no revocation is observed, and Scheduling accepts a +grant only while it is unexpired with no more than 900 seconds remaining at +request entry. A list of authorities disables that path for every source, +including an unlisted one, which is refused even with less than 15 minutes +left. Because the token does not name its authorization mode, the final 15 +minutes of an originally deferred grant can use the offline path on a +deployment that writes the keyword. List the task authorities for every +deployment that accepts deferred grants. ## What the audit records @@ -144,7 +165,7 @@ taskTemplates: subject: scheduling-agent-service-account client: scheduling-agent resource: urn:example:scheduling - scopes: [scheduling-read, scheduling:commit] + scopes: [scheduling:read, scheduling:commit] purpose: schedule-registry-update bounds: type: scheduling @@ -154,13 +175,18 @@ taskTemplates: actions: [appointment.create] subjects: subject_reference: subject-reference - lifetimeSeconds: 900 + authorizationMode: deferred + # Deferred grants choose an explicit lifetime, up to seven days. + # Immediate grants retain their 900-second maximum. + lifetimeSeconds: 86400 ``` `scheduling:commit` is an explicit scope registered at the exchange issuer. The Scheduling runtime derives mutation authority from the grant bounds rather than -that scope. `scheduling-read` is the starter runtime's default read scope and -lets the same short-lived token select a currently published free start. +that scope. The native exchange uses the issuer-compatible `scheduling:read` +handle, so this runtime sets `readsScope: scheduling:read`; `scheduling-read` +remains the compatibility default. The read scope lets the same short-lived +token select a currently published free start. Register `scheduling-agent` as a Casework `taskExchange` service client, and register `urn:example:scheduling` plus those scopes at the shared issuer. In the @@ -173,11 +199,28 @@ authentication: issuer: https://identity.example.test/realms/registry audience: urn:example:scheduling scopeClaim: scope + readsScope: scheduling:read allowedClients: [scheduling-agent] assertionIssuers: scheduling-agent: [https://casework.example.test/task-authority] + +taskGrantStatus: + - sourceIssuer: https://casework.example.test/task-authority + baseUrl: https://casework.example.test + tokenEndpoint: https://identity.example.test/realms/registry/protocol/openid-connect/token + clientAssertionAudience: https://identity.example.test/realms/registry + clientId: scheduling-task-status + privateKeyRef: secret:file/scheduling-task-status-private-jwk + caseworkResource: urn:example:casework ``` +Register `scheduling-task-status` with the identity provider for private-key +JWT client authentication and as a Casework client allowed to read grant +status, with the `casework:grants:status` scope. `privateKeyRef` resolves its private JWK. Add `caBundleRef` when the +token endpoint or Casework uses a private CA. Each configured source issuer is +pinned to its own endpoint and service credential; duplicate sources are a +startup refusal. + After a current holder previews and approves the template, put the approved grant UUID into the existing Casework development exchange path. Its connection entry selects the Scheduling destination, not policy fields: @@ -190,7 +233,7 @@ clients: scheduling-agent: assertionKeyRef: secret:file/assertion-key.jwk resource: urn:example:scheduling - scopes: [scheduling-read, scheduling:commit] + scopes: [scheduling:read, scheduling:commit] ``` ```sh diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 3c521ebf4e..aa0f0cb3fe 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -44,29 +44,32 @@ invariants: path: crates/registry-scheduling/tests/postgres_commitments.rs name: an_arrival_offering_without_its_window_record_is_an_operator_gap - id: SCHEDULING-SEC-03 - state: partial + state: enforced threat: >- - A token that passed the door commits capacity after the task grant behind - it has expired. - enforcementPoint: >- - The grant's expiry re-checked inside the capacity transaction, after - every lock wait and immediately before the claim commits, against a - fresh observation of the store's clock rather than the request's own - entry time. - refusal: >- - Refuse with operation.not-authorized and write the authorization.refused - response audit entry after the rollback, leaving no claim behind. - partialNote: >- - Only the grant's expiry is read again inside the transaction. The bounds - themselves, the service, the location, the action, and the client, are - matched once at the service before the transaction opens, and the - runtime does not ask the issuer for the grant's current status, so a - grant revoked or narrowed at the issuer keeps the authority its token - carries until the token or the grant deadline passes. See deferred entry - SCHEDULING-DEF-01. + A deferred task grant already inactive or mismatched at the fresh status + observation commits capacity, or a request that waited on a lock commits + after its access token or grant expired. + enforcementPoint: >- + The runtime file must state taskGrantStatus, and startup refuses a file + that omits it or lists no authority. When it lists authorities, + require_current_task_grant obtains a fresh exact status before every + capacity transaction and receipt replay, without holding a database + lock, and refuses a grant from an unlisted source rather than falling + back to the offline path. The unchecked-final-fifteen-minutes keyword + admits only grants with at most 900 seconds remaining. The transaction + re-checks the earlier of the verified access-token and grant + deadlines after every lock wait and immediately before commit. Status is + an observation, not a distributed transaction: withdrawal after a + positive answer can race the local commit until that earlier deadline. + refusal: >- + Answer operation.not-authorized for an inactive, mismatched, unlisted, or + incompletely bound grant and leave no claim behind. Answer + service.unavailable and leave no claim behind when the authority or + service credential is unavailable or its response is unusable. A + deadline refusal rolls back every capacity write. negativeTest: path: crates/registry-scheduling/tests/postgres_commitments.rs - name: a_grant_that_lapses_before_the_commit_never_books + name: configured_task_status_is_fresh_for_a_capacity_commit_and_its_replay - id: SCHEDULING-SEC-04 state: enforced threat: >- @@ -706,31 +709,6 @@ invariants: path: crates/registry-scheduling/tests/postgres_commitments.rs name: rotating_the_audit_key_keeps_existing_claims_with_their_owner deferred: - - id: SCHEDULING-DEF-01 - subject: Full task-grant bounds re-checked inside the capacity transaction - state: deferred - reason: >- - The grant's bounds travel in the presented token and are matched once at - the service; its expiry, the fact that changes on its own, is re-checked - inside the capacity transaction (SCHEDULING-SEC-03). Re-reading the - bounds there only defends against a grant revoked or narrowed at the - issuer between the door and the commit. Casework answers that question at - GET /v1/task-grants/{grantId}/status, and the Base Registry Engine (BReg) - asks it before each task-granted write, but a status call from Scheduling - would put Casework's availability inside every capacity transaction: each - commitment would hold its capacity locks while it waits on another - product, and would have to refuse while that product is unreachable. - Revisit when an adopter needs a revocation to take effect faster than a - grant expires. - compensatingControl: >- - Casework, the maintained grant issuer, holds every task grant to a - lifetime of at most 900 seconds, and the expiry re-check inside the - transaction means a revoked grant commits capacity for at most the rest - of that deadline. Scheduling verifies a grant's deadline but does not cap - it, so a grant minted by another authority keeps that authority's - lifetime. The published reference states the limit and the revisit - trigger plainly rather than claiming the bounds are re-read. - recordedIn: docs/site/src/content/docs/reference/apis/registry-scheduling.mdx - id: SCHEDULING-DEF-04 subject: Channel bound to the verified caller state: deferred diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 11ed1cc7ff..3da8c4cfde 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -25,12 +25,27 @@ entries: - {path: crates/registry-scheduling/src/runtime.rs, name: the_offering_pool_anchors_are_collected_without_duplicates} - id: SCHEDULING-SEC-03 tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: configured_task_status_is_fresh_for_a_capacity_commit_and_its_replay} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: inactive_mismatched_and_unavailable_task_status_commit_no_capacity} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: inactive_and_unavailable_task_status_refuse_every_mutation_without_changing_capacity} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_unlisted_source_is_refused_and_never_reaches_the_offline_path} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_offline_path_accepts_at_most_fifteen_minutes_of_remaining_lifetime} + - {path: crates/registry-scheduling/src/service.rs, name: the_offline_path_accepts_only_an_unexpired_grant_with_at_most_fifteen_minutes_left} + - {path: crates/registry-scheduling/src/service.rs, name: listed_authorities_refuse_an_unlisted_source_and_an_unbound_grant_without_the_offline_path} + - {path: crates/registry-scheduling/src/service.rs, name: a_listed_authority_answer_decides_the_grant} + - {path: crates/registry-scheduling/src/config.rs, name: a_missing_task_grant_status_is_refused_naming_both_choices} + - {path: crates/registry-scheduling/src/config.rs, name: cfg_check_1_a_missing_task_grant_status_is_located_at_the_file} + - {path: crates/registry-scheduling/src/config.rs, name: an_empty_or_unknown_task_grant_status_is_refused_naming_both_choices} + - {path: crates/registry-scheduling/src/config.rs, name: the_unchecked_keyword_selects_the_offline_path} + - {path: crates/registry-scheduling/src/schema.rs, name: the_schema_states_both_task_grant_status_choices_and_requires_one, features: [schema]} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_commitment_blocked_past_its_access_token_deadline_never_books} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_grant_that_lapses_before_the_commit_never_books} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_foreign_actor_does_not_learn_the_current_revision} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: expiry_uses_earlier_token_or_grant_deadline_and_refuses_exact_deadline} - id: SCHEDULING-SEC-04 tests: + - {path: crates/registry-scheduling/src/auth.rs, name: an_offline_path_grant_need_not_carry_status_binding_members} - {path: crates/registry-scheduling/src/auth.rs, name: a_partial_grant_is_refused_as_invalid_claims} - {path: crates/registry-scheduling/src/auth.rs, name: a_complete_task_grant_is_extracted_and_bound_to_the_deployment} - {path: crates/registry-scheduling/src/auth.rs, name: a_read_without_the_reads_scope_is_a_profile_refusal_but_still_mutates} diff --git a/products/scheduling/demo/support/demo.py b/products/scheduling/demo/support/demo.py index c3021d8d32..aec9b01832 100644 --- a/products/scheduling/demo/support/demo.py +++ b/products/scheduling/demo/support/demo.py @@ -44,6 +44,11 @@ BOOKER_B = "demo-booker-b" READER = "demo-reader" +# How long a demo task grant lives. The demo runtime writes +# `taskGrantStatus: unchecked-final-fifteen-minutes`, which accepts only a +# grant with at most fifteen minutes left, so every grant is minted short. +GRANT_LIFETIME_SECONDS = 600 + # The task-grant permissions every booking caller carries: one permission per # location, naming the offering's service and the actions the demo exercises. GRANT_PERMISSIONS = [ @@ -202,7 +207,7 @@ def caller_claims( "registry_grant_source_issuer": DEMO_ISSUER, "registry_grant_client": client, "registry_grant_resource": DEMO_AUDIENCE, - "registry_grant_exp": issued_at + 3600, + "registry_grant_exp": issued_at + GRANT_LIFETIME_SECONDS, "registry_grant_bounds": { "type": "scheduling", "permissions": GRANT_PERMISSIONS, @@ -421,6 +426,7 @@ def runtime_config( jwksSource: type: static documentRef: secret:file/jwks +taskGrantStatus: unchecked-final-fifteen-minutes audit: path: {audit_path} hashKeyRef: secret:file/audit-key diff --git a/products/scheduling/demo/support/test_demo.py b/products/scheduling/demo/support/test_demo.py index bcbb24d17a..93a663d4a1 100644 --- a/products/scheduling/demo/support/test_demo.py +++ b/products/scheduling/demo/support/test_demo.py @@ -93,7 +93,11 @@ def test_a_booker_carries_a_complete_scheduling_grant(self): claims = demo.caller_claims("demo-booker-a", "booker-subject", grant=True, now=1_000_000) self.assertEqual(claims["registry_grant_client"], "demo-booker-a") self.assertEqual(claims["registry_grant_resource"], demo.DEMO_AUDIENCE) - self.assertEqual(claims["registry_grant_exp"], 1_000_000 + 3600) + # The demo runtime selects the offline task-grant path, which + # accepts only a grant with at most fifteen minutes left. + self.assertEqual(claims["registry_grant_exp"], 1_000_000 + demo.GRANT_LIFETIME_SECONDS) + self.assertLessEqual(demo.GRANT_LIFETIME_SECONDS, 900) + self.assertLessEqual(claims["registry_grant_exp"], claims["exp"]) self.assertEqual(claims["registry_purpose"], "registry-update") self.assertEqual(claims["registry_approver"], "demo-approver") bounds = claims["registry_grant_bounds"] @@ -154,6 +158,7 @@ def test_the_runtime_config_pins_every_required_block(self): f"audience: {demo.DEMO_AUDIENCE}", "type: static", "documentRef: secret:file/jwks", + "taskGrantStatus: unchecked-final-fifteen-minutes", "path: /run/audit", "hashKeyRef: secret:file/audit-key", ): diff --git a/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml b/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml index 3806bfc183..4287e951f8 100644 --- a/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml +++ b/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml @@ -101,6 +101,21 @@ authentication: # documentRef: secret:file/jwks.json # `type: uri` with `uri: https://...` fetches the key set from a fixed # address instead of the one discovery names. +# Required: how a task grant is checked before any hold or appointment +# changes. List the task authorities whose fresh grant status Scheduling +# checks for every commitment and replay, or write +# `unchecked-final-fifteen-minutes` to accept only a grant with at most +# fifteen minutes left at request entry, without a revocation check. An +# omitted member and an empty list are refused. +taskGrantStatus: unchecked-final-fifteen-minutes +# taskGrantStatus: +# - sourceIssuer: https://casework.example.test/task-authority +# baseUrl: https://casework.example.test +# tokenEndpoint: https://identity.example.test/realms/registry/protocol/openid-connect/token +# clientAssertionAudience: https://identity.example.test/realms/registry +# clientId: scheduling-task-status +# privateKeyRef: secret:file/scheduling-task-status-private-jwk +# caseworkResource: urn:example:casework audit: # One single-writer destination. `file`, the default, appends to the # absolute path, rotates at rotateBytes (default 100 MiB), and deletes diff --git a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml index 3806bfc183..4287e951f8 100644 --- a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml +++ b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml @@ -101,6 +101,21 @@ authentication: # documentRef: secret:file/jwks.json # `type: uri` with `uri: https://...` fetches the key set from a fixed # address instead of the one discovery names. +# Required: how a task grant is checked before any hold or appointment +# changes. List the task authorities whose fresh grant status Scheduling +# checks for every commitment and replay, or write +# `unchecked-final-fifteen-minutes` to accept only a grant with at most +# fifteen minutes left at request entry, without a revocation check. An +# omitted member and an empty list are refused. +taskGrantStatus: unchecked-final-fifteen-minutes +# taskGrantStatus: +# - sourceIssuer: https://casework.example.test/task-authority +# baseUrl: https://casework.example.test +# tokenEndpoint: https://identity.example.test/realms/registry/protocol/openid-connect/token +# clientAssertionAudience: https://identity.example.test/realms/registry +# clientId: scheduling-task-status +# privateKeyRef: secret:file/scheduling-task-status-private-jwk +# caseworkResource: urn:example:casework audit: # One single-writer destination. `file`, the default, appends to the # absolute path, rotates at rotateBytes (default 100 MiB), and deletes diff --git a/products/scheduling/generated/runtime/runtime.schema.json b/products/scheduling/generated/runtime/runtime.schema.json index 9f8d5f8dc0..8fc2456e48 100644 --- a/products/scheduling/generated/runtime/runtime.schema.json +++ b/products/scheduling/generated/runtime/runtime.schema.json @@ -483,6 +483,60 @@ "pattern": "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", "type": "string" }, + "TaskGrantStatusConfig": { + "additionalProperties": false, + "description": "One task authority whose grants a runtime checks for fresh status, and the\nruntime's own credentials for that check, written as one entry of\n`taskGrantStatus`. The status route and scope default to Casework's.", + "properties": { + "baseUrl": { + "$ref": "#/$defs/Url", + "description": "The authority's base URL the status check calls. It is `https`;\n`http` is accepted only for a loopback host, for local development." + }, + "caBundleRef": { + "$ref": "#/$defs/SecretReference", + "description": "PEM CA bundle for the authority and its token endpoint. Omitted, the\nplatform's trusted roots verify them." + }, + "caseworkResource": { + "$ref": "#/$defs/ExternalId", + "description": "The audience the runtime requests its status credential for, an\nabsolute resource URI." + }, + "clientAssertionAudience": { + "$ref": "#/$defs/ExternalId" + }, + "clientId": { + "$ref": "#/$defs/ExternalId" + }, + "privateKeyRef": { + "$ref": "#/$defs/SecretReference" + }, + "sourceIssuer": { + "$ref": "#/$defs/ExternalId", + "description": "The task authority's issuer, exactly as the authority states it,\nincluding an absolute URN issuer. It is compared rather than fetched\nand must be an absolute resource URI." + }, + "statusPath": { + "default": "/v1/task-grants/{grantId}/status", + "description": "The status route appended to `baseUrl`'s path. It starts with `/` and\nhas exactly one whole `{grantId}` segment; every other segment uses\nonly letters, digits, `-`, `.`, `_`, and `~`. Omitted,\n`/v1/task-grants/{grantId}/status`.", + "type": "string" + }, + "statusScope": { + "$ref": "#/$defs/ExternalId", + "default": "casework:grants:status", + "description": "The one scope the runtime requests its status credential with.\nOmitted, `casework:grants:status`." + }, + "tokenEndpoint": { + "$ref": "#/$defs/Url" + } + }, + "required": [ + "sourceIssuer", + "baseUrl", + "tokenEndpoint", + "clientAssertionAudience", + "clientId", + "privateKeyRef", + "caseworkResource" + ], + "type": "object" + }, "TlsTermination": { "description": "Declares the trusted transport boundary for a runtime's plaintext HTTP\nlistener. Production listeners require operator-controlled upstream TLS\ntermination; direct plaintext is limited to the explicit loopback-only\ndevelopment mode.", "enum": [ @@ -648,6 +702,24 @@ }, "secretProviders": { "$ref": "#/$defs/SecretProvidersConfig" + }, + "taskGrantStatus": { + "anyOf": [ + { + "const": "unchecked-final-fifteen-minutes", + "description": "Accept only a grant with at most fifteen minutes left at request\nentry, with no revocation check.", + "type": "string" + }, + { + "description": "Check fresh grant status with these task authorities for every\ncommitment and replay.", + "items": { + "$ref": "#/$defs/TaskGrantStatusConfig" + }, + "minItems": 1, + "type": "array" + } + ], + "description": "How a task grant is checked before capacity work: a list of the task\nauthorities whose fresh grant status every commitment and replay\nchecks, or `unchecked-final-fifteen-minutes` to accept only a grant\nwith at most fifteen minutes left, without a revocation check." } }, "required": [ @@ -659,6 +731,7 @@ "secretProviders", "database", "authentication", + "taskGrantStatus", "audit", "retention" ], From e55f50187be0aedeb86cf889b9db12649253770e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 10:36:50 +0700 Subject: [PATCH 07/11] feat(scheduling,messaging): read original receipts without replaying commands A caller recovering from a lost response can now observe the retained original receipt for its own key and exact request without submitting the command again: Scheduling serves POST /v1/appointments/receipt under ordinary read authentication, and Messaging serves POST /v1/messages/receipt. Lookup stays scoped to the caller's verified identity, and neither route renders, submits, or claims capacity. The Rust clients, the Node.js and Python bindings, the unified facades, the generated OpenAPI documents, the API reference pages, and the security contracts carry the new routes. Signed-off-by: Jeremi Joslin --- .../registry-messaging-client-node/README.md | 6 + .../__test__/facade.test-d.ts | 7 + .../__test__/facade.test.js | 53 +++ .../client.d.ts | 3 + .../registry-messaging-client-node/client.js | 1 + .../registry-messaging-client-node/index.d.ts | 1 + .../registry-messaging-client-node/src/lib.rs | 16 + crates/registry-messaging-client-py/README.md | 6 + .../registry_messaging_client/__init__.pyi | 3 + .../registry-messaging-client-py/src/lib.rs | 20 ++ .../tests/python/bootstrap.py | 20 ++ .../tests/python/test_capabilities.py | 2 +- .../tests/python/test_native_requests.py | 53 +++ .../registry-messaging-client/src/client.rs | 66 ++-- crates/registry-messaging-client/src/lib.rs | 6 +- .../tests/http_boundary.rs | 66 ++++ crates/registry-messaging-core/src/naming.rs | 3 + crates/registry-messaging-core/src/problem.rs | 9 +- crates/registry-messaging/src/http.rs | 52 ++- crates/registry-messaging/src/messages.rs | 82 +++++ crates/registry-messaging/src/schema.rs | 12 + .../tests/postgres_messages.rs | 183 +++++++++++ .../registry-messaging/tests/support/mod.rs | 17 +- .../registry-scheduling-client-node/README.md | 6 + .../__test__/facade.test-d.ts | 7 + .../__test__/facade.test.js | 53 +++ .../client.d.ts | 3 + .../registry-scheduling-client-node/client.js | 1 + .../index.d.ts | 1 + .../src/lib.rs | 16 + .../registry-scheduling-client-py/README.md | 6 + .../registry_scheduling_client/__init__.pyi | 3 + .../registry-scheduling-client-py/src/lib.rs | 21 ++ .../tests/python/bootstrap.py | 20 ++ .../tests/python/test_capabilities.py | 1 + .../tests/python/test_native_requests.py | 53 +++ .../registry-scheduling-client/src/client.rs | 33 +- crates/registry-scheduling-client/src/lib.rs | 11 +- .../tests/http_boundary.rs | 65 ++++ crates/registry-scheduling-core/src/naming.rs | 3 + .../registry-scheduling-core/src/problem.rs | 9 +- crates/registry-scheduling/src/http.rs | 33 +- crates/registry-scheduling/src/service.rs | 72 +++++ crates/registry-scheduling/src/store.rs | 25 ++ .../tests/postgres_commitments.rs | 302 ++++++++++++++++++ .../__test__/facade.test-d.ts | 9 + .../messaging/client.d.ts | 3 + .../messaging/client.js | 1 + .../messaging/index.d.ts | 1 + .../scheduling/client.d.ts | 3 + .../scheduling/client.js | 1 + .../scheduling/index.d.ts | 1 + crates/registry-stack-client/src/lib.rs | 10 +- .../tests/messaging_facade.rs | 3 + .../tests/scheduling_facade.rs | 3 + .../reference/apis/registry-messaging.mdx | 35 +- .../reference/apis/registry-scheduling.mdx | 21 +- docs/site/src/data/messaging-api.yaml | 1 + docs/site/src/data/scheduling-api.yaml | 1 + .../identifiers/generated/catalog.v1.json | 164 ++++++---- products/messaging/README.md | 13 + products/messaging/SECURITY-REVIEW-NOTES.md | 37 +++ .../contracts/recorded-decisions.yaml | 15 +- .../contracts/security-invariant-matrix.yaml | 6 +- .../contracts/security-test-traceability.yaml | 2 + .../generated/registry-messaging.openapi.json | 276 ++++++++++++++++ products/scheduling/README.md | 14 + products/scheduling/SECURITY-REVIEW-NOTES.md | 24 ++ .../contracts/security-invariant-matrix.yaml | 5 + .../contracts/security-test-traceability.yaml | 5 + .../generated/fixture/fixture.schema.json | 1 + .../registry-scheduling.openapi.json | 270 ++++++++++++++++ .../scheduling/scripts/generate_openapi.py | 14 + .../scripts/test_generate_openapi.py | 57 +++- 74 files changed, 2310 insertions(+), 117 deletions(-) diff --git a/crates/registry-messaging-client-node/README.md b/crates/registry-messaging-client-node/README.md index 35fd5ccd34..9045139349 100644 --- a/crates/registry-messaging-client-node/README.md +++ b/crates/registry-messaging-client-node/README.md @@ -26,3 +26,9 @@ for a configuration or request defect, a connection never established, and every 4xx refusal. It is false for `410 idempotency.expired` too, but there an earlier submission under the key was accepted and its message may have been sent: reconcile that submission before choosing a new key. + +`messageReceipt` observes a retained original success using the original request and +caller-chosen key with a current token. It makes one receipt request, never +replays the mutation and never retries. `receipt.unresolved` leaves the +original effect unknown; a missing or expired receipt does not prove absence. +The error's `outcomeUnknown` describes this read, not the original effect. diff --git a/crates/registry-messaging-client-node/__test__/facade.test-d.ts b/crates/registry-messaging-client-node/__test__/facade.test-d.ts index 37c30165d8..0d7f899c40 100644 --- a/crates/registry-messaging-client-node/__test__/facade.test-d.ts +++ b/crates/registry-messaging-client-node/__test__/facade.test-d.ts @@ -98,3 +98,10 @@ function mayHaveTakenEffect(error: MessagingClientError): boolean { return unknown } void mayHaveTakenEffect + +const observedReceipt: Promise> = client.messageReceipt(token, 'original-key', templated) +void observedReceipt +const unresolved: MessagingProblemCode = 'receipt.unresolved' +void unresolved +// @ts-expect-error receipt observation requires the original caller key +void client.messageReceipt(token, templated) diff --git a/crates/registry-messaging-client-node/__test__/facade.test.js b/crates/registry-messaging-client-node/__test__/facade.test.js index ad0ffae692..548ee27557 100644 --- a/crates/registry-messaging-client-node/__test__/facade.test.js +++ b/crates/registry-messaging-client-node/__test__/facade.test.js @@ -648,3 +648,56 @@ test('a retry ceiling outside zero to two is a configuration error', () => { }); } }); + +test('original receipt observation preserves the caller body, key and current token', async (context) => { + const { baseUrl, requests } = await serve(context, () => ({ + status: 200, contentType: 'application/json', document: { id: MESSAGE_ID, status: 'queued', links: LINKS }, + })); + const { MessagingClient } = require('../client'); + const client = new MessagingClient({ baseUrl: `${baseUrl}tenant/` }); + const original = SUBMISSION; + const result = await client.messageReceipt('current-token', 'original-key', original); + assert.deepEqual(result, { kind: 'complete', value: { id: MESSAGE_ID, status: 'queued', links: LINKS }, traceId: TRACE_ID }); + assert.equal(requests.length, 1); + assert.equal(requests[0].method, 'POST'); + assert.equal(requests[0].path, '/tenant/v1/messages/receipt'); + assert.equal(requests[0].headers.authorization, 'Bearer current-token'); + assert.equal(requests[0].headers['idempotency-key'], 'original-key'); + assert.deepEqual(JSON.parse(requests[0].body), SUBMISSION); +}); + +for (const [code, status, title, detail] of [ + ['receipt.unresolved', 409, 'Original receipt unresolved', 'No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred.'], + ['operation.not-authorized', 403, 'Operation not authorized', 'Server-controlled-detail-canary'], + ['service.unavailable', 503, 'Service unavailable', 'Server-controlled-detail-canary'], +]) { + test(`receipt observation maps ${code} without retry or exposing caller values`, async (context) => { + const { baseUrl, requests } = await serve(context, () => problem(code, status, title, detail)); + const { MessagingClient, MessagingClientError } = require('../client'); + const client = new MessagingClient({ baseUrl, maxMutationRetries: 2 }); + await assert.rejects(client.messageReceipt('token-canary', 'key-canary', SUBMISSION), (error) => { + assert.ok(error instanceof MessagingClientError); + assert.equal(error.kind, 'problem'); + assert.equal(error.code, code); + assert.equal(error.status, status); + assert.equal(error.traceId, TRACE_ID); + assert.equal(error.outcomeUnknown, status >= 500); + const rendered = inspect(error); + for (const canary of ['token-canary', 'key-canary', 'Server-controlled-detail-canary']) { + assert.equal(rendered.includes(canary), false); + } + return true; + }); + assert.equal(requests.length, 1); + assert.equal(requests[0].path, '/v1/messages/receipt'); + }); +} + +test('receipt observation refuses an invalid key or request before any exchange', async (context) => { + const { baseUrl, requests } = await serve(context, () => ({ status: 500 })); + const { MessagingClient } = require('../client'); + const client = new MessagingClient({ baseUrl }); + await assert.rejects(client.messageReceipt('token', 'two words', SUBMISSION), (error) => error.kind === 'invalid-request'); + await assert.rejects(client.messageReceipt('token', 'key', { ...SUBMISSION, extra: true }), (error) => error.kind === 'invalid-request'); + assert.equal(requests.length, 0); +}); diff --git a/crates/registry-messaging-client-node/client.d.ts b/crates/registry-messaging-client-node/client.d.ts index c897958bad..de9aff0994 100644 --- a/crates/registry-messaging-client-node/client.d.ts +++ b/crates/registry-messaging-client-node/client.d.ts @@ -109,6 +109,7 @@ export type MessagingProblemCode = | 'profile.not-authorized' | 'quota.exceeded' | 'rate-limit.exceeded' + | 'receipt.unresolved' | 'request.body-too-large' | 'request.invalid' | 'request.method-not-allowed' @@ -156,6 +157,8 @@ export class MessagingClient { health(): Promise> ready(): Promise> submit(token: string, idempotencyKey: string, request: SubmitMessageRequest): Promise> + /** Observe the original success using the same body/key and current token; never resend or mutate. */ + messageReceipt(token: string, idempotencyKey: string, request: SubmitMessageRequest): Promise> message(token: string, messageId: MessageId): Promise> cancel(token: string, messageId: MessageId): Promise> preview(token: string, templateId: string, version: string, request: TemplatePreviewRequest): Promise> diff --git a/crates/registry-messaging-client-node/client.js b/crates/registry-messaging-client-node/client.js index ab0df16560..8a34dbada5 100644 --- a/crates/registry-messaging-client-node/client.js +++ b/crates/registry-messaging-client-node/client.js @@ -113,6 +113,7 @@ for (const [method, jsonIndexes] of [ ['health', []], ['ready', []], ['submit', [2]], + ['messageReceipt', [2]], ['message', []], ['cancel', []], ['preview', [3]], diff --git a/crates/registry-messaging-client-node/index.d.ts b/crates/registry-messaging-client-node/index.d.ts index 34fa094943..289b476bf3 100644 --- a/crates/registry-messaging-client-node/index.d.ts +++ b/crates/registry-messaging-client-node/index.d.ts @@ -14,6 +14,7 @@ export declare class MessagingClient { health(): Promise ready(): Promise submit(token: string, idempotencyKey: string, request: any): Promise + messageReceipt(token: string, idempotencyKey: string, request: any): Promise message(token: string, messageId: string): Promise cancel(token: string, messageId: string): Promise preview(token: string, templateId: string, version: string, request: any): Promise diff --git a/crates/registry-messaging-client-node/src/lib.rs b/crates/registry-messaging-client-node/src/lib.rs index 1bda1bf6b0..f857711bad 100644 --- a/crates/registry-messaging-client-node/src/lib.rs +++ b/crates/registry-messaging-client-node/src/lib.rs @@ -102,6 +102,22 @@ impl MessagingClient { outcome(self.inner.submit(&token, &idempotency_key, &request).await) } + #[napi] + pub async fn message_receipt( + &self, + token: String, + idempotency_key: String, + request: Value, + ) -> Result { + let token = bearer(token)?; + let request: SubmitMessageRequest = input(request)?; + outcome( + self.inner + .message_receipt(&token, &idempotency_key, &request) + .await, + ) + } + #[napi] pub async fn message(&self, token: String, message_id: String) -> Result { let token = bearer(token)?; diff --git a/crates/registry-messaging-client-py/README.md b/crates/registry-messaging-client-py/README.md index ea495391e1..ddb09da406 100644 --- a/crates/registry-messaging-client-py/README.md +++ b/crates/registry-messaging-client-py/README.md @@ -61,3 +61,9 @@ was accepted and its message may have been sent: reconcile that submission before choosing a new key. This crate is private and does not publish a standalone Python distribution. + +`message_receipt` observes a retained original success using the original request and +caller-chosen key with a current token. It makes one receipt request, never +replays the mutation and never retries. `receipt.unresolved` leaves the +original effect unknown; a missing or expired receipt does not prove absence. +The error's `outcome_unknown` describes this read, not the original effect. diff --git a/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi b/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi index f07104b38e..7841840bab 100644 --- a/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi +++ b/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi @@ -149,6 +149,7 @@ MessagingProblemCode: TypeAlias = Literal[ "profile.not-authorized", "quota.exceeded", "rate-limit.exceeded", + "receipt.unresolved", "request.body-too-large", "request.invalid", "request.method-not-allowed", @@ -193,6 +194,8 @@ class MessagingClient: def health(self) -> Complete[None]: ... def ready(self) -> Complete[None]: ... def submit(self, token: str, idempotency_key: str, request: SubmitMessageRequest) -> Complete[MessageReceipt]: ... + # Observe the original success using the same body/key and current token; never resend or mutate. + def message_receipt(self, token: str, idempotency_key: str, request: SubmitMessageRequest) -> Complete[MessageReceipt]: ... def message(self, token: str, message_id: MessageId) -> Complete[MessageView]: ... def cancel(self, token: str, message_id: MessageId) -> Complete[MessageView]: ... def preview(self, token: str, template_id: str, version: str, request: TemplatePreviewRequest) -> Complete[TemplatePreview]: ... diff --git a/crates/registry-messaging-client-py/src/lib.rs b/crates/registry-messaging-client-py/src/lib.rs index 0f5c13488c..80d43435dd 100644 --- a/crates/registry-messaging-client-py/src/lib.rs +++ b/crates/registry-messaging-client-py/src/lib.rs @@ -346,6 +346,26 @@ impl MessagingClient { ) } + fn message_receipt<'py>( + &self, + py: Python<'py>, + token: &str, + idempotency_key: &str, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let token = bearer(py, token)?; + let request: SubmitMessageRequest = input(py, request)?; + complete( + py, + py.detach(|| { + self.runtime.block_on( + self.inner + .message_receipt(&token, idempotency_key, &request), + ) + }), + ) + } + fn message<'py>( &self, py: Python<'py>, diff --git a/crates/registry-messaging-client-py/tests/python/bootstrap.py b/crates/registry-messaging-client-py/tests/python/bootstrap.py index 7736074a1a..51e16e05d5 100644 --- a/crates/registry-messaging-client-py/tests/python/bootstrap.py +++ b/crates/registry-messaging-client-py/tests/python/bootstrap.py @@ -2,8 +2,10 @@ from __future__ import annotations +import ctypes import functools import json +import os import pathlib import platform import shutil @@ -35,6 +37,23 @@ def _target_debug() -> pathlib.Path: return pathlib.Path(json.loads(metadata.stdout)["target_directory"]) / "debug" +def _preload_cargo_runtime() -> None: + if platform.system() != "Darwin": + return + runtime_directory = os.environ.get("REGISTRY_CARGO_RUNTIME_LIBRARY_PATH") + if runtime_directory is None: + return + libraries = list( + pathlib.Path(runtime_directory).glob("libaws_lc_fips*_crypto.dylib") + ) + if len(libraries) != 1: + raise RuntimeError( + "expected one AWS-LC FIPS runtime library in " + f"{runtime_directory}, found {len(libraries)}" + ) + ctypes.CDLL(str(libraries[0]), mode=ctypes.RTLD_GLOBAL) + + def _library() -> pathlib.Path: suffix = {"Darwin": ".dylib", "Linux": ".so"}.get(platform.system()) if suffix is None: @@ -61,6 +80,7 @@ def ensure_built() -> None: source = _library() if not source.is_file(): raise RuntimeError(f"cargo did not produce {source}") + _preload_cargo_runtime() import_root = _target_debug() / "messaging_python_module" import_package = import_root / _MODULE_NAME import_package.mkdir(parents=True, exist_ok=True) diff --git a/crates/registry-messaging-client-py/tests/python/test_capabilities.py b/crates/registry-messaging-client-py/tests/python/test_capabilities.py index 469e2683db..635de8a778 100644 --- a/crates/registry-messaging-client-py/tests/python/test_capabilities.py +++ b/crates/registry-messaging-client-py/tests/python/test_capabilities.py @@ -8,7 +8,7 @@ from registry_messaging_client import MessagingClient # noqa: E402 -_METHODS = {"health", "ready", "submit", "message", "cancel", "preview"} +_METHODS = {"health", "ready", "submit", "message_receipt", "message", "cancel", "preview"} _STUB = ( pathlib.Path(__file__).resolve().parents[2] / "python" diff --git a/crates/registry-messaging-client-py/tests/python/test_native_requests.py b/crates/registry-messaging-client-py/tests/python/test_native_requests.py index a23fea1791..10a78f9984 100644 --- a/crates/registry-messaging-client-py/tests/python/test_native_requests.py +++ b/crates/registry-messaging-client-py/tests/python/test_native_requests.py @@ -123,6 +123,19 @@ def do_POST(self) -> None: # noqa: N802 length = int(self.headers.get("content-length", "0")) body = self.rfile.read(length).decode("utf-8") self.observe(body) + if self.path == "/tenant/v1/messages/receipt": + failures = { + "unresolved-key-canary": ("receipt.unresolved", 409, "Original receipt unresolved", "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred."), + "denied-key-canary": ("operation.not-authorized", 403, "Operation not authorized", "server-detail-canary"), + "unavailable-key-canary": ("service.unavailable", 503, "Service unavailable", "server-detail-canary"), + } + failure = failures.get(self.headers.get("idempotency-key")) + if failure: + self.respond_problem(*failure) + else: + self.respond(200, {"id": MESSAGE_ID, "status": "queued", "links": LINKS}) + return + if self.path == f"/tenant/v1/messages/{MESSAGE_ID}/cancel": self.respond(200, CANCELLED_VIEW) return @@ -397,6 +410,46 @@ def test_unreachable_service_is_a_transport_failure(self) -> None: self.assertEqual(raised.exception.kind, "transport") self.assertIsInstance(raised.exception.transport_kind, str) + def test_receipt_observation_preserves_the_original_request_key_and_current_token(self) -> None: + result = self.client.message_receipt("current-token", "original-key", SUBMISSION) + self.assertEqual(result, {"kind": "complete", "value": {"id": MESSAGE_ID, "status": "queued", "links": LINKS}, "trace_id": TRACE_ID}) + self.assertEqual(len(_Handler.observations), 1) + observed = _Handler.observations[0] + self.assertEqual(observed["method"], "POST") + self.assertEqual(observed["path"], "/tenant/v1/messages/receipt") + self.assertEqual(observed["authorization"], "Bearer current-token") + self.assertEqual(observed["idempotency_key"], "original-key") + self.assertEqual(json.loads(observed["body"]), SUBMISSION) + + def test_receipt_observation_maps_unresolved_and_refused_without_retry_or_disclosure(self) -> None: + for key, code, status in [ + ("unresolved-key-canary", "receipt.unresolved", 409), + ("denied-key-canary", "operation.not-authorized", 403), + ("unavailable-key-canary", "service.unavailable", 503), + ]: + with self.subTest(code=code): + _Handler.observations.clear() + with self.assertRaises(MessagingClientError) as raised: + self.client.message_receipt("token-canary", key, SUBMISSION) + error = raised.exception + self.assertEqual(error.kind, "problem") + self.assertEqual(error.code, code) + self.assertEqual(error.status, status) + self.assertEqual(error.trace_id, TRACE_ID) + self.assertEqual(error.outcome_unknown, status >= 500) + rendered = "\n".join((str(error), repr(error), repr(vars(error)))) + for canary in [key, "token-canary", "server-detail-canary"]: + self.assertNotIn(canary, rendered) + self.assertEqual(len(_Handler.observations), 1) + self.assertEqual(_Handler.observations[0]["path"], "/tenant/v1/messages/receipt") + + def test_receipt_observation_refuses_invalid_key_or_request_before_exchange(self) -> None: + for key, request in [("two words", SUBMISSION), ("key", {**SUBMISSION, "extra": True})]: + with self.assertRaises(MessagingClientError) as raised: + self.client.message_receipt("token", key, request) + self.assertEqual(raised.exception.kind, "invalid-request") + self.assertEqual(_Handler.observations, []) + if __name__ == "__main__": unittest.main() diff --git a/crates/registry-messaging-client/src/client.rs b/crates/registry-messaging-client/src/client.rs index f34e0db16c..cba2174a64 100644 --- a/crates/registry-messaging-client/src/client.rs +++ b/crates/registry-messaging-client/src/client.rs @@ -6,7 +6,7 @@ use registry_messaging_core::{ type_uri, valid_identifier, valid_template_version, MessageReceipt, MessageView, ProblemCode, SubmitMessageRequest, TemplatePreview, TemplatePreviewRequest, HEALTH_PATH, IDEMPOTENCY_KEY_HEADER, MAXIMUM_IDEMPOTENCY_KEY_BYTES, MESSAGES_PATH, MESSAGE_CANCEL_PATH, - MESSAGE_PATH, READY_PATH, TEMPLATE_PREVIEW_PATH, + MESSAGE_PATH, MESSAGE_RECEIPT_PATH, READY_PATH, TEMPLATE_PREVIEW_PATH, }; use registry_platform_httpsec::{response_trace_id, ProblemDocument}; use registry_platform_httputil::client::{ @@ -122,23 +122,7 @@ impl MessagingClient { idempotency_key: &str, request: &SubmitMessageRequest, ) -> Result, MessagingClientError> { - if !is_idempotency_key(idempotency_key) { - return Err(MessagingClientError::invalid_request( - "the idempotency key is not 1 to 128 visible ASCII characters", - )); - } - if let Some(template) = &request.template { - if !valid_identifier(&template.id) { - return Err(MessagingClientError::invalid_request( - "the template identifier is not a package identifier", - )); - } - if !valid_template_version(&template.version) { - return Err(MessagingClientError::invalid_request( - "the template version is not a package version label", - )); - } - } + validate_submission(idempotency_key, request)?; let body = serde_json::to_vec(request).map_err(|_| { MessagingClientError::invalid_request("the submission could not be encoded") })?; @@ -153,6 +137,27 @@ impl MessagingClient { self.send_keyed_json(request, StatusCode::ACCEPTED).await } + /// Observe the original acceptance receipt without submitting a message. + /// This single read never retries and never generates a key. Unresolved + /// receipts retain uncertainty about the original effect. The key and + /// template names are checked exactly as `submit` checks them, before I/O. + pub async fn message_receipt( + &self, + token: &BearerToken, + idempotency_key: &str, + request: &SubmitMessageRequest, + ) -> Result, MessagingClientError> { + validate_submission(idempotency_key, request)?; + let request = self + .http + .post(self.url_from_constant(MESSAGE_RECEIPT_PATH)?) + .header(AUTHORIZATION, token.authorization_header_value()) + .header(ACCEPT, JSON_MEDIA_TYPE) + .header(IDEMPOTENCY_KEY_HEADER, idempotency_key) + .json(request); + self.json_answer(request, StatusCode::OK).await + } + /// One message as the caller may see it: its derived status, the /// dispatch state and delivery report that status is derived from, and /// its attempt summaries. A message the caller may not see answers the @@ -461,6 +466,31 @@ fn keyed_attempt( ) } +/// Validate the original submission for both mutation and receipt observation. +fn validate_submission( + idempotency_key: &str, + request: &SubmitMessageRequest, +) -> Result<(), MessagingClientError> { + if !is_idempotency_key(idempotency_key) { + return Err(MessagingClientError::invalid_request( + "the idempotency key is not 1 to 128 visible ASCII characters", + )); + } + if let Some(template) = &request.template { + if !valid_identifier(&template.id) { + return Err(MessagingClientError::invalid_request( + "the template identifier is not a package identifier", + )); + } + if !valid_template_version(&template.version) { + return Err(MessagingClientError::invalid_request( + "the template version is not a package version label", + )); + } + } + Ok(()) +} + /// Whether `value` is an idempotency key the runtime accepts: 1 to /// `MAXIMUM_IDEMPOTENCY_KEY_BYTES` bytes of visible ASCII. fn is_idempotency_key(value: &str) -> bool { diff --git a/crates/registry-messaging-client/src/lib.rs b/crates/registry-messaging-client/src/lib.rs index fc1d758526..7d3fc80768 100644 --- a/crates/registry-messaging-client/src/lib.rs +++ b/crates/registry-messaging-client/src/lib.rs @@ -36,6 +36,10 @@ //! limit refusal is never resent; it carries the bounded wait its //! `Retry-After` asked for, and waiting on it stays the caller's decision. +//! `message_receipt` observes a retained original success by caller, key, and exact request +//! without replaying the mutation. The single read is never retried, and a +//! typed `receipt.unresolved` preserves uncertainty about the original effect. + #![deny(unsafe_code)] mod client; @@ -51,6 +55,6 @@ pub use registry_messaging_core::{ Recipient, RenderedParts, SegmentCount, SmsEncoding, SubmitMessageRequest, TemplatePreview, TemplatePreviewRequest, TemplateReference, HEALTH_PATH, IDEMPOTENCY_KEY_HEADER, MAXIMUM_IDEMPOTENCY_KEY_BYTES, MESSAGES_PATH, MESSAGE_CANCEL_PATH, MESSAGE_PATH, - MESSAGING_PROBLEM_TYPE_BASE, READY_PATH, TEMPLATE_PREVIEW_PATH, + MESSAGE_RECEIPT_PATH, MESSAGING_PROBLEM_TYPE_BASE, READY_PATH, TEMPLATE_PREVIEW_PATH, }; pub use registry_platform_httputil::client::{BearerToken, TransportKind}; diff --git a/crates/registry-messaging-client/tests/http_boundary.rs b/crates/registry-messaging-client/tests/http_boundary.rs index 31acac3b71..78237b2408 100644 --- a/crates/registry-messaging-client/tests/http_boundary.rs +++ b/crates/registry-messaging-client/tests/http_boundary.rs @@ -176,6 +176,7 @@ async fn answer( async fn serve(prefix: &str, fixture: &Fixture) -> (MessagingClient, tokio::task::JoinHandle<()>) { let app = Router::new() + .route(&format!("{prefix}/v1/messages/receipt"), post(answer)) .route(&format!("{prefix}{HEALTH_PATH}"), get(answer)) .route(&format!("{prefix}{READY_PATH}"), get(answer)) .route(&message_route(prefix), get(answer)) @@ -906,3 +907,68 @@ async fn a_template_name_outside_the_package_grammar_is_refused_before_any_reque assert!(fixture.seen.lock().expect("observations").is_empty()); server.abort(); } + +#[tokio::test] +async fn a_receipt_template_name_outside_the_package_grammar_is_refused_before_any_request() { + let fixture = Fixture::new(StatusCode::OK, Some("application/json"), &message_receipt()); + let (client, server) = serve("", &fixture).await; + for (template_id, version) in [("../ready", "1"), ("appointment-reminder", "1/preview")] { + let mut request = submission(); + request.template = Some(TemplateReference { + id: template_id.to_owned(), + version: version.to_owned(), + }); + assert!( + matches!( + client + .message_receipt(&token(), IDEMPOTENCY_KEY, &request) + .await, + Err(MessagingClientError::InvalidRequest { .. }) + ), + "{template_id} {version}" + ); + } + assert!(fixture.seen.lock().expect("observations").is_empty()); + server.abort(); +} + +#[tokio::test] +async fn original_receipt_lookup_is_one_read_with_the_exact_key_and_body() { + let fixture = Fixture::new(StatusCode::OK, Some("application/json"), &message_receipt()); + let (client, server) = serve("/institution", &fixture).await; + let request = submission(); + let answer = client + .message_receipt(&token(), IDEMPOTENCY_KEY, &request) + .await + .unwrap(); + assert_eq!(answer.value.id, MESSAGE_ID); + let seen = fixture.seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, "/institution/v1/messages/receipt"); + assert_eq!(seen[0].1[IDEMPOTENCY_KEY_HEADER], IDEMPOTENCY_KEY); + assert_eq!( + serde_json::from_str::(&seen[0].2).unwrap(), + serde_json::to_value(request).unwrap() + ); + server.abort(); +} + +#[tokio::test] +async fn unresolved_or_unavailable_receipt_lookup_never_retries() { + for code in [ + ProblemCode::ReceiptUnresolved, + ProblemCode::ServiceUnavailable, + ] { + let fixture = Fixture::problem(code); + let (client, server) = serve("", &fixture).await; + let error = client + .message_receipt(&token(), IDEMPOTENCY_KEY, &submission()) + .await + .unwrap_err(); + assert!( + matches!(error, MessagingClientError::Problem { code: actual, .. } if actual == code) + ); + assert_eq!(fixture.seen.lock().unwrap().len(), 1); + server.abort(); + } +} diff --git a/crates/registry-messaging-core/src/naming.rs b/crates/registry-messaging-core/src/naming.rs index 52a644a594..68abaf886e 100644 --- a/crates/registry-messaging-core/src/naming.rs +++ b/crates/registry-messaging-core/src/naming.rs @@ -101,6 +101,9 @@ pub const METRICS_PATH: &str = "/metrics"; /// Route accepting message submissions. pub const MESSAGES_PATH: &str = "/v1/messages"; +/// Read-only lookup of an original submission receipt. +pub const MESSAGE_RECEIPT_PATH: &str = "/v1/messages/receipt"; + /// Route template reading one message. pub const MESSAGE_PATH: &str = "/v1/messages/{message_id}"; diff --git a/crates/registry-messaging-core/src/problem.rs b/crates/registry-messaging-core/src/problem.rs index 26279fcc5b..cdc9c7f196 100644 --- a/crates/registry-messaging-core/src/problem.rs +++ b/crates/registry-messaging-core/src/problem.rs @@ -43,6 +43,7 @@ pub const OPERATION_NOT_AUTHORIZED_PROBLEM: &str = "operation.not-authorized"; pub const PROFILE_NOT_AUTHORIZED_PROBLEM: &str = "profile.not-authorized"; pub const QUOTA_EXCEEDED_PROBLEM: &str = "quota.exceeded"; pub const RATE_LIMIT_EXCEEDED_PROBLEM: &str = "rate-limit.exceeded"; +pub const RECEIPT_UNRESOLVED_PROBLEM: &str = "receipt.unresolved"; pub const REQUEST_BODY_TOO_LARGE_PROBLEM: &str = "request.body-too-large"; pub const REQUEST_INVALID_PROBLEM: &str = "request.invalid"; pub const REQUEST_METHOD_NOT_ALLOWED_PROBLEM: &str = "request.method-not-allowed"; @@ -73,6 +74,7 @@ pub enum ProblemCode { ProfileNotAuthorized, QuotaExceeded, RateLimitExceeded, + ReceiptUnresolved, RequestBodyTooLarge, RequestInvalid, RequestMethodNotAllowed, @@ -104,6 +106,7 @@ impl ProblemCode { Self::ProfileNotAuthorized, Self::QuotaExceeded, Self::RateLimitExceeded, + Self::ReceiptUnresolved, Self::RequestBodyTooLarge, Self::RequestInvalid, Self::RequestMethodNotAllowed, @@ -135,6 +138,7 @@ impl ProblemCode { Self::ProfileNotAuthorized => PROFILE_NOT_AUTHORIZED_PROBLEM, Self::QuotaExceeded => QUOTA_EXCEEDED_PROBLEM, Self::RateLimitExceeded => RATE_LIMIT_EXCEEDED_PROBLEM, + Self::ReceiptUnresolved => RECEIPT_UNRESOLVED_PROBLEM, Self::RequestBodyTooLarge => REQUEST_BODY_TOO_LARGE_PROBLEM, Self::RequestInvalid => REQUEST_INVALID_PROBLEM, Self::RequestMethodNotAllowed => REQUEST_METHOD_NOT_ALLOWED_PROBLEM, @@ -176,6 +180,7 @@ impl ProblemCode { 409 } Self::IdempotencyExpired => 410, + Self::ReceiptUnresolved => 409, Self::RequestBodyTooLarge => 413, Self::RequestUnsupportedMediaType => 415, Self::CallbackUnreadable @@ -211,6 +216,7 @@ impl ProblemCode { Self::ProfileNotAuthorized => "Profile not authorized", Self::QuotaExceeded => "Daily limit reached", Self::RateLimitExceeded => "Request rate exceeded", + Self::ReceiptUnresolved => "Original receipt unresolved", Self::RequestBodyTooLarge => "Payload too large", Self::RequestInvalid => "Request invalid", Self::RequestMethodNotAllowed => "Method not allowed", @@ -274,6 +280,7 @@ impl ProblemCode { Self::RateLimitExceeded => { "The request rate accepted from this caller is exceeded. Try again after the time in Retry-After." } + Self::ReceiptUnresolved => "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred.", Self::RequestBodyTooLarge => "The request body exceeds the accepted size.", Self::RequestInvalid => "The request could not be read as a Messaging request.", Self::RequestMethodNotAllowed => "The route exists but not for this method.", @@ -311,7 +318,7 @@ mod tests { #[test] fn the_vocabulary_is_complete_and_closed() { - assert_eq!(ProblemCode::ALL.len(), 26); + assert_eq!(ProblemCode::ALL.len(), 27); for code in ProblemCode::ALL { assert_eq!(ProblemCode::from_code(code.code()), Some(*code)); } diff --git a/crates/registry-messaging/src/http.rs b/crates/registry-messaging/src/http.rs index 6dc3a224f8..f33bed2d8c 100644 --- a/crates/registry-messaging/src/http.rs +++ b/crates/registry-messaging/src/http.rs @@ -47,7 +47,7 @@ use axum::{Json, Router}; use registry_messaging_core::{ type_uri, AccessRole, Caller, ContentRefusal, MessageView, Package, ProblemCode, TemplatePreview, TemplatePreviewRequest, HEALTH_PATH, IDEMPOTENCY_KEY_HEADER, MESSAGES_PATH, - MESSAGE_CANCEL_PATH, MESSAGE_PATH, METRICS_PATH, PROVIDER_CALLBACK_PATH, + MESSAGE_CANCEL_PATH, MESSAGE_PATH, MESSAGE_RECEIPT_PATH, METRICS_PATH, PROVIDER_CALLBACK_PATH, PROVIDER_CALLBACK_TOKEN_PATH, READY_PATH, TEMPLATE_PREVIEW_PATH, }; use registry_platform_authcommon::parse_bearer_token; @@ -243,6 +243,24 @@ pub const OPERATIONS: &[Operation] = &[ ProblemCode::ServiceUnavailable, ], }, + Operation { + method: "post", + path: MESSAGE_RECEIPT_PATH, + operation_id: "readMessageReceipt", + summary: "Read the retained original acceptance receipt for this caller, key and exact request without submitting or rendering a message. An unresolved receipt does not establish that no effect occurred.", + authenticated: true, + success_status: 200, + idempotency_key: true, + request_body: RequestBody::Json("SubmitMessageRequest"), + response_body: Some("MessageReceipt"), + problems: &[ + ProblemCode::AuthenticationRefused, ProblemCode::ProfileNotAuthorized, + ProblemCode::ReceiptUnresolved, + ProblemCode::RequestInvalid, ProblemCode::RequestUnprocessable, + ProblemCode::RequestBodyTooLarge, ProblemCode::RequestUnsupportedMediaType, + ProblemCode::RequestMethodNotAllowed, ProblemCode::ServiceUnavailable, + ], + }, Operation { method: "get", path: MESSAGE_PATH, @@ -367,6 +385,7 @@ pub fn router(state: HttpState) -> Router { .route(HEALTH_PATH, get(health)) .route(READY_PATH, get(ready)) .route(MESSAGES_PATH, post(submit_message)) + .route(MESSAGE_RECEIPT_PATH, post(message_receipt)) .route(MESSAGE_PATH, get(get_message)) .route(MESSAGE_CANCEL_PATH, post(cancel_message)) .route(TEMPLATE_PREVIEW_PATH, post(preview_template)) @@ -656,6 +675,37 @@ async fn get_message( Ok(Json(message.view)) } +async fn message_receipt( + State(state): State, + request: Request, +) -> Result { + let caller = authenticate(&state, request.headers()).await?; + // Observation neither renders nor submits. Keep the original receipt + // available even when the caller has spent its submission budget. + if !is_json(request.headers()) { + return Err(HttpError(ProblemCode::RequestUnsupportedMediaType)); + } + let key = request + .headers() + .get(IDEMPOTENCY_KEY_HEADER) + .filter(|key| valid_idempotency_key(key.as_bytes())) + .and_then(|key| key.to_str().ok()) + .ok_or(HttpError(ProblemCode::RequestInvalid))? + .to_owned(); + let body = read_request_body(request).await?; + let service = state + .messages + .as_ref() + .ok_or(HttpError(ProblemCode::ServiceUnavailable))?; + Ok(Json( + service + .message_receipt(&caller, &key, &body) + .await + .map_err(HttpError)?, + ) + .into_response()) +} + async fn cancel_message( State(state): State, message_id: Result, PathRejection>, diff --git a/crates/registry-messaging/src/messages.rs b/crates/registry-messaging/src/messages.rs index 82e3e8c846..07ec1c4daf 100644 --- a/crates/registry-messaging/src/messages.rs +++ b/crates/registry-messaging/src/messages.rs @@ -1031,6 +1031,88 @@ impl MessageService { crate::receipts::record_receipt(&self.messages.store, &self.audit, provider, receipt).await } + /// Read only a retained success receipt under the original owner and key. + /// No content rendering, new submission, dispatch or receipt write occurs. + pub async fn message_receipt( + &self, + caller: &Caller, + key: &str, + body: &[u8], + ) -> Result { + let actor = self + .audit + .principal_pseudonym(&caller.identity) + .map_err(|_| ProblemCode::ServiceUnavailable)?; + let record = json!({"event": "messaging.message.receipt-read", "actor": actor}); + let mut audit = self + .audit + .begin(record.clone()) + .await + .map_err(|_| ProblemCode::ServiceUnavailable)?; + let result = async { + let value = parse_json_strict(body).map_err(|_| ProblemCode::RequestInvalid)?; + let canonical = + canonicalize_json(&value).map_err(|_| ProblemCode::RequestUnprocessable)?; + // The closed submission shape is checked, but its template is never + // loaded or rendered during observation. + serde_json::from_value::(value) + .map_err(|_| ProblemCode::RequestUnprocessable)?; + let request_hash = idempotency_key(REQUEST_HASH_DOMAIN, &[&canonical]); + let reference = idempotency_key_reference(&caller.identity, SUBMIT_OPERATION, key); + let mut client = self + .messages + .store + .client() + .await + .map_err(|_| ProblemCode::ServiceUnavailable)?; + let tx = client + .build_transaction() + .read_only(true) + .start() + .await + .map_err(|_| ProblemCode::ServiceUnavailable)?; + let stored = lookup_key(&tx, &reference) + .await + .map_err(|_| ProblemCode::ServiceUnavailable)? + .ok_or(ProblemCode::ReceiptUnresolved)?; + if stored.spent + || stored.request_hash.as_deref() != Some(request_hash.as_str()) + || stored.status != Some(202) + || stored.message_id.is_none() + { + return Err(ProblemCode::ReceiptUnresolved); + } + let receipt = stored + .receipt + .and_then(|value| serde_json::from_value::(value).ok()) + .filter(|receipt| { + Some(receipt.id.as_str()) + == stored + .message_id + .as_ref() + .map(|id| id.to_string()) + .as_deref() + }) + .ok_or(ProblemCode::ReceiptUnresolved)?; + tx.commit() + .await + .map_err(|_| ProblemCode::ServiceUnavailable)?; + Ok(receipt) + } + .await; + let mut response = record; + response["outcome"] = json!(if result.is_ok() { + "observed" + } else { + "unresolved" + }); + audit + .respond(response) + .await + .map_err(|_| ProblemCode::ServiceUnavailable)?; + result + } + /// Record one prepared submission under `key`, or answer the receipt /// the key already holds. /// diff --git a/crates/registry-messaging/src/schema.rs b/crates/registry-messaging/src/schema.rs index b574f1a249..73cc494b46 100644 --- a/crates/registry-messaging/src/schema.rs +++ b/crates/registry-messaging/src/schema.rs @@ -869,6 +869,18 @@ mod tests { assert!(message["responses"]["401"].is_object()); } + /// Receipt observation charges no request budget, so the contract never + /// tells a client to wait and retry it. + #[test] + fn the_receipt_read_publishes_no_rate_limit_answer() { + let documents = openapi_documents().unwrap(); + let document: Value = serde_json::from_str(&documents[OPENAPI_FILE]).unwrap(); + let receipt = &document["paths"]["/v1/messages/receipt"]["post"]; + assert_eq!(receipt["operationId"], "readMessageReceipt"); + assert!(receipt["responses"]["409"].is_object()); + assert!(receipt["responses"].get("429").is_none()); + } + #[test] fn the_runtime_schema_publishes_the_closed_callback_verifier_kinds() { let documents = runtime_documents().unwrap(); diff --git a/crates/registry-messaging/tests/postgres_messages.rs b/crates/registry-messaging/tests/postgres_messages.rs index 3cbbaebc5f..079991f1cf 100644 --- a/crates/registry-messaging/tests/postgres_messages.rs +++ b/crates/registry-messaging/tests/postgres_messages.rs @@ -1492,3 +1492,186 @@ async fn a_settlement_whose_outcome_record_is_refused_is_applied_and_unconfirmed ); assert_eq!(harness.state(message_id).await, "delivered"); } + +#[tokio::test] +async fn original_receipt_reads_preserve_ownership_request_expiry_and_product_state() { + let harness = Harness::start().await; + let body = email_submission(); + let key = "original-receipt-canary-key"; + let (status, original) = harness.submit(&sender_token(), key, &body).await; + assert_eq!(status, StatusCode::ACCEPTED); + let id = Uuid::parse_str(original["id"].as_str().unwrap()).unwrap(); + let before = harness + .count("SELECT count(*) FROM messaging_messages") + .await; + async fn lookup(h: &Harness, bearer: &str, key: &str, body: &Value) -> (StatusCode, Value) { + h.send( + Request::builder() + .method("POST") + .uri("/v1/messages/receipt") + .header("authorization", format!("Bearer {bearer}")) + .header("content-type", "application/json") + .header("idempotency-key", key) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(), + ) + .await + } + let answer = lookup(&harness, &sender_token(), key, &body).await; + assert_eq!(answer, (StatusCode::OK, original)); + let mut changed = body.clone(); + changed["data"]["name"] = json!("receipt-sensitive-canary"); + for (bearer, command_key, request) in [ + (sender_token_for(OTHER_SENDER_PRINCIPAL), key, body.clone()), + (sender_token(), "unseen-receipt-key", body.clone()), + (sender_token(), key, changed), + ] { + assert_problem( + &lookup(&harness, &bearer, command_key, &request).await, + StatusCode::CONFLICT, + "receipt.unresolved", + ); + } + harness.isolated.admin.execute("UPDATE messaging_idempotency SET expires_at=transaction_timestamp()-interval '1 second' WHERE message_id=$1", &[&id]).await.unwrap(); + assert_problem( + &lookup(&harness, &sender_token(), key, &body).await, + StatusCode::CONFLICT, + "receipt.unresolved", + ); + assert_eq!( + harness + .count("SELECT count(*) FROM messaging_messages") + .await, + before + ); + assert_eq!( + harness + .count("SELECT count(*) FROM messaging_idempotency") + .await, + 1 + ); + assert_eq!(harness.state(id).await, "pending"); + let audit = serde_json::to_string(&harness.audit_responses().await).unwrap(); + assert!(!audit.contains("receipt-sensitive-canary")); + assert!(!audit.contains(key)); +} + +#[tokio::test] +async fn original_receipt_reads_neither_spend_nor_require_submission_budget() { + let harness = Harness::start().await; + let body = email_submission(); + let sender = sender_token(); + let key = "receipt-before-rate-limit"; + let (status, original) = harness.submit(&sender, key, &body).await; + assert_eq!(status, StatusCode::ACCEPTED); + let profiles = AccessProfiles::new( + harness + .package + .access_profiles() + .iter() + .cloned() + .map(|mut profile| { + profile.requests_per_minute = 1; + profile.burst = 1; + profile + }) + .collect(), + ) + .unwrap(); + let app = harness + .app_with_caller_limits(registry_messaging::limits::CallerLimits::new(&profiles).unwrap()) + .await; + for after_submission in [false, true] { + if after_submission { + let (status, _, _) = submit_to(app.clone(), &sender, "one-budgeted-send", &body).await; + assert_eq!(status, StatusCode::ACCEPTED); + } + for _ in 0..3 { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/messages/receipt") + .header("authorization", format!("Bearer {sender}")) + .header("content-type", "application/json") + .header("idempotency-key", key) + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + assert_eq!(serde_json::from_slice::(&bytes).unwrap(), original); + } + } + let (status, _, problem) = submit_to(app, &sender, "send-over-budget", &body).await; + assert_problem( + &(status, problem), + StatusCode::TOO_MANY_REQUESTS, + "rate-limit.exceeded", + ); + assert_eq!( + harness + .count("SELECT count(*) FROM messaging_messages") + .await, + 2 + ); + assert_eq!( + harness + .count("SELECT count(*) FROM messaging_idempotency") + .await, + 2 + ); +} + +#[tokio::test] +async fn original_receipt_disclosure_requires_both_audit_entries() { + let harness = Harness::start().await; + let body = email_submission(); + let key = "audit-gated-original-receipt"; + assert_eq!( + harness.submit(&sender_token(), key, &body).await.0, + StatusCode::ACCEPTED + ); + for accepted_writes in [0, 1] { + let audit = test_audit(AuditWriter::from_line_sink(Box::new( + RefusingAuditSink::after(accepted_writes), + ))); + let app = harness.app_with_audit(audit).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/messages/receipt") + .header("authorization", format!("Bearer {}", sender_token())) + .header("content-type", "application/json") + .header("idempotency-key", key) + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + let problem: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(problem["code"], "service.unavailable"); + } + assert_eq!( + harness + .count("SELECT count(*) FROM messaging_messages") + .await, + 1 + ); + assert_eq!( + harness + .count("SELECT count(*) FROM messaging_idempotency") + .await, + 1 + ); +} diff --git a/crates/registry-messaging/tests/support/mod.rs b/crates/registry-messaging/tests/support/mod.rs index a710ca8ac9..464d13f2ba 100644 --- a/crates/registry-messaging/tests/support/mod.rs +++ b/crates/registry-messaging/tests/support/mod.rs @@ -461,6 +461,21 @@ impl Harness { /// package's own access profiles, as after an activation that changed /// what a caller may send. pub async fn app_with_access_profiles(&self, profiles: AccessProfiles) -> Router { + self.app_with_profiles_and_limits(profiles, unmetered_limits(&self.package)) + .await + } + + /// Rebuild the router with a bounded submission budget for rate tests. + pub async fn app_with_caller_limits(&self, limits: CallerLimits) -> Router { + self.app_with_profiles_and_limits(self.package.access_profiles().clone(), limits) + .await + } + + async fn app_with_profiles_and_limits( + &self, + profiles: AccessProfiles, + limits: CallerLimits, + ) -> Router { let schema = self.store.current_schema().await.expect("the schema"); let messages = MessageStore::new( self.store.clone(), @@ -494,7 +509,7 @@ impl Harness { audit: Arc::clone(&self.audit), }, metrics: Arc::new(Metrics::default()), - limits: Arc::new(unmetered_limits(&self.package)), + limits: Arc::new(limits), package: Arc::clone(&self.package), audit: Arc::clone(&self.audit), messages: Some(service), diff --git a/crates/registry-scheduling-client-node/README.md b/crates/registry-scheduling-client-node/README.md index 89bc1881b5..24d65ad032 100644 --- a/crates/registry-scheduling-client-node/README.md +++ b/crates/registry-scheduling-client-node/README.md @@ -29,3 +29,9 @@ never established, and every 4xx refusal. It is false for answered and may have committed: read an appointment by its external reference or identifier before choosing a new key. A hold cannot be read and expires on its own, so start a new request. + +`appointmentReceipt` observes a retained original success using the original request and +caller-chosen key with a current token. It makes one receipt request, never +replays the mutation and never retries. `receipt.unresolved` leaves the +original effect unknown; a missing or expired receipt does not prove absence. +The error's `outcomeUnknown` describes this read, not the original effect. diff --git a/crates/registry-scheduling-client-node/__test__/facade.test-d.ts b/crates/registry-scheduling-client-node/__test__/facade.test-d.ts index 85024dba35..d1079e796f 100644 --- a/crates/registry-scheduling-client-node/__test__/facade.test-d.ts +++ b/crates/registry-scheduling-client-node/__test__/facade.test-d.ts @@ -100,3 +100,10 @@ function mayHaveTakenEffect(error: SchedulingClientError): boolean { return unknown } void mayHaveTakenEffect + +const observedReceipt: Promise> = client.appointmentReceipt(token, 'original-key', { admission }) +void observedReceipt +const unresolved: SchedulingProblemCode = 'receipt.unresolved' +void unresolved +// @ts-expect-error receipt observation requires the original caller key +void client.appointmentReceipt(token, { admission }) diff --git a/crates/registry-scheduling-client-node/__test__/facade.test.js b/crates/registry-scheduling-client-node/__test__/facade.test.js index b5bbef3cf9..e4de3db10b 100644 --- a/crates/registry-scheduling-client-node/__test__/facade.test.js +++ b/crates/registry-scheduling-client-node/__test__/facade.test.js @@ -766,3 +766,56 @@ test('a retry ceiling outside zero to two is a configuration error', () => { }); } }); + +test('original receipt observation preserves the caller body, key and current token', async (context) => { + const { baseUrl, requests } = await serve(context, () => ({ + status: 200, contentType: 'application/json', document: APPOINTMENT, + })); + const { SchedulingClient } = require('../client'); + const client = new SchedulingClient({ baseUrl: `${baseUrl}tenant/` }); + const original = { admission: ADMISSION }; + const result = await client.appointmentReceipt('current-token', 'original-key', original); + assert.deepEqual(result, { kind: 'complete', value: APPOINTMENT, traceId: TRACE_ID }); + assert.equal(requests.length, 1); + assert.equal(requests[0].method, 'POST'); + assert.equal(requests[0].path, '/tenant/v1/appointments/receipt'); + assert.equal(requests[0].headers.authorization, 'Bearer current-token'); + assert.equal(requests[0].headers['idempotency-key'], 'original-key'); + assert.deepEqual(JSON.parse(requests[0].body), { hold: null, admission: ADMISSION_ON_THE_WIRE }); +}); + +for (const [code, status, title, detail] of [ + ['receipt.unresolved', 409, 'Original receipt unresolved', 'No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred.'], + ['operation.not-authorized', 403, 'Operation not authorized', 'Server-controlled-detail-canary'], + ['service.unavailable', 503, 'Service unavailable', 'Server-controlled-detail-canary'], +]) { + test(`receipt observation maps ${code} without retry or exposing caller values`, async (context) => { + const { baseUrl, requests } = await serve(context, () => problem(code, status, title, detail)); + const { SchedulingClient, SchedulingClientError } = require('../client'); + const client = new SchedulingClient({ baseUrl, maxMutationRetries: 2 }); + await assert.rejects(client.appointmentReceipt('token-canary', 'key-canary', { admission: ADMISSION }), (error) => { + assert.ok(error instanceof SchedulingClientError); + assert.equal(error.kind, 'problem'); + assert.equal(error.code, code); + assert.equal(error.status, status); + assert.equal(error.traceId, TRACE_ID); + assert.equal(error.outcomeUnknown, status >= 500); + const rendered = inspect(error); + for (const canary of ['token-canary', 'key-canary', 'Server-controlled-detail-canary']) { + assert.equal(rendered.includes(canary), false); + } + return true; + }); + assert.equal(requests.length, 1); + assert.equal(requests[0].path, '/v1/appointments/receipt'); + }); +} + +test('receipt observation refuses an invalid key or request before any exchange', async (context) => { + const { baseUrl, requests } = await serve(context, () => ({ status: 500 })); + const { SchedulingClient } = require('../client'); + const client = new SchedulingClient({ baseUrl }); + await assert.rejects(client.appointmentReceipt('token', 'two words', { admission: ADMISSION }), (error) => error.kind === 'invalid-request'); + await assert.rejects(client.appointmentReceipt('token', 'key', { admission: ADMISSION, extra: true }), (error) => error.kind === 'invalid-request'); + assert.equal(requests.length, 0); +}); diff --git a/crates/registry-scheduling-client-node/client.d.ts b/crates/registry-scheduling-client-node/client.d.ts index 719c28c0b1..43557bf0ec 100644 --- a/crates/registry-scheduling-client-node/client.d.ts +++ b/crates/registry-scheduling-client-node/client.d.ts @@ -170,6 +170,7 @@ export type SchedulingProblemCode = | 'precondition.required' | 'prerequisite.missing' | 'profile.not-authorized' + | 'receipt.unresolved' | 'request.body-too-large' | 'request.invalid' | 'request.method-not-allowed' @@ -216,6 +217,8 @@ export class SchedulingClient { createHold(token: string, idempotencyKey: string, request: AdmissionRequest): Promise> releaseHold(token: string, holdId: string): Promise> createAppointment(token: string, idempotencyKey: string, request: CreateAppointmentRequest): Promise> + /** Observe the original success using the same body/key and current token; never resend or mutate. */ + appointmentReceipt(token: string, idempotencyKey: string, request: CreateAppointmentRequest): Promise> getAppointment(token: string, appointmentId: string): Promise> listAppointments(token: string, reference: ExternalReference, page?: PageQuery | null): Promise>> rescheduleAppointment(token: string, appointmentId: string, idempotencyKey: string, request: RescheduleAppointmentRequest): Promise> diff --git a/crates/registry-scheduling-client-node/client.js b/crates/registry-scheduling-client-node/client.js index 41c3150f61..ad19f0f50b 100644 --- a/crates/registry-scheduling-client-node/client.js +++ b/crates/registry-scheduling-client-node/client.js @@ -118,6 +118,7 @@ for (const [method, jsonIndexes] of [ ['createHold', [2]], ['releaseHold', []], ['createAppointment', [2]], + ['appointmentReceipt', [2]], ['getAppointment', []], ['listAppointments', [1, 2]], ['rescheduleAppointment', [3]], diff --git a/crates/registry-scheduling-client-node/index.d.ts b/crates/registry-scheduling-client-node/index.d.ts index 91d5815151..43278beaa5 100644 --- a/crates/registry-scheduling-client-node/index.d.ts +++ b/crates/registry-scheduling-client-node/index.d.ts @@ -19,6 +19,7 @@ export declare class SchedulingClient { createHold(token: string, idempotencyKey: string, request: any): Promise releaseHold(token: string, holdId: string): Promise createAppointment(token: string, idempotencyKey: string, request: any): Promise + appointmentReceipt(token: string, idempotencyKey: string, request: any): Promise getAppointment(token: string, appointmentId: string): Promise listAppointments(token: string, reference: any, page?: any | undefined | null): Promise rescheduleAppointment(token: string, appointmentId: string, idempotencyKey: string, request: any): Promise diff --git a/crates/registry-scheduling-client-node/src/lib.rs b/crates/registry-scheduling-client-node/src/lib.rs index 9ecfd2df17..7f23250286 100644 --- a/crates/registry-scheduling-client-node/src/lib.rs +++ b/crates/registry-scheduling-client-node/src/lib.rs @@ -224,6 +224,22 @@ impl SchedulingClient { ) } + #[napi] + pub async fn appointment_receipt( + &self, + token: String, + idempotency_key: String, + request: Value, + ) -> Result { + let token = bearer(token)?; + let request: CreateAppointmentRequest = input(request)?; + outcome( + self.inner + .appointment_receipt(SchedulingAuth::new(&token), &idempotency_key, &request) + .await, + ) + } + #[napi] pub async fn get_appointment( &self, diff --git a/crates/registry-scheduling-client-py/README.md b/crates/registry-scheduling-client-py/README.md index 237ab6413d..df19e57a00 100644 --- a/crates/registry-scheduling-client-py/README.md +++ b/crates/registry-scheduling-client-py/README.md @@ -63,3 +63,9 @@ reference or identifier before choosing a new key. A hold cannot be read and expires on its own, so start a new request. This crate is private and does not publish a standalone Python distribution. + +`appointment_receipt` observes a retained original success using the original request and +caller-chosen key with a current token. It makes one receipt request, never +replays the mutation and never retries. `receipt.unresolved` leaves the +original effect unknown; a missing or expired receipt does not prove absence. +The error's `outcome_unknown` describes this read, not the original effect. diff --git a/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi b/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi index c34319905b..f1dbe046b1 100644 --- a/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi +++ b/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi @@ -209,6 +209,7 @@ SchedulingProblemCode: TypeAlias = Literal[ "precondition.required", "prerequisite.missing", "profile.not-authorized", + "receipt.unresolved", "request.body-too-large", "request.invalid", "request.method-not-allowed", @@ -255,6 +256,8 @@ class SchedulingClient: def create_hold(self, token: str, idempotency_key: str, request: AdmissionRequest) -> Complete[HoldDocument]: ... def release_hold(self, token: str, hold_id: str) -> Complete[None]: ... def create_appointment(self, token: str, idempotency_key: str, request: CreateAppointmentRequest) -> Complete[AppointmentDocument]: ... + # Observe the original success using the same body/key and current token; never resend or mutate. + def appointment_receipt(self, token: str, idempotency_key: str, request: CreateAppointmentRequest) -> Complete[AppointmentDocument]: ... def get_appointment(self, token: str, appointment_id: str) -> Complete[AppointmentDocument]: ... def list_appointments(self, token: str, reference: ExternalReference, *, cursor: str | None = None, limit: int | None = None) -> Complete[PageDocument[AppointmentDocument]]: ... def reschedule_appointment(self, token: str, appointment_id: str, idempotency_key: str, request: RescheduleAppointmentRequest) -> Complete[AppointmentDocument]: ... diff --git a/crates/registry-scheduling-client-py/src/lib.rs b/crates/registry-scheduling-client-py/src/lib.rs index 123f4a8375..e5db75c799 100644 --- a/crates/registry-scheduling-client-py/src/lib.rs +++ b/crates/registry-scheduling-client-py/src/lib.rs @@ -491,6 +491,27 @@ impl SchedulingClient { ) } + fn appointment_receipt<'py>( + &self, + py: Python<'py>, + token: &str, + idempotency_key: &str, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let token = bearer(py, token)?; + let request: CreateAppointmentRequest = input(py, request)?; + complete( + py, + py.detach(|| { + self.runtime.block_on(self.inner.appointment_receipt( + SchedulingAuth::new(&token), + idempotency_key, + &request, + )) + }), + ) + } + fn get_appointment<'py>( &self, py: Python<'py>, diff --git a/crates/registry-scheduling-client-py/tests/python/bootstrap.py b/crates/registry-scheduling-client-py/tests/python/bootstrap.py index 3dfa25c968..33e3407838 100644 --- a/crates/registry-scheduling-client-py/tests/python/bootstrap.py +++ b/crates/registry-scheduling-client-py/tests/python/bootstrap.py @@ -2,8 +2,10 @@ from __future__ import annotations +import ctypes import functools import json +import os import pathlib import platform import shutil @@ -35,6 +37,23 @@ def _target_debug() -> pathlib.Path: return pathlib.Path(json.loads(metadata.stdout)["target_directory"]) / "debug" +def _preload_cargo_runtime() -> None: + if platform.system() != "Darwin": + return + runtime_directory = os.environ.get("REGISTRY_CARGO_RUNTIME_LIBRARY_PATH") + if runtime_directory is None: + return + libraries = list( + pathlib.Path(runtime_directory).glob("libaws_lc_fips*_crypto.dylib") + ) + if len(libraries) != 1: + raise RuntimeError( + "expected one AWS-LC FIPS runtime library in " + f"{runtime_directory}, found {len(libraries)}" + ) + ctypes.CDLL(str(libraries[0]), mode=ctypes.RTLD_GLOBAL) + + def _library() -> pathlib.Path: suffix = {"Darwin": ".dylib", "Linux": ".so"}.get(platform.system()) if suffix is None: @@ -61,6 +80,7 @@ def ensure_built() -> None: source = _library() if not source.is_file(): raise RuntimeError(f"cargo did not produce {source}") + _preload_cargo_runtime() import_root = _target_debug() / "scheduling_python_module" import_package = import_root / _MODULE_NAME import_package.mkdir(parents=True, exist_ok=True) diff --git a/crates/registry-scheduling-client-py/tests/python/test_capabilities.py b/crates/registry-scheduling-client-py/tests/python/test_capabilities.py index d6ef8164fc..6d13807c96 100644 --- a/crates/registry-scheduling-client-py/tests/python/test_capabilities.py +++ b/crates/registry-scheduling-client-py/tests/python/test_capabilities.py @@ -17,6 +17,7 @@ "create_hold", "release_hold", "create_appointment", + "appointment_receipt", "get_appointment", "list_appointments", "reschedule_appointment", diff --git a/crates/registry-scheduling-client-py/tests/python/test_native_requests.py b/crates/registry-scheduling-client-py/tests/python/test_native_requests.py index 9ed90e3dbc..334d96922f 100644 --- a/crates/registry-scheduling-client-py/tests/python/test_native_requests.py +++ b/crates/registry-scheduling-client-py/tests/python/test_native_requests.py @@ -181,6 +181,19 @@ def do_POST(self) -> None: # noqa: N802 body = self.rfile.read(length).decode("utf-8") path, _ = self.observe(body) key = self.headers.get("idempotency-key") + if path == "/tenant/v1/appointments/receipt": + failures = { + "unresolved-key-canary": ("receipt.unresolved", 409, "Original receipt unresolved", "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred."), + "denied-key-canary": ("operation.not-authorized", 403, "Operation not authorized", "server-detail-canary"), + "unavailable-key-canary": ("service.unavailable", 503, "Service unavailable", "server-detail-canary"), + } + failure = failures.get(self.headers.get("idempotency-key")) + if failure: + self.respond_problem(*failure) + else: + self.respond(200, APPOINTMENT) + return + if key == REUSED_KEY: self.respond_problem( "idempotency.key-reused", @@ -513,6 +526,46 @@ def test_unreachable_service_is_a_transport_failure(self) -> None: self.assertEqual(raised.exception.kind, "transport") self.assertIsInstance(raised.exception.transport_kind, str) + def test_receipt_observation_preserves_the_original_request_key_and_current_token(self) -> None: + result = self.client.appointment_receipt("current-token", "original-key", {"admission": ADMISSION}) + self.assertEqual(result, {"kind": "complete", "value": APPOINTMENT, "trace_id": TRACE_ID}) + self.assertEqual(len(_Handler.observations), 1) + observed = _Handler.observations[0] + self.assertEqual(observed["method"], "POST") + self.assertEqual(observed["path"], "/tenant/v1/appointments/receipt") + self.assertEqual(observed["authorization"], "Bearer current-token") + self.assertEqual(observed["idempotency_key"], "original-key") + self.assertEqual(json.loads(observed["body"]), {"hold": None, "admission": ADMISSION_ON_THE_WIRE}) + + def test_receipt_observation_maps_unresolved_and_refused_without_retry_or_disclosure(self) -> None: + for key, code, status in [ + ("unresolved-key-canary", "receipt.unresolved", 409), + ("denied-key-canary", "operation.not-authorized", 403), + ("unavailable-key-canary", "service.unavailable", 503), + ]: + with self.subTest(code=code): + _Handler.observations.clear() + with self.assertRaises(SchedulingClientError) as raised: + self.client.appointment_receipt("token-canary", key, {"admission": ADMISSION}) + error = raised.exception + self.assertEqual(error.kind, "problem") + self.assertEqual(error.code, code) + self.assertEqual(error.status, status) + self.assertEqual(error.trace_id, TRACE_ID) + self.assertEqual(error.outcome_unknown, status >= 500) + rendered = "\n".join((str(error), repr(error), repr(vars(error)))) + for canary in [key, "token-canary", "server-detail-canary"]: + self.assertNotIn(canary, rendered) + self.assertEqual(len(_Handler.observations), 1) + self.assertEqual(_Handler.observations[0]["path"], "/tenant/v1/appointments/receipt") + + def test_receipt_observation_refuses_invalid_key_or_request_before_exchange(self) -> None: + for key, request in [("two words", {"admission": ADMISSION}), ("key", {"admission": ADMISSION, "extra": True})]: + with self.assertRaises(SchedulingClientError) as raised: + self.client.appointment_receipt("token", key, request) + self.assertEqual(raised.exception.kind, "invalid-request") + self.assertEqual(_Handler.observations, []) + if __name__ == "__main__": unittest.main() diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs index d9371b377b..8efda1f6f1 100644 --- a/crates/registry-scheduling-client/src/client.rs +++ b/crates/registry-scheduling-client/src/client.rs @@ -16,10 +16,10 @@ use registry_scheduling_core::{ AppointmentHistoryEntryDocument, AvailabilityEntry, CancelAppointmentRequest, CreateAppointmentRequest, ExplainDocument, ExternalReference, HoldDocument, LocationDocument, OfferingDocument, PageDocument, ProblemCode, RescheduleAppointmentRequest, ResourceDocument, - SchedulingServiceDocument, ServiceDocument, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, - AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, - LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, - RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, + SchedulingServiceDocument, ServiceDocument, APPOINTMENTS_PATH, APPOINTMENT_RECEIPT_PATH, + AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, HOLDS_PATH, + IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, + OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, }; use reqwest::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use reqwest::{RequestBuilder, Response, StatusCode, Url}; @@ -213,6 +213,31 @@ impl SchedulingClient { .await } + /// Observe a retained original success without replaying a capacity command. + /// A missing, changed or expired receipt is unresolved, not proof of no effect. + pub async fn appointment_receipt( + &self, + auth: SchedulingAuth<'_>, + idempotency_key: &str, + body: &CreateAppointmentRequest, + ) -> Result, SchedulingClientError> { + validate_idempotency_key(idempotency_key)?; + let request = self + .authorized( + self.http + .post(self.url_from_constant(APPOINTMENT_RECEIPT_PATH)?) + .json(body), + &auth, + ) + .header( + HeaderName::from_static(IDEMPOTENCY_KEY_HEADER), + HeaderValue::from_str(idempotency_key).map_err(|_| { + SchedulingClientError::invalid_request("the idempotency key is invalid") + })?, + ); + self.send_json(request, StatusCode::OK).await + } + pub async fn get_appointment( &self, auth: SchedulingAuth<'_>, diff --git a/crates/registry-scheduling-client/src/lib.rs b/crates/registry-scheduling-client/src/lib.rs index 82d64ed68d..84b4fb7179 100644 --- a/crates/registry-scheduling-client/src/lib.rs +++ b/crates/registry-scheduling-client/src/lib.rs @@ -32,6 +32,10 @@ //! Re-exports are flat, so a caller names a document, a route constant, or a //! vocabulary entry through this crate exactly as the core spells it. +//! `appointment_receipt` observes a retained original success by caller, key, and exact request +//! without replaying the mutation. The single read is never retried, and a +//! typed `receipt.unresolved` preserves uncertainty about the original effect. + #![deny(unsafe_code)] mod client; @@ -51,7 +55,8 @@ pub use registry_scheduling_core::{ OfferingDocument, PageDocument, PartyCounts, ProblemCode, ReminderDocument, RescheduleAppointmentRequest, ResourceDocument, SchedulingModeDocument, SchedulingServiceDocument, ServiceDocument, WindowDocument, APPOINTMENTS_PATH, - AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, HOLDS_PATH, - IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, - OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, + APPOINTMENT_RECEIPT_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, + HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, + MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, + SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, }; diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index 8347df1ddd..2ed8aa1418 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -925,3 +925,68 @@ async fn invalid_client_input_never_reaches_the_wire() { Err(SchedulingClientError::InvalidRequest { .. }) )); } + +#[tokio::test] +async fn original_appointment_receipt_lookup_is_one_read_with_the_original_body() { + let observations: Observations = Arc::default(); + let fixture = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let app = Router::new() + .route("/v1/appointments/receipt", post(capture_call)) + .with_state(fixture); + let (address, server) = spawn(app).await; + let token = BearerToken::new("read-token").unwrap(); + let body = CreateAppointmentRequest { + hold: None, + admission: Some(admission()), + }; + let answer = client(&address) + .appointment_receipt(auth(&token), "original-key", &body) + .await + .unwrap(); + assert_eq!(answer.value.appointment_id, "appt-1"); + let seen = observations.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].method, "POST"); + assert_eq!(seen[0].headers["idempotency-key"], "original-key"); + assert_eq!( + serde_json::from_slice::(&seen[0].body).unwrap(), + serde_json::to_value(body).unwrap() + ); + server.abort(); +} + +#[tokio::test] +async fn unresolved_or_unavailable_appointment_receipt_lookup_never_retries() { + for code in [ + ProblemCode::ReceiptUnresolved, + ProblemCode::ServiceUnavailable, + ] { + let observations: Observations = Arc::default(); + let body = serde_json::json!({"type":type_uri(code.code()), "title":code.title(), + "status":code.http_status(), "detail":code.detail(), "code":code.code(), "traceId":TRACE_ID}) + .to_string(); + let fixture = Fixture::json( + &observations, + StatusCode::from_u16(code.http_status()).unwrap(), + &body, + ) + .with_content_type("application/problem+json"); + let app = Router::new() + .route("/v1/appointments/receipt", post(capture_call)) + .with_state(fixture); + let (address, server) = spawn(app).await; + let token = BearerToken::new("read-token").unwrap(); + let original = CreateAppointmentRequest { + hold: None, + admission: Some(admission()), + }; + let result = client(&address) + .appointment_receipt(auth(&token), "original-key", &original) + .await; + assert!( + matches!(result, Err(SchedulingClientError::Problem { code: observed, .. }) if observed == code) + ); + assert_eq!(observations.lock().unwrap().len(), 1); + server.abort(); + } +} diff --git a/crates/registry-scheduling-core/src/naming.rs b/crates/registry-scheduling-core/src/naming.rs index 0c7672365c..c6967c6d86 100644 --- a/crates/registry-scheduling-core/src/naming.rs +++ b/crates/registry-scheduling-core/src/naming.rs @@ -101,6 +101,9 @@ pub const HOLDS_PATH: &str = "/v1/holds"; /// Route confirming holds and creating appointments. pub const APPOINTMENTS_PATH: &str = "/v1/appointments"; +/// Read-only lookup of an original appointment command receipt. +pub const APPOINTMENT_RECEIPT_PATH: &str = "/v1/appointments/receipt"; + /// Route listing backing resources. pub const RESOURCES_PATH: &str = "/v1/resources"; diff --git a/crates/registry-scheduling-core/src/problem.rs b/crates/registry-scheduling-core/src/problem.rs index a8d1fa5b50..2f09e7345b 100644 --- a/crates/registry-scheduling-core/src/problem.rs +++ b/crates/registry-scheduling-core/src/problem.rs @@ -40,6 +40,7 @@ pub const PRECONDITION_FAILED_PROBLEM: &str = "precondition.failed"; pub const PRECONDITION_REQUIRED_PROBLEM: &str = "precondition.required"; pub const PREREQUISITE_MISSING_PROBLEM: &str = "prerequisite.missing"; pub const PROFILE_NOT_AUTHORIZED_PROBLEM: &str = "profile.not-authorized"; +pub const RECEIPT_UNRESOLVED_PROBLEM: &str = "receipt.unresolved"; pub const REQUEST_BODY_TOO_LARGE_PROBLEM: &str = "request.body-too-large"; pub const REQUEST_INVALID_PROBLEM: &str = "request.invalid"; pub const REQUEST_METHOD_NOT_ALLOWED_PROBLEM: &str = "request.method-not-allowed"; @@ -76,6 +77,7 @@ pub enum ProblemCode { PreconditionRequired, PrerequisiteMissing, ProfileNotAuthorized, + ReceiptUnresolved, RequestBodyTooLarge, RequestInvalid, RequestMethodNotAllowed, @@ -113,6 +115,7 @@ impl ProblemCode { Self::PreconditionRequired, Self::PrerequisiteMissing, Self::ProfileNotAuthorized, + Self::ReceiptUnresolved, Self::RequestBodyTooLarge, Self::RequestInvalid, Self::RequestMethodNotAllowed, @@ -150,6 +153,7 @@ impl ProblemCode { Self::PreconditionRequired => PRECONDITION_REQUIRED_PROBLEM, Self::PrerequisiteMissing => PREREQUISITE_MISSING_PROBLEM, Self::ProfileNotAuthorized => PROFILE_NOT_AUTHORIZED_PROBLEM, + Self::ReceiptUnresolved => RECEIPT_UNRESOLVED_PROBLEM, Self::RequestBodyTooLarge => REQUEST_BODY_TOO_LARGE_PROBLEM, Self::RequestInvalid => REQUEST_INVALID_PROBLEM, Self::RequestMethodNotAllowed => REQUEST_METHOD_NOT_ALLOWED_PROBLEM, @@ -194,6 +198,7 @@ impl ProblemCode { Self::OperationNotAuthorized | Self::ProfileNotAuthorized => 403, Self::RequestNotFound => 404, Self::RequestMethodNotAllowed => 405, + Self::ReceiptUnresolved => 409, Self::RequestBodyTooLarge => 413, Self::RequestUnsupportedMediaType => 415, Self::BookingDuplicateActive @@ -245,6 +250,7 @@ impl ProblemCode { Self::PreconditionRequired => "Precondition required", Self::PrerequisiteMissing => "Prerequisite missing", Self::ProfileNotAuthorized => "Profile not authorized", + Self::ReceiptUnresolved => "Original receipt unresolved", Self::RequestBodyTooLarge => "Payload too large", Self::RequestInvalid => "Request invalid", Self::RequestMethodNotAllowed => "Method not allowed", @@ -326,6 +332,7 @@ impl ProblemCode { Self::ProfileNotAuthorized => { "The selected Scheduling profile does not authorize this request." } + Self::ReceiptUnresolved => "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred.", Self::RequestBodyTooLarge => "The request body exceeds the accepted size.", Self::RequestInvalid => "The request could not be read as a Scheduling request.", Self::RequestMethodNotAllowed => "The route exists but not for this method.", @@ -361,7 +368,7 @@ mod tests { #[test] fn the_vocabulary_is_complete_and_closed() { - assert_eq!(ProblemCode::ALL.len(), 32); + assert_eq!(ProblemCode::ALL.len(), 33); for code in ProblemCode::ALL { assert_eq!(ProblemCode::from_code(code.code()), Some(*code)); } diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index 22f0b8598d..06fd0e246b 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -22,7 +22,7 @@ use std::collections::BTreeSet; use std::sync::Arc; -use axum::extract::{Path, Query, State}; +use axum::extract::{FromRequest, Path, Query, Request, State}; use axum::http::header::{ AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE, }; @@ -39,8 +39,8 @@ use registry_platform_httpsec::{ use registry_scheduling_core::{ type_uri, valid_identifier, valid_reference, AdmissionRequest, CancelAppointmentRequest, CreateAppointmentRequest, ExternalReference, ProblemCode, RescheduleAppointmentRequest, - APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, HOLDS_PATH, - IDEMPOTENCY_KEY_HEADER, LOCATIONS_PATH, MAXIMUM_COLLECTION_ENTRIES, + APPOINTMENTS_PATH, APPOINTMENT_RECEIPT_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, + HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LOCATIONS_PATH, MAXIMUM_COLLECTION_ENTRIES, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, }; use serde::Deserialize; @@ -84,6 +84,7 @@ pub fn router(state: HttpState) -> Router { .route(HOLD_ROUTE, delete(release_hold)) .route(APPOINTMENTS_PATH, get(list_appointments)) .route(APPOINTMENTS_PATH, post(create_appointment)) + .route(APPOINTMENT_RECEIPT_PATH, post(appointment_receipt)) .route(APPOINTMENT_ROUTE, get(get_appointment)) .route(APPOINTMENT_RESCHEDULE_ROUTE, post(reschedule_appointment)) .route(APPOINTMENT_CANCEL_ROUTE, post(cancel_appointment)) @@ -365,6 +366,32 @@ async fn create_appointment( }) } +async fn appointment_receipt( + State(state): State, + request: Request, +) -> Result { + let caller = authenticate_read(&state, request.headers()).await?; + let headers = request.headers().clone(); + // Keep Axum's media-type, request-shape and bounded-body semantics, but + // authenticate the receipt observer before any JSON extraction. + let Json(request) = match Json::::from_request(request, &state).await + { + Ok(request) => request, + Err(rejection) => return Ok(rejection.into_response()), + }; + let key = idempotency_key(&headers)?; + if let Some(admission) = &request.admission { + bounded_admission(admission)?; + } + Ok(Json( + state + .service + .appointment_receipt(&caller, key, &request, state.store.observed_now()) + .await?, + ) + .into_response()) +} + async fn get_appointment( State(state): State, headers: HeaderMap, diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 9823271907..3bf0955c05 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -847,6 +847,78 @@ impl SchedulingService { .await } + /// Observe the original success receipt without replaying a command. + /// Ordinary read authority is checked at the HTTP boundary; lookup stays + /// scoped to the verified issuer and subject even after task authority ends. + pub async fn appointment_receipt( + &self, + caller: &Caller, + key: &str, + request: &CreateAppointmentRequest, + now: DateTime, + ) -> Result { + let (scope, request_hash) = match (&request.hold, &request.admission) { + (None, Some(admission)) => ( + "appointment:create".to_owned(), + admission_request_hash(admission), + ), + (Some(hold), None) => { + let hold = Uuid::parse_str(hold) + .map_err(|_| ServiceError::Problem(ProblemCode::RequestInvalid))?; + ( + format!("hold:{hold}:confirm"), + canonical_hash(&json!({"hold": hold}))?, + ) + } + _ => return Err(ServiceError::Problem(ProblemCode::RequestInvalid)), + }; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let record = json!({"event": "scheduling.appointment.receipt-read", "actor": actor}); + let mut audit = self.audit.begin(Uuid::new_v4(), record.clone(), + json!({"event": "scheduling.appointment.receipt-read", "actor": actor, "outcome": "unfinished"})) + .await.map_err(|_| ServiceError::Problem(ProblemCode::ServiceUnavailable))?; + let result = async { + let receipt = self + .store + .appointment_receipt( + &caller.issuer, + &caller.subject, + &scope, + key, + &request_hash, + now, + ) + .await? + .ok_or(ServiceError::Problem(ProblemCode::ReceiptUnresolved))?; + let claim = receipt + .get("claim") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + .ok_or(ServiceError::Problem(ProblemCode::ReceiptUnresolved))?; + if receipt.get("kind").and_then(Value::as_str) != Some("booking") { + return Err(ServiceError::Problem(ProblemCode::ReceiptUnresolved)); + } + Ok(appointment_document( + &claim, + &self.policy, + self.revision(), + Some(&receipt), + )) + } + .await; + let mut response = record; + response["outcome"] = json!(if result.is_ok() { + "observed" + } else { + "unresolved" + }); + audit + .respond(response) + .await + .map_err(|_| ServiceError::Problem(ProblemCode::ServiceUnavailable))?; + result + } + /// One appointment by id, visible to the caller that owns it. pub async fn get_appointment( &self, diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index dacebf748a..33480ae58e 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -2063,6 +2063,31 @@ impl PostgresStore { Ok(CommitOutcome::Cancelled(cancelled)) } + /// Read a retained successful command receipt for the exact owning caller. + /// Absence, request mismatch and expiry all remain unresolved. This read + /// never opens a capacity transaction or creates an idempotency attempt. + pub async fn appointment_receipt( + &self, + issuer: &str, + subject: &str, + scope: &str, + key: &str, + request_hash: &str, + now: DateTime, + ) -> Result, StoreError> { + let client = self.client().await?; + let reference = attempt_key_reference(issuer, subject, scope, key); + let row = client + .query_opt( + "SELECT receipt FROM scheduling_attempts WHERE key_reference=$1 \ + AND request_hash=$2 AND state='completed' AND status_code IN (200,201) \ + AND erased_at IS NULL AND expires_at>$3", + &[&reference, &request_hash, &now], + ) + .await?; + Ok(row.and_then(|row| row.get(0))) + } + /// Expire holds whose TTL has passed. Each expiry is a state change with /// its own history event, so the ledger never depends on the sweeper /// having run for capacity to be free: the snapshot query already diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index ed13ffcf90..80211fdbf9 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -8886,6 +8886,308 @@ async fn a_permission_refusal_the_destination_refuses_answers_service_unavailabl assert_eq!(problem["code"], "service.unavailable"); } +#[tokio::test] +async fn original_appointment_receipt_is_readable_without_a_grant_and_never_creates_capacity() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let key = "original-booking-receipt"; + let (status, original) = fx + .post("/v1/appointments", &fx.agent, key, body.clone()) + .await; + assert_eq!(status, StatusCode::CREATED, "{original}"); + let same_owner = token(json!({"sub":"principal-agent", "azp": CLIENT, + "registry_scopes":"scheduling-read", "registry_actor_kind":"service"})); + let before: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_claims", &[]) + .await + .unwrap() + .get(0); + let (status, observed) = fx + .post("/v1/appointments/receipt", &same_owner, key, body.clone()) + .await; + assert_eq!(status, StatusCode::OK, "{observed}"); + assert_eq!(observed, original); + let mut changed = body.clone(); + changed["admission"]["duplicateKey"] = json!("different-party"); + for (bearer, command_key, request) in [ + (&fx.reader, key, body.clone()), + (&same_owner, "missing-key", body.clone()), + (&same_owner, key, changed), + ] { + let (status, unresolved) = fx + .post("/v1/appointments/receipt", bearer, command_key, request) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{unresolved}"); + assert_eq!(unresolved["code"], "receipt.unresolved"); + } + fx.admin + .execute( + "UPDATE scheduling_attempts SET expires_at=$1", + &[&(pinned_now() - TimeDelta::seconds(1))], + ) + .await + .unwrap(); + let (status, unresolved) = fx + .post("/v1/appointments/receipt", &same_owner, key, body) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{unresolved}"); + assert_eq!(unresolved["code"], "receipt.unresolved"); + let after: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_claims", &[]) + .await + .unwrap() + .get(0); + let attempts: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_attempts", &[]) + .await + .unwrap() + .get(0); + assert_eq!(before, after); + assert_eq!(attempts, 1); +} + +/// A confirmation from a hold is stored under that hold's own confirm scope, +/// so its receipt is read back by naming the hold, and only the hold and the +/// key that confirmed it resolve it. +#[tokio::test] +async fn original_hold_confirmation_receipt_is_read_by_its_hold_and_key() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "receipt-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{hold}"); + let hold_id = hold["holdId"].as_str().expect("a hold identifier"); + let body = json!({"hold": hold_id, "admission": null}); + let key = "original-hold-confirmation"; + let (status, original) = fx + .post("/v1/appointments", &fx.agent, key, body.clone()) + .await; + assert_eq!(status, StatusCode::CREATED, "{original}"); + let same_owner = token(json!({"sub":"principal-agent", "azp": CLIENT, + "registry_scopes":"scheduling-read", "registry_actor_kind":"service"})); + let before = claims_and_receipts(&fx).await; + + let (status, observed) = fx + .post("/v1/appointments/receipt", &same_owner, key, body.clone()) + .await; + assert_eq!(status, StatusCode::OK, "{observed}"); + assert_eq!(observed, original); + + let other_hold = json!({"hold": Uuid::new_v4().to_string(), "admission": null}); + for (command_key, request) in [(key, other_hold), ("receipt-hold", body)] { + let (status, unresolved) = fx + .post( + "/v1/appointments/receipt", + &same_owner, + command_key, + request, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{unresolved}"); + assert_eq!(unresolved["code"], "receipt.unresolved"); + } + assert_eq!(claims_and_receipts(&fx).await, before); +} + +/// A refusal stores its receipt so a replay of the key answers the same, but +/// that receipt is never an original appointment: the receipt read resolves +/// only a completed attempt that answered 200 or 201. +#[tokio::test] +async fn a_refused_attempt_never_resolves_an_original_appointment_receipt() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let booked_key = "receipt-booked"; + let (status, booked) = fx + .post("/v1/appointments", &fx.agent, booked_key, body.clone()) + .await; + assert_eq!(status, StatusCode::CREATED, "{booked}"); + let refused_key = "receipt-refused"; + let (status, refused) = fx + .post("/v1/appointments", &fx.agent, refused_key, body.clone()) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refused}"); + assert_eq!(refused["code"], "capacity.exhausted"); + let stored: (String, i32) = fx + .admin + .query_one( + "SELECT state, status_code FROM scheduling_attempts WHERE idempotency_key=$1", + &[&refused_key], + ) + .await + .map(|row| (row.get(0), row.get(1))) + .expect("the refusal stored its receipt"); + assert_eq!(stored, ("refused".to_owned(), 409)); + let same_owner = token(json!({"sub":"principal-agent", "azp": CLIENT, + "registry_scopes":"scheduling-read", "registry_actor_kind":"service"})); + + let (status, unresolved) = fx + .post( + "/v1/appointments/receipt", + &same_owner, + refused_key, + body.clone(), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{unresolved}"); + assert_eq!(unresolved["code"], "receipt.unresolved"); + + // No path completes an attempt with a refusal status, so the status + // filter is observed by giving the booked attempt one: its stored + // appointment would otherwise still be disclosed. + fx.admin + .execute( + "UPDATE scheduling_attempts SET status_code=409 WHERE idempotency_key=$1", + &[&booked_key], + ) + .await + .expect("rewrite the booked attempt's status"); + let (status, unresolved) = fx + .post("/v1/appointments/receipt", &same_owner, booked_key, body) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{unresolved}"); + assert_eq!(unresolved["code"], "receipt.unresolved"); + assert_eq!(claims_and_receipts(&fx).await, (1, 2)); +} + +#[tokio::test] +async fn original_appointment_receipt_disclosure_requires_both_audit_entries() { + for accepted_writes in [0, 1] { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let key = "audit-gated-original-booking"; + assert_eq!( + fx.post("/v1/appointments", &fx.agent, key, body.clone()) + .await + .0, + StatusCode::CREATED + ); + fx.capture + .refuse_after(fx.capture.entries().len() + accepted_writes); + let (status, problem) = fx + .post("/v1/appointments/receipt", &fx.agent, key, body) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}"); + assert_eq!(problem["code"], "service.unavailable"); + assert_eq!(claims_and_receipts(&fx).await, (1, 1)); + } +} + +#[tokio::test] +async fn original_appointment_receipt_authenticates_before_parsing_its_bounded_body() { + async fn raw_receipt( + fx: &Fixture, + bearer: Option<&str>, + media_type: &str, + body: &str, + declared_length: bool, + ) -> (StatusCode, Value) { + let mut request = Request::builder() + .method("POST") + .uri("/v1/appointments/receipt") + .header("content-type", media_type) + .header("idempotency-key", "authenticated-original-receipt"); + if let Some(bearer) = bearer { + request = request.header("authorization", format!("Bearer {bearer}")); + } + if declared_length { + request = request.header("content-length", body.len()); + } + let response = fx + .http + .clone() + .oneshot(request.body(Body::from(body.to_owned())).unwrap()) + .await + .unwrap(); + let status = response.status(); + assert_eq!( + response.headers()["content-type"], + "application/problem+json" + ); + let bytes = to_bytes(response.into_body(), 1 << 20).await.unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("private-body-canary")); + assert!(!String::from_utf8_lossy(&bytes).contains("private-credential-canary")); + (status, value) + } + + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let key = "authenticated-original-receipt"; + let (status, original) = fx + .post("/v1/appointments", &fx.agent, key, body.clone()) + .await; + assert_eq!(status, StatusCode::CREATED, "{original}"); + let same_owner = token(json!({"sub":"principal-agent", "azp": CLIENT, + "registry_scopes":"scheduling-read", "registry_actor_kind":"service"})); + let oversized = "x".repeat(registry_platform_httpsec::DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1); + for (media_type, text) in [ + ("application/json", "{private-body-canary"), + ("application/json", r#"{"hold":42}"#), + ("text/plain", "private-body-canary"), + ("application/json", oversized.as_str()), + ] { + for bearer in [None, Some("invalid-token-private-credential-canary")] { + let (status, problem) = raw_receipt(&fx, bearer, media_type, text, false).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{problem}"); + assert_eq!(problem["code"], "authentication.refused"); + } + } + // The router's existing declared-size ceiling precedes the handler even + // for unauthenticated traffic. Do not weaken it to change parse ordering. + let (status, problem) = raw_receipt(&fx, None, "application/json", &oversized, true).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{problem}"); + assert_eq!(problem["code"], "request.body-too-large"); + for (media_type, text, expected_status, expected_code) in [ + ( + "application/json", + "{private-body-canary", + StatusCode::BAD_REQUEST, + "request.invalid", + ), + ( + "application/json", + r#"{"hold":42}"#, + StatusCode::UNPROCESSABLE_ENTITY, + "request.unprocessable", + ), + ( + "text/plain", + "private-body-canary", + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "request.unsupported-media-type", + ), + ( + "application/json", + oversized.as_str(), + StatusCode::PAYLOAD_TOO_LARGE, + "request.body-too-large", + ), + ] { + let (status, problem) = raw_receipt(&fx, Some(&same_owner), media_type, text, false).await; + assert_eq!(status, expected_status, "{problem}"); + assert_eq!(problem["code"], expected_code); + } + let (status, observed) = fx + .post("/v1/appointments/receipt", &same_owner, key, body) + .await; + assert_eq!(status, StatusCode::OK, "{observed}"); + assert_eq!(observed, original); + assert_eq!(claims_and_receipts(&fx).await, (1, 1)); +} + /// Rewrite the retained current policy document so its identity sits under a /// top-level `scheduling` member, which the policy model does not define, /// leaving the required `project` block absent. diff --git a/crates/registry-stack-client-node/__test__/facade.test-d.ts b/crates/registry-stack-client-node/__test__/facade.test-d.ts index e8c0ebb28d..3a2101ff63 100644 --- a/crates/registry-stack-client-node/__test__/facade.test-d.ts +++ b/crates/registry-stack-client-node/__test__/facade.test-d.ts @@ -109,3 +109,12 @@ void schedulingClient.createAppointment('header.payload.signature', 'book-2', { prerequisites: [], }, }) + +void messagingClient.messageReceipt('header.payload.signature', 'original-notice', { + senderProfile: 'reminders-sms', to: { phone: '+15550100' }, content: { text: 'Appointment tomorrow' }, +}) +void schedulingClient.appointmentReceipt('header.payload.signature', 'original-booking', { hold: 'hold-1' }) +const unresolvedMessage: messaging.MessagingProblemCode = 'receipt.unresolved' +const unresolvedAppointment: scheduling.SchedulingProblemCode = 'receipt.unresolved' +void unresolvedMessage +void unresolvedAppointment diff --git a/crates/registry-stack-client-node/messaging/client.d.ts b/crates/registry-stack-client-node/messaging/client.d.ts index c897958bad..de9aff0994 100644 --- a/crates/registry-stack-client-node/messaging/client.d.ts +++ b/crates/registry-stack-client-node/messaging/client.d.ts @@ -109,6 +109,7 @@ export type MessagingProblemCode = | 'profile.not-authorized' | 'quota.exceeded' | 'rate-limit.exceeded' + | 'receipt.unresolved' | 'request.body-too-large' | 'request.invalid' | 'request.method-not-allowed' @@ -156,6 +157,8 @@ export class MessagingClient { health(): Promise> ready(): Promise> submit(token: string, idempotencyKey: string, request: SubmitMessageRequest): Promise> + /** Observe the original success using the same body/key and current token; never resend or mutate. */ + messageReceipt(token: string, idempotencyKey: string, request: SubmitMessageRequest): Promise> message(token: string, messageId: MessageId): Promise> cancel(token: string, messageId: MessageId): Promise> preview(token: string, templateId: string, version: string, request: TemplatePreviewRequest): Promise> diff --git a/crates/registry-stack-client-node/messaging/client.js b/crates/registry-stack-client-node/messaging/client.js index ab0df16560..8a34dbada5 100644 --- a/crates/registry-stack-client-node/messaging/client.js +++ b/crates/registry-stack-client-node/messaging/client.js @@ -113,6 +113,7 @@ for (const [method, jsonIndexes] of [ ['health', []], ['ready', []], ['submit', [2]], + ['messageReceipt', [2]], ['message', []], ['cancel', []], ['preview', [3]], diff --git a/crates/registry-stack-client-node/messaging/index.d.ts b/crates/registry-stack-client-node/messaging/index.d.ts index a2618ccecb..afb8fc0849 100644 --- a/crates/registry-stack-client-node/messaging/index.d.ts +++ b/crates/registry-stack-client-node/messaging/index.d.ts @@ -6,6 +6,7 @@ export declare class MessagingClient { health(): Promise ready(): Promise submit(token: string, idempotencyKey: string, request: any): Promise + messageReceipt(token: string, idempotencyKey: string, request: any): Promise message(token: string, messageId: string): Promise cancel(token: string, messageId: string): Promise preview(token: string, templateId: string, version: string, request: any): Promise diff --git a/crates/registry-stack-client-node/scheduling/client.d.ts b/crates/registry-stack-client-node/scheduling/client.d.ts index 719c28c0b1..43557bf0ec 100644 --- a/crates/registry-stack-client-node/scheduling/client.d.ts +++ b/crates/registry-stack-client-node/scheduling/client.d.ts @@ -170,6 +170,7 @@ export type SchedulingProblemCode = | 'precondition.required' | 'prerequisite.missing' | 'profile.not-authorized' + | 'receipt.unresolved' | 'request.body-too-large' | 'request.invalid' | 'request.method-not-allowed' @@ -216,6 +217,8 @@ export class SchedulingClient { createHold(token: string, idempotencyKey: string, request: AdmissionRequest): Promise> releaseHold(token: string, holdId: string): Promise> createAppointment(token: string, idempotencyKey: string, request: CreateAppointmentRequest): Promise> + /** Observe the original success using the same body/key and current token; never resend or mutate. */ + appointmentReceipt(token: string, idempotencyKey: string, request: CreateAppointmentRequest): Promise> getAppointment(token: string, appointmentId: string): Promise> listAppointments(token: string, reference: ExternalReference, page?: PageQuery | null): Promise>> rescheduleAppointment(token: string, appointmentId: string, idempotencyKey: string, request: RescheduleAppointmentRequest): Promise> diff --git a/crates/registry-stack-client-node/scheduling/client.js b/crates/registry-stack-client-node/scheduling/client.js index 41c3150f61..ad19f0f50b 100644 --- a/crates/registry-stack-client-node/scheduling/client.js +++ b/crates/registry-stack-client-node/scheduling/client.js @@ -118,6 +118,7 @@ for (const [method, jsonIndexes] of [ ['createHold', [2]], ['releaseHold', []], ['createAppointment', [2]], + ['appointmentReceipt', [2]], ['getAppointment', []], ['listAppointments', [1, 2]], ['rescheduleAppointment', [3]], diff --git a/crates/registry-stack-client-node/scheduling/index.d.ts b/crates/registry-stack-client-node/scheduling/index.d.ts index 9331195f62..315ddb7726 100644 --- a/crates/registry-stack-client-node/scheduling/index.d.ts +++ b/crates/registry-stack-client-node/scheduling/index.d.ts @@ -11,6 +11,7 @@ export declare class SchedulingClient { createHold(token: string, idempotencyKey: string, request: any): Promise releaseHold(token: string, holdId: string): Promise createAppointment(token: string, idempotencyKey: string, request: any): Promise + appointmentReceipt(token: string, idempotencyKey: string, request: any): Promise getAppointment(token: string, appointmentId: string): Promise listAppointments(token: string, reference: any, page?: any | undefined | null): Promise rescheduleAppointment(token: string, appointmentId: string, idempotencyKey: string, request: any): Promise diff --git a/crates/registry-stack-client/src/lib.rs b/crates/registry-stack-client/src/lib.rs index 57ba071c27..ee87964087 100644 --- a/crates/registry-stack-client/src/lib.rs +++ b/crates/registry-stack-client/src/lib.rs @@ -79,7 +79,7 @@ pub mod messaging { SubmitMessageRequest, TemplatePreview, TemplatePreviewRequest, TemplateReference, TransportKind, HEALTH_PATH, IDEMPOTENCY_KEY_HEADER, MAXIMUM_IDEMPOTENCY_KEY_BYTES, MAXIMUM_RETRY_AFTER_SECONDS, MESSAGES_PATH, MESSAGE_CANCEL_PATH, MESSAGE_PATH, - MESSAGING_PROBLEM_TYPE_BASE, READY_PATH, TEMPLATE_PREVIEW_PATH, + MESSAGE_RECEIPT_PATH, MESSAGING_PROBLEM_TYPE_BASE, READY_PATH, TEMPLATE_PREVIEW_PATH, }; } @@ -93,10 +93,10 @@ pub mod scheduling { SchedulingClient, SchedulingClientConfig, SchedulingClientError, SchedulingComplete, SchedulingModeDocument, SchedulingProtocolFailure, SchedulingServiceDocument, ServiceDocument, TransportKind, WindowDocument, APPOINTMENTS_PATH, - AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, HOLDS_PATH, - IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, - MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, - SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, + APPOINTMENT_RECEIPT_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, + CURSOR_QUERY_PARAMETER, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, + LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, + SCHEDULING_PATH, SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, }; } diff --git a/crates/registry-stack-client/tests/messaging_facade.rs b/crates/registry-stack-client/tests/messaging_facade.rs index 036d7fb342..f316133faa 100644 --- a/crates/registry-stack-client/tests/messaging_facade.rs +++ b/crates/registry-stack-client/tests/messaging_facade.rs @@ -39,6 +39,9 @@ async fn every_messaging_method_names_its_types( let _: MessagingComplete<()> = client.ready().await?; let _: MessagingComplete = client.submit(token, idempotency_key, submission).await?; + let _: MessagingComplete = client + .message_receipt(token, idempotency_key, submission) + .await?; let _: MessagingComplete = client.message(token, message_id).await?; let _: MessagingComplete = client.cancel(token, message_id).await?; let _: MessagingComplete = client diff --git a/crates/registry-stack-client/tests/scheduling_facade.rs b/crates/registry-stack-client/tests/scheduling_facade.rs index a5551ab354..c249095c40 100644 --- a/crates/registry-stack-client/tests/scheduling_facade.rs +++ b/crates/registry-stack-client/tests/scheduling_facade.rs @@ -68,6 +68,9 @@ async fn every_scheduling_method_names_its_types( let _: SchedulingComplete = client .create_appointment(auth(), idempotency_key, requests.create) .await?; + let _: SchedulingComplete = client + .appointment_receipt(auth(), idempotency_key, requests.create) + .await?; let _: SchedulingComplete = client.get_appointment(auth(), identifier).await?; let _: SchedulingComplete> = client diff --git a/docs/site/src/content/docs/reference/apis/registry-messaging.mdx b/docs/site/src/content/docs/reference/apis/registry-messaging.mdx index f1fc1c2a3d..cf5461425a 100644 --- a/docs/site/src/content/docs/reference/apis/registry-messaging.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-messaging.mdx @@ -37,21 +37,23 @@ callback verifier authenticates them instead. products/messaging/RUNTIME-CONFIG.md, Keys. */} `POST /v1/messages` requires an `Idempotency-Key` header of 1 to 128 visible ASCII characters, -scoped to the calling principal, named by the token's issuer and subject. The same key with the +scoped to the verified issuer and principal. The access profile's `principalClaim` selects that +principal from the verified token; with `principalClaim: sub`, it is the token subject. The same key with the same request answers the stored receipt again, and the same key with a different request is refused with `idempotency.key-reused`, whatever the key's age. The same request under a key whose stored receipt is older than the deployment's `retention.submissionReceiptRetentionDays` is refused with `idempotency.expired`, because the first answer is no longer held. Retention then clears the raw -issuer, subject, and key from the row and keeps only a SHA-256 digest of them beside the request +issuer, principal, and key from the row and keeps only a SHA-256 digest of them beside the request hash, so the key stays spent for that caller alone; another caller's identical key is a fresh submission. A key whose message has since been deleted by `retention.recordRetentionDays` answers `idempotency.expired` with the same or a different request: the digest row stays with its message, request hash, and receipt nulled, so a repeat is refused rather than accepted as a new message to the same recipient. A rotation of the deployment's audit reference key frees no spent key. Choose a random key, such as a UUID: the digest takes no secret, so anyone who reads the database and can -guess your issuer, subject, and key can confirm that you used it. +guess your issuer, principal, and key can confirm that you used it. {/* Evidence: crates/registry-messaging-core/src/wire.rs, idempotency key bounds; + crates/registry-messaging-core/src/access.rs, resolve(); crates/registry-messaging/src/messages.rs, SUBMIT_OPERATION, idempotency_key_reference(), lookup_key(), and replay(); crates/registry-messaging/src/retention.rs, expire_submission_receipts() and delete_records(); @@ -68,8 +70,8 @@ up, so a key never outlives the caller's authority. An exact retry can therefore example with `403 profile.not-authorized` or `422 template.locale-unavailable` after the deployment activated a package that narrows what the caller may send, while the first attempt stays accepted and may already be sending. A `403` or `422` answer to an exact retry does not show that the first -attempt failed; do not resubmit under a new key on that answer alone. To recover the first -attempt's receipt, have the operator restore the caller's access with an active package that again +attempt failed; do not resubmit under a new key on that answer alone. To retry the original +submission through the submission route, have the operator restore the caller's access with an active package that again lists the sender profile, template, and locale and still carries the template version the first attempt named, then retry under the same key within `retention.submissionReceiptRetentionDays`. A package that retired that version answers the retry with `404 template.not-found`, and the retry must still @@ -83,6 +85,24 @@ message `id`. an_exact_retry_the_caller_may_no_longer_send_is_refused_while_the_first_attempt_stands; products/messaging/contracts/recorded-decisions.yaml, MESSAGING-DEC-07. */} +`POST /v1/messages/receipt` observes the original submission with a current authenticated +bearer token, the original `Idempotency-Key`, and the original submission body. It returns a +retained successful receipt only for the exact verified issuer and configured principal that submitted the +message. The lookup does not render the template, submit a message, or dispatch provider work, +so it can observe acceptance after the sender's submission authority or template changes. +It requires current authentication but neither spends nor requires the caller's submission rate +budget, so it never answers `429`. A missing, expired, or +mismatched receipt answers `409 receipt.unresolved`; that answer does not prove that no message +was accepted. Another principal, including an operator reading someone else's message status, +cannot use this route to recover the original principal's receipt. + +{/* Evidence: crates/registry-messaging/src/http.rs, message_receipt(); + crates/registry-messaging/src/messages.rs, message_receipt(); + crates/registry-messaging-core/src/problem.rs, ReceiptUnresolved; + crates/registry-messaging/tests/postgres_messages.rs, + original_receipt_reads_neither_spend_nor_require_submission_budget; + products/messaging/contracts/recorded-decisions.yaml, MESSAGING-DEC-20. */} + A refusal answers `application/problem+json` with `type`, `title`, `status`, `detail`, `code`, and `traceId`. The `type` is the code with each dot replaced by a slash under `https://id.registrystack.org/problems/registry-messaging/`, and `detail` is the fixed sentence that @@ -109,7 +129,7 @@ elsewhere does not make a caller allowed to send. {/* Evidence: crates/registry-messaging-core/src/access.rs, AccessRole, resolve(), and authorize_submission(); crates/registry-messaging-core/src/visibility.rs. */} -A message is visible to the principal that submitted it, named by the token's issuer and subject, +A message is visible to the principal that submitted it, identified by the verified issuer and configured principal, and to every operator. Any other caller reading or cancelling it receives `404 message.not-visible`, the same answer a message that does not exist receives. @@ -119,7 +139,7 @@ message.not-visible`, the same answer a message that does not exist receives. ## Limits Each access profile limits its callers' submissions. `requestsPerMinute` and `burst` set a request -rate kept per caller, by token issuer and subject, and `maximumMessagesPerDay` caps the messages the whole +rate kept per caller, by verified issuer and configured principal, and `maximumMessagesPerDay` caps the messages the whole profile accepts in any 24 hours. A submission past either one is refused with `429` and a `Retry-After` header in seconds: @@ -254,6 +274,7 @@ HTTP status listed beside it. `request.not-found` answers a path the contract do | `GET /health` | None | `200` while the process serves | | `GET /ready` | None | `200` when the database carries every expected migration and its package ledger names the package this process serves active, `503 service.unavailable` otherwise | | `POST /v1/messages` | Bearer, sender | Submit one message | +| `POST /v1/messages/receipt` | Bearer, the exact original submitting principal | Observe a retained success receipt using the original key and request | | `GET /v1/messages/{message_id}` | Bearer, the submitting principal or an operator | Read one message's status and attempts | | `POST /v1/messages/{message_id}/cancel` | Bearer, the submitting principal or an operator | Cancel a queued message | | `POST /v1/templates/{template_id}/versions/{version}/preview` | Bearer, sender | Render a listed template version with given data, persisting nothing | diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 1af5d5e602..f61ca10fc3 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -157,6 +157,24 @@ and names no member. {/* Evidence: crates/registry-scheduling-core/src/admission.rs, detailed_code(); products/scheduling/generated/registry-scheduling.openapi.json. */} +`POST /v1/appointments/receipt` uses ordinary read authority, the original `Idempotency-Key`, +and the original `CreateAppointmentRequest` body to observe a retained successful creation +receipt. Its verified issuer and subject must exactly match the original caller. It returns +`200` with the original appointment projection and never creates or replays a capacity command. +A confirmation from a hold is read back with the same `{"hold": "", "admission": null}` +body, so both the hold and the key that confirmed it must match. Observation can therefore +continue after the original task grant ends, using a current token with read authority. +Missing, expired, changed, or refused receipts answer `409 receipt.unresolved`. This refusal +leaves the original outcome unknown and is not evidence that no appointment was created. + +{/* Evidence: crates/registry-scheduling/src/http.rs, appointment_receipt(); + crates/registry-scheduling/src/service.rs, appointment_receipt(); + crates/registry-scheduling/src/store.rs, appointment_receipt(); + crates/registry-scheduling-core/src/problem.rs, ReceiptUnresolved; + crates/registry-scheduling/tests/postgres_commitments.rs, + original_hold_confirmation_receipt_is_read_by_its_hold_and_key and + a_refused_attempt_never_resolves_an_original_appointment_receipt. */} + ## Task grants A commitment carries no product scope. Its authority is a task grant inside the access token: the six @@ -322,6 +340,7 @@ checked per route. | Availability | `/v1/availability`, `/v1/availability/explain` | Catalogue read; Explain | | Holds | `/v1/holds`, `/v1/holds/{holdId}` | Commitment | | Appointments | `/v1/appointments`, `/v1/appointments/{appointmentId}`, its `reschedule`, `cancel`, and `history` | Commitment for the mutations; Catalogue read for the reads | +| Appointment receipt observation | `/v1/appointments/receipt` | Catalogue read, matching the exact original owner, key, and creation request | | Liveness and readiness | `/healthz`, `/readyz` | Any caller, without a token | `/readyz` answers `service.unavailable` while PostgreSQL is unreachable or the audit writer is not @@ -330,7 +349,7 @@ ready. {/* Evidence: crates/registry-scheduling/src/http.rs, router() and readyz(); crates/registry-scheduling-core/src/naming.rs, SCHEDULING_PATH, SERVICES_PATH, OFFERINGS_PATH, RESOURCES_PATH, LOCATIONS_PATH, AVAILABILITY_PATH, AVAILABILITY_EXPLAIN_PATH, HOLDS_PATH, and - APPOINTMENTS_PATH. */} + APPOINTMENTS_PATH and APPOINTMENT_RECEIPT_PATH. */} ## Source of truth diff --git a/docs/site/src/data/messaging-api.yaml b/docs/site/src/data/messaging-api.yaml index 9d402f7dfa..3792691c60 100644 --- a/docs/site/src/data/messaging-api.yaml +++ b/docs/site/src/data/messaging-api.yaml @@ -17,6 +17,7 @@ - ['`profile.not-authorized`', '403', 'No Messaging access profile authorizes this caller for this request.'] - ['`quota.exceeded`', '429', 'Your Messaging access profile has accepted its daily limit of messages. Try again after the time in Retry-After.'] - ['`rate-limit.exceeded`', '429', 'The request rate accepted from this caller is exceeded. Try again after the time in Retry-After.'] + - ['`receipt.unresolved`', '409', 'No retained success receipt matches the verified caller, original key, and original request. Missing, expired, or changed receipts leave the original outcome unknown; this does not prove that no effect occurred.'] - ['`request.body-too-large`', '413', 'The request body exceeds the accepted size.'] - ['`request.invalid`', '400', 'The request could not be read as a Messaging request.'] - ['`request.method-not-allowed`', '405', 'The route exists but not for this method.'] diff --git a/docs/site/src/data/scheduling-api.yaml b/docs/site/src/data/scheduling-api.yaml index 515b79bf60..6dc0a7b102 100644 --- a/docs/site/src/data/scheduling-api.yaml +++ b/docs/site/src/data/scheduling-api.yaml @@ -24,6 +24,7 @@ - ['`precondition.required`', '428', 'The mutation requires the revision the caller loaded, or the duplicate key the offering keys on.'] - ['`prerequisite.missing`', '422', 'The party is missing a prerequisite the offering requires.'] - ['`profile.not-authorized`', '403', 'The caller holds no profile that authorizes this request, for example a read scope check on a mutation route.'] + - ['`receipt.unresolved`', '409', 'No retained success receipt matches the verified caller, original key, and original request. Missing, expired, or changed receipts leave the original outcome unknown; this does not prove that no effect occurred.'] - ['`request.body-too-large`', '413', 'The request body exceeds the accepted size.'] - ['`request.invalid`', '400', 'The request could not be read as a Scheduling request.'] - ['`request.method-not-allowed`', '405', 'The route exists but not for this method.'] diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index bce1656d06..8fdee1f3f9 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -1753,7 +1753,7 @@ "description": "The bearer credential is missing, invalid, or expired. Sign in again.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "authentication.refused", @@ -1772,7 +1772,7 @@ "description": "The callback was verified, but the provider's receipt script could not read a delivery report from it.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "callback.unreadable", @@ -1791,7 +1791,7 @@ "description": "The callback did not verify for a provider this deployment accepts callbacks from.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "callback.unverified", @@ -1810,7 +1810,7 @@ "description": "The content does not have the parts the channel requires, or the template's channel differs from the sender profile's.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "content.invalid", @@ -1829,7 +1829,7 @@ "description": "A content part exceeds the size the channel accepts. Shorten the content or the data.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "content.too-large", @@ -1848,7 +1848,7 @@ "description": "The SMS text needs more segments than the sender profile allows. Shorten the text or the data.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "content.too-many-segments", @@ -1867,7 +1867,7 @@ "description": "The stored response for this idempotency key has expired. Reconcile the original submission before choosing a new key.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "idempotency.expired", @@ -1886,7 +1886,7 @@ "description": "This idempotency key was used for a different request.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "idempotency.key-reused", @@ -1905,7 +1905,7 @@ "description": "The message is being sent or has been handed to the provider, so it can no longer be cancelled.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "message.dispatch-started", @@ -1924,7 +1924,7 @@ "description": "No message with this identifier is visible to the caller.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "message.not-visible", @@ -1943,7 +1943,7 @@ "description": "The message has reached a final state, so it can no longer be cancelled.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "message.terminal", @@ -1962,7 +1962,7 @@ "description": "Your Messaging access profile does not allow this operation.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "operation.not-authorized", @@ -1981,7 +1981,7 @@ "description": "No Messaging access profile authorizes this caller for this request.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "profile.not-authorized", @@ -2000,7 +2000,7 @@ "description": "Your Messaging access profile has accepted its daily limit of messages. Try again after the time in Retry-After.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "quota.exceeded", @@ -2019,7 +2019,7 @@ "description": "The request rate accepted from this caller is exceeded. Try again after the time in Retry-After.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "rate-limit.exceeded", @@ -2028,6 +2028,25 @@ ] } }, + { + "uri": "https://id.registrystack.org/problems/registry-messaging/receipt/unresolved", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "messaging", + "title": "Original receipt unresolved", + "description": "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred.", + "source": { + "path": "crates/registry-messaging-core/src/problem.rs", + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" + }, + "problem": { + "code": "receipt.unresolved", + "httpStatuses": [ + 409 + ] + } + }, { "uri": "https://id.registrystack.org/problems/registry-messaging/request/body-too-large", "kind": "problem", @@ -2038,7 +2057,7 @@ "description": "The request body exceeds the accepted size.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "request.body-too-large", @@ -2057,7 +2076,7 @@ "description": "The request could not be read as a Messaging request.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "request.invalid", @@ -2076,7 +2095,7 @@ "description": "The route exists but not for this method.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "request.method-not-allowed", @@ -2095,7 +2114,7 @@ "description": "The requested route does not exist.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "request.not-found", @@ -2114,7 +2133,7 @@ "description": "The request body could not be processed.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "request.unprocessable", @@ -2133,7 +2152,7 @@ "description": "The request body is not JSON.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "request.unsupported-media-type", @@ -2152,7 +2171,7 @@ "description": "Messaging is unavailable. Try again after the service recovers.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "service.unavailable", @@ -2171,7 +2190,7 @@ "description": "The data does not match the template's data schema.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "template.data-invalid", @@ -2190,7 +2209,7 @@ "description": "The template version has no content in the requested locale. Choose one of its declared locales.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "template.locale-unavailable", @@ -2209,7 +2228,7 @@ "description": "No template with this identifier and version is available to the caller.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "template.not-found", @@ -2228,7 +2247,7 @@ "description": "The template could not be rendered with this data within the rendering limits.", "source": { "path": "crates/registry-messaging-core/src/problem.rs", - "sha256": "debf21ffbfc35916067d15f4cee489e502876297a74924035fe96f38475cb98c" + "sha256": "b2c08b13660c7bc99e9865575be322dae315474f708180031d14702ad62acdb7" }, "problem": { "code": "template.render-refused", @@ -2247,7 +2266,7 @@ "description": "The bearer credential is missing, invalid, or expired. Sign in again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "authentication.refused", @@ -2266,7 +2285,7 @@ "description": "An active booking already holds this party's duplicate key. Cancel it, or wait until it has passed, before booking again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "booking.duplicate-active", @@ -2285,7 +2304,7 @@ "description": "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "cancellation.cutoff-passed", @@ -2304,7 +2323,7 @@ "description": "The party or backing supply does not carry every capability this offering requires.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "capability.unmatched", @@ -2323,7 +2342,7 @@ "description": "The supply is fully committed for the requested interval. Choose another time.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "capacity.exhausted", @@ -2342,7 +2361,7 @@ "description": "This cursor has expired. Start again without a cursor and deduplicate entries by id.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "cursor.expired", @@ -2361,7 +2380,7 @@ "description": "The cursor is invalid for this request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "cursor.invalid", @@ -2380,7 +2399,7 @@ "description": "A required eligibility check could not run. Retry; do not treat this as permission.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "eligibility.unavailable", @@ -2399,7 +2418,7 @@ "description": "The hold expired before confirmation. Its capacity is bookable again; start a new request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "hold.expired", @@ -2418,7 +2437,7 @@ "description": "The claim is not an active hold. It may already be confirmed or released.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "hold.released", @@ -2437,7 +2456,7 @@ "description": "A required lifecycle hook could not run, so the request was refused rather than half-applied.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "hook.unavailable", @@ -2456,7 +2475,7 @@ "description": "The requested start is earlier than the lead time allows or further ahead than the horizon allows.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "horizon.outside", @@ -2475,7 +2494,7 @@ "description": "The stored response for this idempotency key has expired. For an appointment command, read the appointment by its external reference or its identifier to learn the original outcome before choosing a new key. A hold cannot be read and expires on its own, so start a new request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "idempotency.expired", @@ -2494,7 +2513,7 @@ "description": "This idempotency key was used for a different request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "idempotency.key-reused", @@ -2513,7 +2532,7 @@ "description": "A closure covers the requested start. Choose a start outside the closure.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "location.closed", @@ -2532,7 +2551,7 @@ "description": "Your current Scheduling authority does not allow this operation.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "operation.not-authorized", @@ -2551,7 +2570,7 @@ "description": "The party is larger than this offering can ever serve, or its size falls outside the published units.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "party.capacity-inadequate", @@ -2570,7 +2589,7 @@ "description": "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "policy.changed", @@ -2589,7 +2608,7 @@ "description": "The appointment changed since you loaded it. Reload and try again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "precondition.failed", @@ -2608,7 +2627,7 @@ "description": "This mutation requires the revision you loaded, or the duplicate key this offering keys on.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "precondition.required", @@ -2627,7 +2646,7 @@ "description": "The party is missing a prerequisite this offering requires.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "prerequisite.missing", @@ -2646,7 +2665,7 @@ "description": "The selected Scheduling profile does not authorize this request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "profile.not-authorized", @@ -2655,6 +2674,25 @@ ] } }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/receipt/unresolved", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Original receipt unresolved", + "description": "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" + }, + "problem": { + "code": "receipt.unresolved", + "httpStatuses": [ + 409 + ] + } + }, { "uri": "https://id.registrystack.org/problems/registry-scheduling/request/body-too-large", "kind": "problem", @@ -2665,7 +2703,7 @@ "description": "The request body exceeds the accepted size.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "request.body-too-large", @@ -2684,7 +2722,7 @@ "description": "The request could not be read as a Scheduling request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "request.invalid", @@ -2703,7 +2741,7 @@ "description": "The route exists but not for this method.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "request.method-not-allowed", @@ -2722,7 +2760,7 @@ "description": "The requested route does not exist.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "request.not-found", @@ -2741,7 +2779,7 @@ "description": "The request body could not be processed.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "request.unprocessable", @@ -2760,7 +2798,7 @@ "description": "The request body is not JSON.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "request.unsupported-media-type", @@ -2779,7 +2817,7 @@ "description": "Every capable member is unavailable for this interval.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "resource.unavailable", @@ -2798,7 +2836,7 @@ "description": "The window revision changed before the request committed. Reload the catalogue and try again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "revision.mismatch", @@ -2817,7 +2855,7 @@ "description": "No published schedule serves that start. Choose a start on the published grid.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "schedule.unpublished", @@ -2836,7 +2874,7 @@ "description": "Scheduling storage is unavailable. Try again after the service recovers.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "5ce5a4507c1e778ece0e58a44f59976ba543df23a12d484e9cd381b50cf97d68" + "sha256": "12f4afc02a4396f0111b84b58cfa2a28d7112e14e7e9b80ea1809b4a03905e54" }, "problem": { "code": "service.unavailable", @@ -4205,7 +4243,7 @@ "description": "Registry Messaging project, template, and HTTP provider authoring JSON Schema.", "source": { "path": "crates/registry-messaging/src/schema.rs", - "sha256": "e001b849eb52722e19b94b39a3e18191202776ac0762cdcdeb902fcaf2844ec8" + "sha256": "566657f59c1c8bb5059c0e0e2faa499f559a6fe905d1d8b1aba2eff781f62a09" }, "artifact": { "path": "products/messaging/generated/authoring/project.schema.json", @@ -4223,7 +4261,7 @@ "description": "Registry Messaging project, template, and HTTP provider authoring JSON Schema.", "source": { "path": "crates/registry-messaging/src/schema.rs", - "sha256": "e001b849eb52722e19b94b39a3e18191202776ac0762cdcdeb902fcaf2844ec8" + "sha256": "566657f59c1c8bb5059c0e0e2faa499f559a6fe905d1d8b1aba2eff781f62a09" }, "artifact": { "path": "products/messaging/generated/authoring/provider.schema.json", @@ -4241,7 +4279,7 @@ "description": "Registry Messaging runtime configuration JSON Schema.", "source": { "path": "crates/registry-messaging/src/schema.rs", - "sha256": "e001b849eb52722e19b94b39a3e18191202776ac0762cdcdeb902fcaf2844ec8" + "sha256": "566657f59c1c8bb5059c0e0e2faa499f559a6fe905d1d8b1aba2eff781f62a09" }, "artifact": { "path": "products/messaging/generated/runtime/runtime.schema.json", @@ -4259,7 +4297,7 @@ "description": "Registry Messaging project, template, and HTTP provider authoring JSON Schema.", "source": { "path": "crates/registry-messaging/src/schema.rs", - "sha256": "e001b849eb52722e19b94b39a3e18191202776ac0762cdcdeb902fcaf2844ec8" + "sha256": "566657f59c1c8bb5059c0e0e2faa499f559a6fe905d1d8b1aba2eff781f62a09" }, "artifact": { "path": "products/messaging/generated/authoring/template.schema.json", @@ -4371,7 +4409,7 @@ }, "artifact": { "path": "products/scheduling/generated/fixture/fixture.schema.json", - "sha256": "968ec77a589957cfcd70eb6d4c2f906bf1285308f295c80af295673f51c36572", + "sha256": "ea3e5db017d1c733bbbc9d5aed6ad5f5e760649379d6ba1a993c48e17e02e3a2", "mediaType": "application/schema+json" } }, diff --git a/products/messaging/README.md b/products/messaging/README.md index ae9581449e..9e4c553ff6 100644 --- a/products/messaging/README.md +++ b/products/messaging/README.md @@ -215,6 +215,7 @@ never hand-edited. |---|---|---| | `GET /health` | none | `200` with an empty body while the process serves | | `GET /ready` | none | `200` when the database carries every expected migration, its activation ledger names the configured database identity and served package active under the current runtime role, and its audit writer is healthy, `503 service.unavailable` otherwise | +| `POST /v1/messages/receipt` | bearer, current caller profile, original `Idempotency-Key` and request | `200` original success receipt for its owner; `409 receipt.unresolved` when retained evidence does not match; no send or mutation | | `POST /v1/messages` | bearer, an access profile listing the sender profile and template, and an `Idempotency-Key` header | `202` with the message receipt; the same key and request answer the stored receipt again; `429 rate-limit.exceeded` past the caller's rate and `429 quota.exceeded` past the profile's daily limit, both with `Retry-After` | | `GET /v1/messages/{message_id}` | bearer, the submitting principal (the same issuer and subject) or an operator | `200` with the status derived from the dispatch state and the delivery report, both of those, the recipient's channel with the value `redacted`, and the attempts; `404 message.not-visible` for any other message | | `POST /v1/messages/{message_id}/cancel` | bearer, the submitting principal (the same issuer and subject) or an operator | `200` with the cancelled status; `409 message.dispatch-started` once dispatch started, `409 message.terminal` once it is final | @@ -335,3 +336,15 @@ cargo run -p registry-messaging --features schema --example runtime-schema -- \ cargo run -p registry-messaging --features schema --example openapi -- \ --output products/messaging/generated ``` + +## Observe an original receipt + +`POST /v1/messages/receipt` accepts the original request body and `Idempotency-Key` +under current ordinary read authority. It returns `200` with the retained +original success receipt for the same verified issuer and configured principal. It never +repeats the business operation. A same-principal read credential can observe +work after task authority ends; another service identity cannot impersonate +that principal. `409 receipt.unresolved` covers unavailable evidence such as +a missing, expired, or changed receipt and does not prove that no effect occurred. + +When a caller must be bound to its issuer and subject, configure the Messaging caller profile with `principalClaim: sub`. Other profiles retain the product’s existing configured principal ownership rule, and cannot substitute a different principal to observe an original receipt. diff --git a/products/messaging/SECURITY-REVIEW-NOTES.md b/products/messaging/SECURITY-REVIEW-NOTES.md index 7193bb1ae2..9c6f1d8dd7 100644 --- a/products/messaging/SECURITY-REVIEW-NOTES.md +++ b/products/messaging/SECURITY-REVIEW-NOTES.md @@ -1019,3 +1019,40 @@ field. This regression is excluded from `postgres-test` builds so a permissive test binary cannot satisfy the shipped-feature proof. The runtime guard and release build feature selection are unchanged; the existing default-feature test suite owns this check. + +## Original receipt observation + +Threat: recovery submits a second effect after cancellation, expiry, or restore, +or accepts a different caller's receipt as evidence of its original command. +The receipt lookup uses current ordinary read authority, the original verified +issuer and configured principal, the exact operation key, and the existing canonical request +hash. It reads only retained successful receipts and never renders, writes business state, +replays, or creates an attempt. Task lineage already stored with the original +operation is preserved. A different standing identity gains no ownership. + +Missing, changed, erased, expired, and non-success receipts all answer the +value-free `409 receipt.unresolved`. That response establishes no absence of +effect. A durable audit request precedes the database lookup and its response +precedes disclosure of the receipt. Payloads, keys, and raw identities never +enter these audit records. The focused PostgreSQL receipt tests exercise +ownership, exact-request refusal, retention, and unchanged product state. + +Receipt observation uses no submission rate budget because it neither renders +nor sends. Exhausting that budget must not prevent recovery of an already +accepted command. Authentication, bounded bodies, exact caller/request matching, +retention, and audit gates still apply. The PostgreSQL test +`original_receipt_reads_neither_spend_nor_require_submission_budget` reads the +same stored receipt before and after the budget is exhausted and proves that +the next submission is still rate limited. The operation's published contract +lists no `429`, which `the_receipt_read_publishes_no_rate_limit_answer` in +`schema.rs` holds. + +Residual risk: receipt observation is not throttled. An authenticated caller +that repeats it makes the runtime authenticate each request, perform one +bounded indexed lookup, and write its audit records, the same kind of journal +growth a refused submission flood already causes (see Limits). This is +accepted rather than given a separate read budget: an operator relies on the +deployment's edge proxy to bound an authenticated flood on this route as on +every other. + +When a caller must be bound to its issuer and subject, configure the Messaging caller profile with `principalClaim: sub`. Other profiles retain the product’s existing configured principal ownership rule, and cannot substitute a different principal to observe an original receipt. diff --git a/products/messaging/contracts/recorded-decisions.yaml b/products/messaging/contracts/recorded-decisions.yaml index 8387ec18a4..d0fb31e8fc 100644 --- a/products/messaging/contracts/recorded-decisions.yaml +++ b/products/messaging/contracts/recorded-decisions.yaml @@ -403,15 +403,26 @@ decisions: the runtime process. A submission is charged after the role check and before the body is read, a replay included; past the burst it is refused with 429 rate-limit.exceeded and Retry-After, and a limiter - that cannot decide answers 503 service.unavailable. + that cannot decide answers 503 service.unavailable. Original receipt + observation does not consume or require this submission budget, is + not throttled, and publishes no 429. reasoning: >- One replica is the supported topology, and the platform token bucket is the primitive Evidence already uses. Charging before the body keeps rendering behind the budget, and keying by the pseudonym - keeps the raw subject out of the limiter. + keeps the raw subject out of the limiter. Receipt observation neither + renders nor sends, and a caller that lost a submission response still + needs to recover its receipt after its send budget is exhausted. + A separate read budget would add a setting for little gain: an + authenticated caller repeating the lookup costs one bounded indexed + read and its audit records per call, the same kind of journal growth a + refused submission flood already causes, and the deployment's edge + proxy bounds both. evidence: - {path: crates/registry-messaging/src/http.rs, name: a_caller_past_its_profile_rate_is_refused_with_the_wait_needed} - {path: crates/registry-messaging/src/limits.rs, name: callers_and_profiles_have_separate_budgets} + - {path: crates/registry-messaging/tests/postgres_messages.rs, name: original_receipt_reads_neither_spend_nor_require_submission_budget} + - {path: crates/registry-messaging/src/schema.rs, name: the_receipt_read_publishes_no_rate_limit_answer, features: [schema]} - id: MESSAGING-DEC-21 subject: A profile's daily limit is counted from its accepted messages decision: >- diff --git a/products/messaging/contracts/security-invariant-matrix.yaml b/products/messaging/contracts/security-invariant-matrix.yaml index 1bff19eea0..9fe465933e 100644 --- a/products/messaging/contracts/security-invariant-matrix.yaml +++ b/products/messaging/contracts/security-invariant-matrix.yaml @@ -88,7 +88,9 @@ invariants: The core's check_message_visibility decision, applied on the status and cancel routes after authentication against the stored submitter: only the submitter and operator profiles see a message, and a missing message - answers exactly like an invisible one. + answers exactly like an invisible one. Original receipt lookup requires + the exact submitter issuer and subject, original key, and canonical request + hash; a different principal never gains receipt ownership. refusal: >- Answer 404 message.not-visible to every other caller, whether or not the message exists. @@ -124,6 +126,8 @@ invariants: acceptDuplicates. The provider idempotency capability is stored at acceptance and controls the key sent on every later attempt, even after a package upgrade removes that capability. + Original receipt observation never resubmits and remains available + independently of the caller's submission rate budget. refusal: >- Record unknown and never send again without an operator settle. negativeTest: diff --git a/products/messaging/contracts/security-test-traceability.yaml b/products/messaging/contracts/security-test-traceability.yaml index b127f2c9da..ecca3ecad7 100644 --- a/products/messaging/contracts/security-test-traceability.yaml +++ b/products/messaging/contracts/security-test-traceability.yaml @@ -43,6 +43,7 @@ entries: - {path: crates/registry-messaging/src/smtp/tests.rs, name: development_plaintext_refuses_an_answer_that_is_not_loopback} - id: MESSAGING-SEC-04 tests: + - {path: crates/registry-messaging/tests/postgres_messages.rs, name: original_receipt_reads_preserve_ownership_request_expiry_and_product_state} - {path: crates/registry-messaging-core/src/visibility.rs, name: another_caller_is_told_the_message_is_not_visible} - {path: crates/registry-messaging-core/src/visibility.rs, name: a_missing_message_answers_exactly_like_an_invisible_one} - {path: crates/registry-messaging/src/http.rs, name: an_authenticated_caller_is_told_an_unknown_message_is_not_visible} @@ -125,6 +126,7 @@ entries: - {path: crates/registry-messaging-core/src/receipt.rs, name: a_terminal_state_is_never_displaced_by_the_other_terminal_state} - id: MESSAGING-SEC-08 tests: + - {path: crates/registry-messaging/tests/postgres_messages.rs, name: original_receipt_disclosure_requires_both_audit_entries} - {path: crates/registry-platform-activation/tests/postgres_activation.rs, name: postgres_version_floor_precedes_missing_ledger_observation} - {path: crates/registry-messaging/tests/postgres_package.rs, name: apply_refuses_a_newer_schema_before_activation_and_audits_the_refusal} - {path: crates/registry-messaging/tests/postgres_package.rs, name: split_role_activation_binds_identity_and_withholds_both_ledgers} diff --git a/products/messaging/generated/registry-messaging.openapi.json b/products/messaging/generated/registry-messaging.openapi.json index 4a9640f436..26c798e26e 100644 --- a/products/messaging/generated/registry-messaging.openapi.json +++ b/products/messaging/generated/registry-messaging.openapi.json @@ -252,6 +252,7 @@ "profile.not-authorized", "quota.exceeded", "rate-limit.exceeded", + "receipt.unresolved", "request.body-too-large", "request.invalid", "request.method-not-allowed", @@ -295,6 +296,7 @@ "https://id.registrystack.org/problems/registry-messaging/profile/not-authorized", "https://id.registrystack.org/problems/registry-messaging/quota/exceeded", "https://id.registrystack.org/problems/registry-messaging/rate-limit/exceeded", + "https://id.registrystack.org/problems/registry-messaging/receipt/unresolved", "https://id.registrystack.org/problems/registry-messaging/request/body-too-large", "https://id.registrystack.org/problems/registry-messaging/request/invalid", "https://id.registrystack.org/problems/registry-messaging/request/method-not-allowed", @@ -1020,6 +1022,280 @@ "summary": "Submit one message through a sender profile the caller's access profile lists, rendered from a template version at acceptance or, where the profile allows it, from direct content. The `Idempotency-Key` header is required: the same key and request answer the stored receipt again." } }, + "/v1/messages/receipt": { + "post": { + "operationId": "readMessageReceipt", + "parameters": [ + { + "description": "Scopes a retry to the caller's issuer and subject: the same key and request answer the stored receipt again, across an audit key rotation too. A retry is authorized and rendered again before its key is looked up, so a 403 or 422 answer to an exact retry does not mean the first attempt failed.", + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7e]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitMessageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageReceipt" + } + } + }, + "description": "The operation succeeded." + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "request.invalid" + ] + } + } + } + ] + } + } + }, + "description": "A problem: request.invalid." + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "authentication.refused" + ] + } + } + } + ] + } + } + }, + "description": "A problem: authentication.refused.", + "headers": { + "WWW-Authenticate": { + "description": "The bearer challenge.", + "required": true, + "schema": { + "const": "Bearer", + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "profile.not-authorized" + ] + } + } + } + ] + } + } + }, + "description": "A problem: profile.not-authorized." + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "request.method-not-allowed" + ] + } + } + } + ] + } + } + }, + "description": "A problem: request.method-not-allowed." + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "receipt.unresolved" + ] + } + } + } + ] + } + } + }, + "description": "A problem: receipt.unresolved." + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "request.body-too-large" + ] + } + } + } + ] + } + } + }, + "description": "A problem: request.body-too-large." + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "request.unsupported-media-type" + ] + } + } + } + ] + } + } + }, + "description": "A problem: request.unsupported-media-type." + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "request.unprocessable" + ] + } + } + } + ] + } + } + }, + "description": "A problem: request.unprocessable." + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "service.unavailable" + ] + } + } + } + ] + } + } + }, + "description": "A problem: service.unavailable.", + "headers": { + "Retry-After": { + "description": "Whole seconds to wait before trying again.", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Read the retained original acceptance receipt for this caller, key and exact request without submitting or rendering a message. An unresolved receipt does not establish that no effect occurred." + } + }, "/v1/messages/{message_id}": { "get": { "operationId": "getMessage", diff --git a/products/scheduling/README.md b/products/scheduling/README.md index 830614da95..3b62a6b244 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -383,3 +383,17 @@ workspace `AGENTS.md` records what changes when one does. The source-neutral core sits at the bottom of the product depending on no other scheduling crate, and the client shares the core without ever linking the runtime or adopter tooling. + +## Observe an original receipt + +`POST /v1/appointments/receipt` accepts the original request body and `Idempotency-Key` +under current ordinary read authority. It returns `200` with the retained +original success receipt for the same verified issuer and subject. It never +repeats the business operation. A same-principal read credential can observe +work after task authority ends; another service identity cannot impersonate +that principal. `409 receipt.unresolved` covers unavailable evidence such as +a missing, expired, or changed receipt and does not prove that no effect occurred. +The receipt route authenticates before checking the JSON media type and decoding +its bounded body. The router still rejects a declared body larger than one MiB +before entering the handler. Authenticated callers retain the usual malformed +JSON, unsupported media type, request-shape and body-size refusals. diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md index caa090a3b6..8d862f44de 100644 --- a/products/scheduling/SECURITY-REVIEW-NOTES.md +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -1105,3 +1105,27 @@ suggested action is the one for a damaged database. The release notes of the release that made each break, v0.36.0 and v0.40.0, carry the instruction. The unpublished-audit guard (SCHEDULING-SEC-14) and the removed-key refusals remain. + +## Original receipt observation + +Threat: recovery submits a second effect after cancellation, expiry, or restore, +or accepts a different caller's receipt as evidence of its original command. +The receipt lookup uses current ordinary read authority, the original verified +issuer and subject, the exact operation key, and the existing canonical request +hash. It reads only retained successful receipts and never renders, writes business state, +replays, or creates an attempt. Task lineage already stored with the original +operation is preserved. A different standing identity gains no ownership. + +Missing, changed, erased, expired, and non-success receipts all answer the +value-free `409 receipt.unresolved`. That response establishes no absence of +effect. A durable audit request precedes the database lookup and its response +precedes disclosure of the receipt. Payloads, keys, and raw identities never +enter these audit records. The focused PostgreSQL receipt tests exercise +ownership, exact-request refusal, retention, and unchanged product state. + +Residual risk: receipt observation is not throttled, like every other +Scheduling route; Scheduling keeps no request budget of its own. An +authenticated caller that repeats it costs one bounded indexed lookup and its +audit records per request. This is accepted rather than given a read budget: +an operator bounds an authenticated flood at the deployment's ingress, as for +every other route. diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index aa0f0cb3fe..77379ec4ab 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -694,6 +694,11 @@ invariants: per-caller hold ceiling stops counting holds the caller already has; or the owner leaks into a receipt, outbox payload, or observer projection. enforcementPoint: >- + Original receipt observation matches the verified issuer and subject, + original operation key and canonical request hash, reads only retained + successful receipts, and never replays a capacity command. + The receipt route authenticates before media-type checks and JSON + decoding, under the router's existing request-body ceiling. A claim stores the verified issuer and subject that booked it under a check constraint holding both present and non-empty or both absent. ClaimRow::is_owned_by, owned_booking, the external reference listing, diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 3da8c4cfde..eace90a5fb 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -132,6 +132,7 @@ entries: - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: edge_answers_stay_edge_talk} - id: SCHEDULING-SEC-14 tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: original_appointment_receipt_disclosure_requires_both_audit_entries} - {path: crates/registry-scheduling/src/audit.rs, name: a_response_record_is_stamped_with_its_identity_exactly_once} - {path: crates/registry-scheduling/src/audit.rs, name: a_request_record_omits_only_the_decision} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hold_confirms_into_an_appointment_with_attributable_history} @@ -279,6 +280,10 @@ entries: - {path: crates/registry-schedulingctl/tests/activation_postgres.rs, name: fresh_attempt_storage_preserves_digests_and_refuses_partial_erasure, features: [postgres-test]} - id: SCHEDULING-SEC-34 tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: original_appointment_receipt_is_readable_without_a_grant_and_never_creates_capacity} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: original_appointment_receipt_authenticates_before_parsing_its_bounded_body} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: original_hold_confirmation_receipt_is_read_by_its_hold_and_key} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_refused_attempt_never_resolves_an_original_appointment_receipt} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: rotating_the_audit_key_keeps_existing_claims_with_their_owner} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: one_caller_cannot_outrun_the_hold_ceiling_across_two_pools} - {path: crates/registry-scheduling/src/service.rs, name: a_success_replay_receipt_projects_through_the_same_document} diff --git a/products/scheduling/generated/fixture/fixture.schema.json b/products/scheduling/generated/fixture/fixture.schema.json index 1f102b8ded..4dadd1f789 100644 --- a/products/scheduling/generated/fixture/fixture.schema.json +++ b/products/scheduling/generated/fixture/fixture.schema.json @@ -248,6 +248,7 @@ "precondition.required", "prerequisite.missing", "profile.not-authorized", + "receipt.unresolved", "request.body-too-large", "request.invalid", "request.method-not-allowed", diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index 0436b64901..b3e46444ab 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -794,6 +794,7 @@ "precondition.required", "prerequisite.missing", "profile.not-authorized", + "receipt.unresolved", "request.body-too-large", "request.invalid", "request.method-not-allowed", @@ -1428,6 +1429,33 @@ } ] }, + "ProblemReceiptUnresolved": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "receipt.unresolved" + }, + "detail": { + "const": "No retained success receipt matches this caller, key, and original request. The original outcome remains unknown; this is not evidence that no effect occurred." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Original receipt unresolved" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/receipt/unresolved" + } + }, + "type": "object" + } + ] + }, "ProblemRequestBodyTooLarge": { "allOf": [ { @@ -2671,6 +2699,248 @@ "x-scheduling-authority": "task-grant" } }, + "/v1/appointments/receipt": { + "post": { + "description": "Reads only a retained success receipt matching the verified caller's issuer and subject, the original key, and the original request. It requires current ordinary read authority, can observe work after task authority ends, and never creates or replays a capacity command. Missing, expired, changed, or refused receipts are receipt.unresolved; this is not evidence that no effect occurred.", + "operationId": "readAppointmentReceipt", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemReceiptUnresolved" + } + } + }, + "description": "Problem response: receipt.unresolved", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read the original appointment receipt", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "reads-scope" + } + }, "/v1/appointments/{appointment_id}": { "get": { "description": "Answers only to the caller that owns the booking. Another caller's appointment, a hold, and an unknown identifier are all operation.not-authorized, so existence is never disclosed.", diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index 8a9478dbe2..d132abde98 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -67,6 +67,7 @@ "/v1/holds", "/v1/holds/{hold_id}", "/v1/appointments", + "/v1/appointments/receipt", "/v1/appointments/{appointment_id}", "/v1/appointments/{appointment_id}/reschedule", "/v1/appointments/{appointment_id}/cancel", @@ -145,6 +146,7 @@ ("POST", "/v1/holds"): "create_hold", ("DELETE", "/v1/holds/{hold_id}"): "release_hold", ("POST", "/v1/appointments"): "create_appointment", + ("POST", "/v1/appointments/receipt"): "appointment_receipt", ("GET", "/v1/appointments"): "list_appointments", ("GET", "/v1/appointments/{appointment_id}"): "get_appointment", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "reschedule_appointment", @@ -165,6 +167,7 @@ ("POST", "/v1/holds"): "createHold", ("DELETE", "/v1/holds/{hold_id}"): "releaseHold", ("POST", "/v1/appointments"): "createAppointment", + ("POST", "/v1/appointments/receipt"): "readAppointmentReceipt", ("GET", "/v1/appointments"): "listAppointmentsByExternalReference", ("GET", "/v1/appointments/{appointment_id}"): "getAppointment", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "rescheduleAppointment", @@ -238,6 +241,7 @@ + ["hold.released", "idempotency.expired", "service.unavailable"], ("POST", "/v1/appointments"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + OFFERING + ["hold.expired", "hold.released", "service.unavailable"], + ("POST", "/v1/appointments/receipt"): EDGE + AUTHENTICATION + JSON_BODY + ["receipt.unresolved", "service.unavailable"], ("GET", "/v1/appointments"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + STORAGE, # A reschedule resolves its offering from the appointment rather than from @@ -814,6 +818,13 @@ def document(contract: dict) -> dict: idempotency=True, description="Carries exactly one of hold or admission. Confirming transfers the hold's own reservation, so capacity is re-checked only for identity: the hold must still be active and unexpired, its policy revision still current, and its holder still the caller. A direct create is evaluated against the live ledger like a hold, and commits instead of reserving. Both branches demand a complete task grant naming the offering's service, location, and appointment.create.", )}, + "/v1/appointments/receipt": {"post": operation( + "Read the original appointment receipt", + "AppointmentDocument", + body="CreateAppointmentRequest", + idempotency=True, + description="Reads only a retained success receipt matching the verified caller's issuer and subject, the original key, and the original request. It requires current ordinary read authority, can observe work after task authority ends, and never creates or replays a capacity command. Missing, expired, changed, or refused receipts are receipt.unresolved; this is not evidence that no effect occurred.", + )}, "/v1/appointments/{appointment_id}": {"get": operation( "Read one owned appointment", "AppointmentDocument", @@ -965,6 +976,9 @@ def handler_wiring( # so a multi-line parameter list is read whole. parameters = signature[signature.index("(") + 1 : signature.rindex(")")] json_body = re.search(r"Json\(\w+\):\s*Json<([A-Za-z0-9_]+)>", parameters) + if json_body is None: + # A protected body can be extracted explicitly after authentication. + json_body = re.search(r"Json::<([A-Za-z0-9_]+)>::from_request\(", body) query = re.search(r"Query\(\w+\):\s*Query<([A-Za-z0-9_]+)>", parameters) path_parameters = re.findall(r"Path\((\w+)\):\s*Path<([A-Za-z0-9_:]+)>", parameters) authority = "unauthenticated" diff --git a/products/scheduling/scripts/test_generate_openapi.py b/products/scheduling/scripts/test_generate_openapi.py index 58135e9b93..71d4780385 100644 --- a/products/scheduling/scripts/test_generate_openapi.py +++ b/products/scheduling/scripts/test_generate_openapi.py @@ -75,8 +75,8 @@ def test_every_documented_problem_matches_its_pinned_rust_catalog_entry(self) -> self.assertEqual(entry["title"], properties["title"]["const"]) self.assertEqual(entry["description"], properties["detail"]["const"]) self.assertEqual(entry["httpStatuses"][0], properties["status"]["const"]) - self.assertEqual(32, len(self.contract["entries"])) - self.assertEqual(61, len(self.openapi["components"]["schemas"])) + self.assertEqual(33, len(self.contract["entries"])) + self.assertEqual(62, len(self.openapi["components"]["schemas"])) def test_every_operation_answers_exactly_the_problems_it_was_mapped(self) -> None: for key, expected in GENERATOR.OPERATION_PROBLEMS.items(): @@ -198,12 +198,14 @@ def test_an_operation_documents_the_authority_its_handler_uses(self) -> None: ("POST", "/v1/holds"): "task-grant", ("DELETE", "/v1/holds/{hold_id}"): "task-grant", ("POST", "/v1/appointments"): "task-grant", + ("POST", "/v1/appointments/receipt"): "reads-scope", ("GET", "/v1/appointments"): "reads-scope", ("GET", "/v1/appointments/{appointment_id}"): "reads-scope", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "task-grant", ("POST", "/v1/appointments/{appointment_id}/cancel"): "task-grant", ("GET", "/v1/appointments/{appointment_id}/history"): "reads-scope", } + self.assertEqual(set(GENERATOR.ROUTE_HANDLERS), set(expected)) for key, authority in expected.items(): documented = operation(self.openapi, key) self.assertEqual(authority, documented["x-scheduling-authority"], key) @@ -219,12 +221,13 @@ def test_an_operation_documents_the_authority_its_handler_uses(self) -> None: self.assertEqual(1, authorities.count("explain-scope")) self.assertEqual(2, authorities.count("unauthenticated")) self.assertEqual(5, authorities.count("task-grant")) - self.assertEqual(9, authorities.count("reads-scope")) + self.assertEqual(10, authorities.count("reads-scope")) def test_an_idempotency_key_is_demanded_exactly_where_the_handler_reads_it(self) -> None: expected = { ("POST", "/v1/holds"), ("POST", "/v1/appointments"), + ("POST", "/v1/appointments/receipt"), ("POST", "/v1/appointments/{appointment_id}/reschedule"), ("POST", "/v1/appointments/{appointment_id}/cancel"), } @@ -239,6 +242,19 @@ def test_an_idempotency_key_is_demanded_exactly_where_the_handler_reads_it(self) self.assertIn("idempotency.expired", demanded["description"]) self.assertIn("idempotency.key-reused", demanded["description"]) + def test_only_original_receipt_observation_answers_an_unresolved_receipt(self) -> None: + receipt = ("POST", "/v1/appointments/receipt") + for key in GENERATOR.ROUTE_HANDLERS: + self.assertEqual(key == receipt, "receipt.unresolved" in documented_codes(self.openapi, key), key) + observed = operation(self.openapi, receipt) + self.assertEqual({"receipt.unresolved"}, codes_of(self.openapi, receipt)[409]) + self.assertEqual("reads-scope", observed["x-scheduling-authority"]) + self.assertEqual("#/components/schemas/AppointmentDocument", + observed["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]) + self.assertIn("verified caller's issuer and subject", observed["description"]) + self.assertIn("never creates or replays a capacity command", observed["description"]) + self.assertIn("not evidence that no effect occurred", observed["description"]) + def test_a_release_uses_the_hold_itself_as_its_idempotency_key(self) -> None: """No caller key means a reused key is unreachable on this route.""" key = ("DELETE", "/v1/holds/{hold_id}") @@ -254,6 +270,7 @@ def test_a_request_body_is_demanded_exactly_where_the_handler_extracts_one(self) expected = { ("POST", "/v1/holds"): "AdmissionRequest", ("POST", "/v1/appointments"): "CreateAppointmentRequest", + ("POST", "/v1/appointments/receipt"): "CreateAppointmentRequest", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "RescheduleAppointmentRequest", ("POST", "/v1/appointments/{appointment_id}/cancel"): "CancelAppointmentRequest", } @@ -268,6 +285,38 @@ def test_a_request_body_is_demanded_exactly_where_the_handler_extracts_one(self) self.assertIn("request.unprocessable", codes, key) self.assertIn("request.unsupported-media-type", codes, key) + def test_post_authentication_json_extraction_still_pins_the_receipt_body_schema(self) -> None: + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + service = GENERATOR.production_source(ROOT, GENERATOR.SERVICE_SOURCE) + naming = GENERATOR.production_source(ROOT, GENERATOR.NAMING_SOURCE) + inventory = GENERATOR.route_inventory(http, naming) + wiring = GENERATOR.handler_wiring(http, service, inventory, self.variants) + key = ("POST", "/v1/appointments/receipt") + self.assertEqual("CreateAppointmentRequest", wiring[key]["body"]) + bad = copy.deepcopy(self.openapi) + operation(bad, key)["requestBody"]["content"]["application/json"]["schema"]["$ref"] = ( + "#/components/schemas/AdmissionRequest" + ) + with self.assertRaisesRegex(ValueError, "request body schema drifted"): + GENERATOR.verify_handler_wiring(ROOT, bad["paths"], self.variants) + + def test_a_type_mention_or_an_unrelated_decoder_is_not_a_json_body_extractor(self) -> None: + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + service = GENERATOR.production_source(ROOT, GENERATOR.SERVICE_SOURCE) + naming = GENERATOR.production_source(ROOT, GENERATOR.NAMING_SOURCE) + inventory = GENERATOR.route_inventory(http, naming) + extraction = "Json::::from_request(" + self.assertIn(extraction, http) + for unrelated in ( + "decode::(", + "Json::::from_value(", + "CreateAppointmentRequest::from_request(", + ): + with self.subTest(unrelated=unrelated): + changed = http.replace(extraction, unrelated) + wiring = GENERATOR.handler_wiring(changed, service, inventory, self.variants) + self.assertIsNone(wiring[("POST", "/v1/appointments/receipt")]["body"]) + def test_success_statuses_come_from_the_handlers(self) -> None: expected = { ("POST", "/v1/holds"): "201", @@ -638,7 +687,7 @@ def test_the_problem_envelope_is_the_platform_one(self) -> None: set(problem["properties"])) self.assertEqual(["code", "detail", "status", "title", "traceId", "type"], sorted(problem["required"])) - self.assertEqual(32, len(problem["properties"]["code"]["enum"])) + self.assertEqual(33, len(problem["properties"]["code"]["enum"])) httpsec = GENERATOR.production_source(ROOT, GENERATOR.HTTPSEC_SOURCE) http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) body = GENERATOR.rust_struct_fields(httpsec, "ProblemBody") From f9d1e575cf8bab891f1602b3cd82424b0a0b4f19 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 10:39:42 +0700 Subject: [PATCH 08/11] feat(breg,casework): wire local task-grant status and resource grants in dev sessions bregctl dev derives the generated runtime's principal claim from the compiled access profiles, resolves a `$localClientSubject` fixture marker for `sub` journeys, and binds declared Casework task-grant status services with their own status-only clients. Authored machine-client `grants` give a local client exact registered scopes on other resources through the ThunderID tooling's explicit `ordinary_resource_permissions` allowlist, which stays empty, and bootstrap-only, by default. caseworkctl dev refuses borrowed sessions whose access profiles use `sub` and reports known integration and shared-registration mismatches with a fixed diagnostic category and the clients-file section to correct. Signed-off-by: Jeremi Joslin --- crates/registry-bregctl/src/dev/config.rs | 181 +++++- .../registry-bregctl/src/dev/export_client.rs | 5 +- crates/registry-bregctl/src/dev/mod.rs | 63 +++ .../src/dev/prepare_source.rs | 1 + crates/registry-bregctl/src/dev/tests.rs | 530 ++++++++++++++++++ .../src/task_grants/native_exchange_tests.rs | 2 + crates/registry-caseworkctl/src/dev/config.rs | 29 +- .../src/dev/integrations.rs | 34 +- crates/registry-caseworkctl/src/dev/mod.rs | 83 +++ crates/registry-caseworkctl/src/dev/tests.rs | 121 +++- crates/registry-caseworkctl/src/lib.rs | 61 +- .../tests/against_a_real_deployment.rs | 1 + crates/registry-thunderid-tooling/README.md | 13 +- .../examples/contextual-exchange.rs | 2 + .../src/description.rs | 165 +++++- .../registry-thunderid-tooling/src/render.rs | 211 ++++--- products/breg/DEV.md | 72 ++- .../.breg/dev/source-transition.json | 2 +- .../tools/dev-clients.v1alpha1.schema.json | 38 ++ products/casework/DEV-SOURCES.md | 15 + .../identifiers/generated/catalog.v1.json | 2 +- 21 files changed, 1493 insertions(+), 138 deletions(-) diff --git a/crates/registry-bregctl/src/dev/config.rs b/crates/registry-bregctl/src/dev/config.rs index 84bbc366bf..857973d0a5 100644 --- a/crates/registry-bregctl/src/dev/config.rs +++ b/crates/registry-bregctl/src/dev/config.rs @@ -5,7 +5,9 @@ use super::{private, State, DATABASE_ID, MAX_BYTES, MIGRATION_ROLE, RUNTIME_ROLE use anyhow::{bail, Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use p256::ecdsa::SigningKey; -use registry_breg::literal_text::{LiteralText, WRITE_THE_VALUE_OR_A_SECRET_REFERENCE}; +use registry_breg::literal_text::{ + LiteralText, WRITE_THE_VALUE, WRITE_THE_VALUE_OR_A_SECRET_REFERENCE, +}; use registry_platform_config::{SecretProvidersConfig, SecretReference}; use registry_platform_yaml::{ ApiVersion, Diagnostic, Document, EnvelopeRule, Expect, FormatSpec, Reader, RemovedKey, Report, @@ -213,6 +215,13 @@ pub(super) struct Clients { schemars(schema_with = "keyed_schema::") )] pub review_executors: BTreeMap, + /// Exact Casework status services for compiled task-grant profiles. + #[serde(default, deserialize_with = "local_id_keys")] + #[cfg_attr( + feature = "schema", + schemars(schema_with = "keyed_schema::") + )] + pub task_grant_status: BTreeMap, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -291,6 +300,19 @@ pub(super) struct LocalReviewExecutor { pub client: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct LocalTaskGrantStatus { + pub source_issuer: ExternalId, + #[serde(deserialize_with = "url")] + #[cfg_attr(feature = "schema", schemars(with = "Url"))] + pub base_url: String, + #[serde(deserialize_with = "local_id")] + #[cfg_attr(feature = "schema", schemars(with = "LocalId"))] + pub client: String, +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -441,6 +463,10 @@ pub(super) struct Client { #[serde(deserialize_with = "external_id_keys")] #[cfg_attr(feature = "schema", schemars(schema_with = "claims_schema"))] pub claims: BTreeMap, + /// Additional explicit permissions on declared resource audiences. These + /// do not change default token scopes or the exchange bootstrap scope. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub grants: Vec, /// Exact schema-test steps that use this claim variant. Runtime requests /// still select an authored access profile; this field only disambiguates /// credentials for maintained local journeys. @@ -903,6 +929,39 @@ fn validate(clients: Clients, secrets: Secrets) -> Result { if clients.review_authorities.len() > 8 { bail!("at most 8 local review authorities may be bound"); } + if clients.task_grant_status.len() > 8 { + bail!("at most 8 local task-grant status services may be bound"); + } + let mut task_sources = BTreeSet::new(); + for (id, status) in &clients.task_grant_status { + let endpoint = reqwest::Url::parse(&status.base_url) + .context("local task-grant status baseUrl must be an exact loopback HTTP URL")?; + let client = clients + .clients + .iter() + .find(|client| client.id == status.client); + if !governed_identifier(id) + || !registry_platform_httputil::valid_resource_uri(&status.source_issuer) + || !task_sources.insert(&status.source_issuer) + || endpoint.scheme() != "http" + || endpoint.host_str() != Some("127.0.0.1") + || endpoint.port().is_none_or(|port| port == 0) + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || registry_platform_httputil::client::ServiceBaseUrl::new(endpoint).is_err() + || client.is_none_or(|client| { + client.scopes != ["casework:grants:status"] + || !client.access_profiles.is_empty() + || client.allow_breg_access + || client_token_claims(client).get("registry_actor_kind") + != Some(&json!("service")) + }) + { + bail!("local task-grant status services need unique source issuers, exact loopback endpoints, and declared service clients scoped only to casework:grants:status without BReg access"); + } + } for (id, authority) in &clients.review_authorities { let endpoint = reqwest::Url::parse(&authority.endpoint) .context("local review authority endpoint must be an exact loopback HTTP URL")?; @@ -1105,6 +1164,11 @@ fn validate(clients: Clients, secrets: Secrets) -> Result { bail!("issuer client resource bindings need a declared client, audience, and resource scopes"); } } + for client in &clients.clients { + if !client.grants.is_empty() && !valid_grants(&clients, &client.grants) { + bail!("machine client grants require distinct declared audiences and exact bounded resource scopes"); + } + } let mut exchange_clients = BTreeSet::new(); for id in &clients.issuer.exchange_clients { if !exchange_clients.insert(id) @@ -1253,6 +1317,13 @@ pub(super) fn keypair(root: &Path) -> Result { pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<()> { if state.issuer_project.is_some() { validate_borrowed_issuer_composition(&clients.issuer)?; + if clients + .clients + .iter() + .any(|client| !client.grants.is_empty()) + { + bail!("a borrowed issuer cannot declare owner-only machine client grants; declare them on the issuer owner"); + } } for directory in [ "credentials", @@ -1431,6 +1502,19 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( private::create(&root.join("secrets").join(name), &bytes)?; } } + for (id, status) in &clients.task_grant_status { + let source = root + .join("credentials") + .join(&status.client) + .join("assertion-key.jwk"); + let key = Zeroizing::new(private::read(&source, 64 * 1024)?); + private::create( + &root + .join("secrets") + .join(format!("task-status-{id}-client-assertion-key")), + &key, + )?; + } // The dev session's issuer is the pinned upstream ThunderID container, // rendered and provisioned through the shared tooling crate from these // same authored declarations. BREG keeps its database, seeding, retained @@ -1680,12 +1764,37 @@ pub(super) fn issuer_description( .context("a declared issuer client role is missing")?; role.permissions[0].0 = server.id.clone(); } + for client in &clients.clients { + if client.grants.is_empty() { + continue; + } + let permissions = role_permissions(&description, state, &client.grants)?; + let agent = registry_thunderid_tooling::local::agent_id(&state.instance_id, &client.id); + let role = description + .roles + .iter_mut() + .find(|role| role.assigned_agents.contains(&agent)) + .context("a declared issuer client role is missing")?; + for (resource, scopes) in permissions { + if let Some((_, existing)) = role.permissions.iter_mut().find(|(id, _)| *id == resource) + { + for scope in scopes { + if !existing.contains(&scope) { + existing.push(scope); + } + } + } else { + role.permissions.push((resource, scopes)); + } + } + } for id in &clients.issuer.exchange_clients { let client = clients .clients .iter() .find(|client| &client.id == id) .context("an exchange client is missing")?; + let ordinary_permissions = role_permissions(&description, state, &client.grants)?; let machine = description .machine_clients .iter_mut() @@ -1699,6 +1808,10 @@ pub(super) fn issuer_description( machine.token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: role.permissions[0].0.clone(), assertion_scope: client.scopes[0].clone(), + ordinary_resource_permissions: ordinary_permissions + .into_iter() + .filter(|(resource, _)| resource != &role.permissions[0].0) + .collect(), }); } let mut purpose_clients = Vec::new(); @@ -1738,6 +1851,7 @@ pub(super) fn issuer_description( machine.token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: role.permissions[0].0.clone(), assertion_scope: client.scopes[0].clone(), + ordinary_resource_permissions: Vec::new(), }); } description @@ -1941,6 +2055,34 @@ pub(super) fn runtime(root: &Path, state: &State, clients: &Clients, test: bool) let compiled = crate::compile(&root.join("project"), crate::ProfileArg::Production, "dev") .map_err(|_| anyhow::anyhow!("captured local project no longer compiles"))?; let authority_claims = local_authority_claims(&compiled)?; + let journeys_path = root.join("project/tests/journeys.yaml"); + if journeys_path.exists() { + let mut journeys = Reader::new(journeys_path.display().to_string()) + .with_hook(&mut LiteralText { + remedy: WRITE_THE_VALUE, + }) + .read( + &private::read(&journeys_path, MAX_BYTES)?, + &Expect::one(®istry_breg::fixtures::JOURNEYS_FORMAT), + )? + .to_json_value(); + if super::has_local_subject_marker(&journeys) { + let owner = super::borrowed_owner(state)?; + let issuer_instance = owner.as_ref().map_or(state.instance_id.as_str(), |owner| { + owner.instance_id.as_str() + }); + super::resolve_local_subject_markers( + &mut journeys, + clients, + &compiled, + issuer_instance, + )?; + private::replace( + &journeys_path, + serde_norway::to_string(&journeys)?.as_bytes(), + )?; + } + } let destinations = if state.webhook_port.is_some() || !clients.event_destinations.is_empty() { if let Some(port) = state.webhook_port { event_destinations(&compiled, port) @@ -2012,7 +2154,8 @@ pub(super) fn runtime(root: &Path, state: &State, clients: &Clients, test: bool) "caBundleRef":provider.ca_bundle_ref.as_ref().map(|_| format!("secret:file/evidence-ca-{id}")) }))).collect::>(), "reviewAuthorities":local_review_authorities(state, clients), - "reviewExecutors":review_executors + "reviewExecutors":review_executors, + "taskGrantStatus":local_task_grant_status(state, clients) }); // runtime.yaml writes an optional member by leaving it out (CFG-EMPTY-1), // and an empty assertionIssuers map is refused, so an absent setting is @@ -2191,12 +2334,7 @@ fn local_review_authorities(state: &State, clients: &Clients) -> BTreeMap BTreeMap String { + clients + .issuer + .client_resources + .get(client) + .cloned() + .unwrap_or_else(|| state.audience()) +} + +fn local_task_grant_status(state: &State, clients: &Clients) -> Vec { + clients + .task_grant_status + .iter() + .map(|(id, status)| { + json!({ + "sourceIssuer": status.source_issuer, + "baseUrl": status.base_url, + "tokenEndpoint": format!("{}/oauth2/token", state.issuer_origin()), + "clientAssertionAudience": state.issuer_origin(), + "clientId": status.client, + "privateKeyRef": format!("secret:file/task-status-{id}-client-assertion-key"), + "caseworkResource": client_resource(state, clients, &status.client) + }) + }) + .collect() +} + pub(super) fn external_event_destinations( compiled: ®istry_breg::CompiledRegistry, bindings: &BTreeMap, diff --git a/crates/registry-bregctl/src/dev/export_client.rs b/crates/registry-bregctl/src/dev/export_client.rs index d7d897e2de..2b9f0b4022 100644 --- a/crates/registry-bregctl/src/dev/export_client.rs +++ b/crates/registry-bregctl/src/dev/export_client.rs @@ -56,6 +56,7 @@ pub(super) fn run(args: ExportClientArgs) -> Result { let key_stage = key_output.stage(&key)?; id_stage.publish()?; key_stage.publish()?; + let resource = config::client_resource(&state, &clients, &client.id); Ok( json!({"ok":true,"command":"dev export-client","project":project, "client":client.id,"accessProfiles":client.access_profiles, @@ -65,9 +66,9 @@ pub(super) fn run(args: ExportClientArgs) -> Result { "issuer":state.issuer_origin(), "tokenEndpoint":format!("{}/oauth2/token",state.issuer_origin()), "clientAssertionAudience":state.issuer_origin(), - "resource":state.audience(), + "resource":resource, "scopes":client.scopes, - "audience":state.audience()}), + "audience":resource}), ) } diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index c3779bbe19..c02b301c78 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -789,6 +789,69 @@ fn receiver_port(state: &State) -> Result { } } +const LOCAL_SUBJECT_MARKER: &str = "$localClientSubject"; + +fn has_local_subject_marker(value: &Value) -> bool { + match value { + Value::String(value) => value == LOCAL_SUBJECT_MARKER, + Value::Array(values) => values.iter().any(has_local_subject_marker), + Value::Object(values) => values.values().any(has_local_subject_marker), + _ => false, + } +} + +/// Resolve an explicit local fixture identity, never an arbitrary expectation. +/// The caller writes only its private captured project, not authored inputs. +fn resolve_local_subject_markers( + journeys: &mut Value, + clients: &Clients, + compiled: ®istry_breg::CompiledRegistry, + issuer_instance: &str, +) -> Result<()> { + for journey in journeys["journeys"] + .as_array_mut() + .context("journeys must contain an array")? + { + let journey_id = journey["id"] + .as_str() + .context("journey requires an id")? + .to_owned(); + for step in journey["steps"] + .as_array_mut() + .context("journey steps must be an array")? + { + if step["claims"]["principal"] != LOCAL_SUBJECT_MARKER { + continue; + } + let profile = step["accessProfile"] + .as_str() + .context("journey step requires an access profile")?; + let step_id = step["id"].as_str().context("journey step requires an id")?; + let profiles = compiled + .entities() + .values() + .filter_map(|entity| entity.access_profiles.get(profile)) + .collect::>(); + if profiles.is_empty() + || profiles + .iter() + .any(|profile| profile.principal_claim != "sub") + { + bail!("$localClientSubject is supported only as claims.principal for an authenticated sub access profile"); + } + let client = journey_client(clients, &journey_id, step_id, profile)?; + step["claims"]["principal"] = json!(registry_thunderid_tooling::local::agent_id( + issuer_instance, + &client.id + )); + } + } + if has_local_subject_marker(journeys) { + bail!("$localClientSubject must appear only as a sub profile's journey claims.principal"); + } + Ok(()) +} + /// Name the first journey step whose access profile no local client binds. /// /// The schema-test stage makes the same lookup, but by then the database has diff --git a/crates/registry-bregctl/src/dev/prepare_source.rs b/crates/registry-bregctl/src/dev/prepare_source.rs index 0d97473764..04e1f0dcc0 100644 --- a/crates/registry-bregctl/src/dev/prepare_source.rs +++ b/crates/registry-bregctl/src/dev/prepare_source.rs @@ -615,6 +615,7 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { allow_human_fixture: false, scopes: vec![scope], claims, + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }; diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 33fcdeed45..c6c5369d7f 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -337,6 +337,113 @@ fn write_init_project() -> (tempfile::TempDir, PathBuf) { (temporary, project) } +fn local_principal_source(claim: &str) -> Value { + json!({ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1", "kind":"BRegProject", + "project":{"id":"generic-local","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://synthetic.example.test"}, + "package":{"sourceRevision":"synthetic-1"}, + "entities":[{"id":"record","primaryDataset":"test-dataset","route":"records","mutationMode":"mutable", + "fields":[{"id":"code","type":"string","maximumLength":64,"required":true,"classification":"internal"}]}], + "accessProfiles":[{"id":"operator","principalClaim":claim,"requiredScopes":"unrestricted","permissions":{"entities":[{"entity":"record","operations":["get"],"readableFields":["code"],"rowBoundaries":"unrestricted"}]}}] + }) +} + +fn local_principal_compiled(source: &Value) -> registry_breg::CompiledRegistry { + let project = + registry_breg::contract::parse_project_json(source.to_string().as_bytes()).unwrap(); + registry_breg::compiler::compile_project( + &project, + &[], + registry_breg::compiler::CompileProfile::Production, + ) + .unwrap() +} + +#[test] +fn local_subject_marker_uses_actual_issuer_owner_and_exact_client_binding() { + let (_temporary, _, mut clients, _) = fixture(); + let mut variant = clients.clients[0].clone(); + variant.id = "agent-variant".into(); + variant.test_bindings = vec![config::TestBinding { + journey_id: "read".into(), + step_id: "variant".into(), + }]; + clients.clients.push(variant); + let compiled = local_principal_compiled(&local_principal_source("sub")); + let mut journeys = json!({"journeys":[{"id":"read","steps":[ + {"id":"ordinary","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER}}, + {"id":"variant","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER}}, + {"id":"literal","accessProfile":"operator","claims":{"principal":"authored-expectation"}} + ]}]}); + resolve_local_subject_markers(&mut journeys, &clients, &compiled, "actual-issuer-owner") + .unwrap(); + for (index, client) in [(0, "operator"), (1, "agent-variant")] { + assert_eq!( + journeys["journeys"][0]["steps"][index]["claims"]["principal"], + registry_thunderid_tooling::local::agent_id("actual-issuer-owner", client) + ); + assert_ne!( + journeys["journeys"][0]["steps"][index]["claims"]["principal"], + registry_thunderid_tooling::local::agent_id("borrower-instance", client) + ); + } + assert_eq!( + journeys["journeys"][0]["steps"][2]["claims"]["principal"], + "authored-expectation" + ); + assert!(!has_local_subject_marker(&journeys)); + for claim in ["registry_principal", "sub"] { + let mut misplaced = json!({"journeys":[{"id":"read","steps":[{"id":"ordinary","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER},"other":LOCAL_SUBJECT_MARKER}]}]}); + assert!(resolve_local_subject_markers( + &mut misplaced, + &clients, + &local_principal_compiled(&local_principal_source(claim)), + "owner" + ) + .is_err()); + } +} + +#[test] +fn local_sub_runtime_resolves_only_its_private_captured_journey() { + let (_temporary, state, clients, _) = fixture(); + let source = local_principal_source("sub"); + let journeys = json!({"apiVersion":registry_breg::fixtures::JOURNEYS_API_VERSION,"kind":registry_breg::fixtures::JOURNEYS_KIND,"journeys":[{"id":"read","steps":[{"id":"get","accessProfile":"operator","claims":{"principal":LOCAL_SUBJECT_MARKER}}]}]}); + let files = BTreeMap::from([ + ("registry.yaml".into(), source.to_string().into_bytes()), + ( + "tests/journeys.yaml".into(), + journeys.to_string().into_bytes(), + ), + ]); + initialize(&state.root(), &state, &clients, &files).unwrap(); + let captured: Value = serde_norway::from_slice( + &private::read(&state.root().join("project/tests/journeys.yaml"), MAX_BYTES).unwrap(), + ) + .unwrap(); + assert_eq!( + captured["journeys"][0]["steps"][0]["claims"]["principal"], + registry_thunderid_tooling::local::agent_id(&state.instance_id, "operator") + ); + assert_eq!( + serde_json::from_slice::(&files["tests/journeys.yaml"]).unwrap(), + journeys, + "authored input bytes remain unchanged" + ); + private::directory(&state.root().join("build")).unwrap(); + private::directory(&state.root().join("build/package")).unwrap(); + config::runtime(&state.root(), &state, &clients, false).unwrap(); + for name in ["runtime-test.yaml", "runtime.yaml"] { + let runtime: Value = + serde_norway::from_slice(&private::read(&state.root().join(name), MAX_BYTES).unwrap()) + .unwrap(); + assert_eq!( + runtime["authentication"]["authorityClaims"]["principal"], + "sub" + ); + } +} + #[test] fn a_fresh_init_project_starts_without_edits() { // `bregctl dev` runs the local session as the registry's one instance and @@ -476,6 +583,7 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allow_human_fixture: false, scopes: vec!["registry:generic:introspect".into()], claims: BTreeMap::new(), + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }); @@ -486,6 +594,7 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allow_human_fixture: false, scopes: vec!["registry:generic:review".into()], claims: BTreeMap::from([("registry_actor_kind".into(), json!("agent"))]), + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }); @@ -496,6 +605,7 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allow_human_fixture: true, scopes: vec!["casework:admin".into()], claims: BTreeMap::from([("registry_actor_kind".into(), json!("human"))]), + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }); @@ -967,6 +1077,218 @@ fn pair_exchange_client(mut clients: Clients, mapping: config::IssuerConnectionM clients } +fn machine_grant_inventory(mut clients: Clients) -> Clients { + clients.clients.push( + serde_json::from_value(json!({ + "id":"task-agent","accessProfiles":[],"scopes":["casework:grants:assert"],"claims":{}, + "grants":[{"audience":"urn:scheduling:dev:machine","scopes":["scheduling:read"]}] + })) + .unwrap(), + ); + clients.issuer.resources.extend([ + config::IssuerResource { + audience: "urn:casework:dev:machine".into(), + scopes: vec!["casework:grants:assert".into(), "casework:read".into()], + }, + config::IssuerResource { + audience: "urn:scheduling:dev:machine".into(), + scopes: vec!["scheduling:read".into()], + }, + ]); + clients + .issuer + .client_resources + .insert("task-agent".into(), "urn:casework:dev:machine".into()); + clients.issuer.exchange_clients.push("task-agent".into()); + clients + .issuer + .exchange_issuers + .push(config::IssuerConnection { + id: "casework".into(), + issuer: "https://casework.example.test".into(), + jwks_endpoint: "https://casework.example.test/oauth2/jwks".into(), + mapping: config::IssuerConnectionMapping::InstitutionalGrant, + clients: vec!["task-agent".into()], + token_attributes: BTreeMap::new(), + }); + clients +} + +#[test] +fn machine_resource_grants_project_exact_permissions_without_changing_bootstrap_or_export() { + let (_temp, state, base, files) = fixture(); + assert!(base.clients.iter().all(|client| client.grants.is_empty())); + assert!(!serde_json::to_value(&base).unwrap()["clients"][0] + .as_object() + .unwrap() + .contains_key("grants")); + let mut clients = machine_grant_inventory(base); + let grant = config::LocalPermissionGrant { + audience: None, + scopes: vec!["registry:generic:operate".into()], + }; + clients + .clients + .last_mut() + .unwrap() + .grants + .push(grant.clone()); + clients.clients[1].grants.push(grant); + let clients = config::clients( + "dev-clients.yaml", + serde_norway::to_string(&clients).unwrap().as_bytes(), + ) + .unwrap(); + initialize(&state.root(), &state, &clients, &files).unwrap(); + let description = config::issuer_description(&state, &clients, &state.root()).unwrap(); + description.validate().unwrap(); + let machine = description + .machine_clients + .iter() + .find(|m| m.client_id == "task-agent") + .unwrap(); + let role = description + .roles + .iter() + .find(|r| r.assigned_agents.contains(&machine.agent_id)) + .unwrap(); + let resource = |audience: &str| { + description + .resource_servers + .iter() + .find(|r| r.identifier == audience) + .unwrap() + .id + .clone() + }; + assert_eq!( + role.permissions, + vec![ + ( + resource("urn:casework:dev:machine"), + vec!["casework:grants:assert".into()] + ), + ( + resource("urn:scheduling:dev:machine"), + vec!["scheduling:read".into()] + ), + ( + resource(&state.audience()), + vec!["registry:generic:operate".into()] + ), + ] + ); + let exchange = machine.token_exchange.as_ref().unwrap(); + assert_eq!(exchange.assertion_resource_server_id, role.permissions[0].0); + assert_eq!(exchange.assertion_scope, "casework:grants:assert"); + assert_eq!( + exchange.ordinary_resource_permissions, + role.permissions[1..] + ); + let source = description + .machine_clients + .iter() + .find(|m| m.client_id == "source") + .unwrap(); + let source_role = description + .roles + .iter() + .find(|role| role.assigned_agents.contains(&source.agent_id)) + .unwrap(); + assert_eq!( + source_role.permissions, + vec![( + resource(&state.audience()), + vec![ + "registry:evidence:lookup".into(), + "registry:generic:operate".into() + ] + )] + ); + assert_eq!( + clients.clients.last().unwrap().scopes, + ["casework:grants:assert"] + ); + let mut args = export_args(&state); + args.client = "task-agent".into(); + let report = export_client::run(args).unwrap(); + assert_eq!(report["resource"], "urn:casework:dev:machine"); + assert_eq!(report["scopes"], json!(["casework:grants:assert"])); + let runtime: Value = serde_norway::from_slice( + &private::read(&state.root().join("runtime-test.yaml"), MAX_BYTES).unwrap(), + ) + .unwrap(); + assert!(!runtime["authentication"]["oidc"]["allowedClients"] + .as_array() + .unwrap() + .iter() + .any(|id| id == "task-agent")); +} + +#[test] +fn machine_resource_grants_refuse_undeclared_broader_duplicate_and_unbounded_permissions() { + let (_temp, _state, base, _files) = fixture(); + let base = machine_grant_inventory(base); + let read = json!({"audience":"urn:scheduling:dev:machine","scopes":["scheduling:read"]}); + for grants in [ + json!([{"audience":"urn:undeclared:machine","scopes":["scheduling:read"]}]), + json!([{"audience":"not a resource URI","scopes":["scheduling:read"]}]), + json!([{"audience":"urn:scheduling:dev:machine","scopes":["scheduling:commit"]}]), + json!([{"audience":"urn:scheduling:dev:machine","scopes":["scheduling:*"]}]), + json!([{"audience":"urn:scheduling:dev:machine","scopes":[]}]), + json!([{"audience":"urn:scheduling:dev:machine","scopes":["scheduling:read","scheduling:read"]}]), + json!([read.clone(), read.clone()]), + json!(vec![read.clone(); 8]), + json!([{"audience":"urn:scheduling:dev:machine","scopes":vec!["scheduling:read";33]}]), + ] { + let mut clients = base.clone(); + clients.clients.last_mut().unwrap().grants = serde_json::from_value(grants).unwrap(); + let error = config::clients( + "dev-clients.yaml", + serde_norway::to_string(&clients).unwrap().as_bytes(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("machine client grants"), "{error}"); + } + let mut wider_bootstrap = base; + wider_bootstrap + .clients + .last_mut() + .unwrap() + .scopes + .push("casework:read".into()); + assert!(config::clients( + "dev-clients.yaml", + serde_norway::to_string(&wider_bootstrap) + .unwrap() + .as_bytes() + ) + .unwrap_err() + .to_string() + .contains("one exact bootstrap scope")); +} + +#[test] +fn borrowed_issuer_refuses_machine_resource_grants_before_owner_lookup() { + let (_temp, mut state, mut clients, files) = fixture(); + state.issuer_project = Some(state.project.join("missing-owner")); + let scopes = clients.clients[0].scopes.clone(); + clients.clients[0] + .grants + .push(config::LocalPermissionGrant { + audience: None, + scopes, + }); + let error = initialize(&state.root(), &state, &clients, &files) + .unwrap_err() + .to_string(); + assert!( + error.contains("owner-only machine client grants"), + "{error}" + ); +} + #[test] fn exchange_clients_and_connections_name_each_other() { // The resource server's per-client assertion-issuer rule is derived from @@ -1143,6 +1465,7 @@ fn rehearsal_binding_still_resolves_each_journey_step_despite_an_unbound_client( allow_human_fixture: false, scopes: vec!["registry:generic:introspect".into()], claims: BTreeMap::new(), + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }); @@ -1372,6 +1695,7 @@ fn rehearsal_tokens_request_only_the_exact_fixture_scope_subset() { allow_human_fixture: true, scopes: vec!["casework:supervisor".into(), "starter:reviewer".into()], claims: BTreeMap::new(), + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }; @@ -1399,6 +1723,7 @@ fn rehearsal_tokens_select_distinct_declared_purposes_on_one_logical_client() { json!(["record-change", "record-read"]), ), ]), + grants: Vec::new(), test_bindings: Vec::new(), assertion_key_ref: None, }; @@ -2960,6 +3285,39 @@ fn export_client_copies_a_stopped_retained_pair_and_retries_without_state_change private::check(&export_args(&state).assertion_key_file, false).unwrap(); } +#[test] +fn export_client_reports_the_registered_external_resource() { + let (_temp, state, mut clients, files) = fixture(); + let resource = "urn:example:messaging"; + let client = clients + .clients + .iter_mut() + .find(|client| client.id == "source") + .unwrap(); + client.access_profiles.clear(); + client.scopes = vec!["messaging:send".into()]; + clients.issuer.resources.push(config::IssuerResource { + audience: resource.into(), + scopes: vec!["messaging:send".into()], + }); + clients + .issuer + .client_resources + .insert("source".into(), resource.into()); + config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&clients).unwrap().into_bytes(), + ) + .unwrap(); + initialize(&state.root(), &state, &clients, &files).unwrap(); + let report = export_client::run(export_args(&state)).unwrap(); + assert_eq!(report["resource"], resource); + assert_eq!(report["audience"], resource); + assert_eq!(report["clientAssertionAudience"], state.issuer_origin()); + assert_ne!(report["resource"], state.audience()); + assert!(!report.to_string().contains("\"d\"")); +} + #[test] fn export_client_refuses_missing_session_client_and_incomplete_credentials_before_output() { let (_temp, state, clients, files) = fixture(); @@ -4359,6 +4717,178 @@ fn spatial_prerequisites_provision_the_bbox_role_each_runtime_file_serves_with() } } +#[test] +fn local_task_grant_status_uses_only_the_registered_status_client_and_resource() { + for external_resource in [None, Some("urn:example:casework")] { + let (_temp, state, mut clients, files) = fixture(); + let client = clients + .clients + .iter_mut() + .find(|client| client.id == "source") + .unwrap(); + client.access_profiles.clear(); + client.scopes = vec!["casework:grants:status".into()]; + clients.task_grant_status.insert( + "casework".into(), + config::LocalTaskGrantStatus { + source_issuer: registry_platform_yaml::ExternalId::new( + "https://casework.local.example", + ) + .unwrap(), + base_url: "http://127.0.0.1:18096/".into(), + client: "source".into(), + }, + ); + if let Some(resource) = external_resource { + clients.issuer.resources.push(config::IssuerResource { + audience: resource.into(), + scopes: vec!["casework:grants:status".into()], + }); + clients + .issuer + .client_resources + .insert("source".into(), resource.into()); + } + config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&clients).unwrap().into_bytes(), + ) + .unwrap(); + initialize(&state.root(), &state, &clients, &files).unwrap(); + let key = "task-status-casework-client-assertion-key"; + assert_eq!( + fs::read(state.root().join("secrets").join(key)).unwrap(), + fs::read(state.root().join("credentials/source/assertion-key.jwk")).unwrap() + ); + private::check(&state.root().join("secrets").join(key), false).unwrap(); + for test in [true, false] { + if !test { + private::directory(&state.root().join("build")).unwrap(); + private::directory(&state.root().join("build/package")).unwrap(); + config::runtime(&state.root(), &state, &clients, false).unwrap(); + } + let file = state.root().join(if test { + "runtime-test.yaml" + } else { + "runtime.yaml" + }); + let runtime: Value = serde_norway::from_slice(&fs::read(&file).unwrap()).unwrap(); + let status = &runtime["taskGrantStatus"][0]; + assert_eq!(runtime["taskGrantStatus"].as_array().unwrap().len(), 1); + assert_eq!(status["sourceIssuer"], "https://casework.local.example"); + assert_eq!(status["baseUrl"], "http://127.0.0.1:18096/"); + assert_eq!(status["clientId"], "source"); + assert_eq!( + status["tokenEndpoint"], + "http://127.0.0.1:8095/oauth2/token" + ); + assert_eq!(status["clientAssertionAudience"], "http://127.0.0.1:8095"); + assert_eq!( + status["caseworkResource"], + external_resource + .map(str::to_owned) + .unwrap_or_else(|| state.audience()) + ); + assert_eq!(status["privateKeyRef"], format!("secret:file/{key}")); + assert!(!runtime["authentication"]["oidc"]["allowedClients"] + .as_array() + .unwrap() + .contains(&json!("source"))); + assert!(!runtime.to_string().contains("\"d\"")); + registry_breg::runtime_config::load_runtime_config(&file).unwrap(); + } + } +} + +#[test] +fn local_task_grant_status_refuses_wider_authority_duplicate_sources_and_unsafe_endpoints() { + let (_temp, _state, mut clients, _files) = fixture(); + let client = clients + .clients + .iter_mut() + .find(|client| client.id == "source") + .unwrap(); + client.access_profiles.clear(); + client.scopes = vec!["casework:grants:status".into()]; + clients.task_grant_status.insert( + "casework".into(), + config::LocalTaskGrantStatus { + source_issuer: registry_platform_yaml::ExternalId::new( + "https://casework.local.example", + ) + .unwrap(), + base_url: "http://127.0.0.1:18096/".into(), + client: "source".into(), + }, + ); + let valid = serde_json::to_value(&clients).unwrap(); + for base in [ + "https://casework.example/", + "http://localhost:18096/", + "http://127.0.0.1/", + "http://127.0.0.1:0/", + "http://secret@127.0.0.1:18096/", + "http://127.0.0.1:18096/?authority=other", + ] { + let mut invalid = valid.clone(); + invalid["taskGrantStatus"]["casework"]["baseUrl"] = json!(base); + assert!(config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&invalid).unwrap().into_bytes() + ) + .is_err()); + } + for (field, value) in [ + ( + "scopes", + json!(["casework:grants:status", "casework:grants:assert"]), + ), + ("scopes", json!(["casework:grants:assert"])), + ("accessProfiles", json!(["operator"])), + ("allowBregAccess", json!(true)), + ("claims", json!({"registry_actor_kind":"human"})), + ] { + let mut invalid = valid.clone(); + invalid["clients"][1][field] = value; + assert!(config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&invalid).unwrap().into_bytes() + ) + .is_err()); + } + for (field, value) in [ + ("client", json!("missing")), + ("sourceIssuer", json!("invalid issuer")), + ("scopes", json!(["casework:grants:status"])), + ] { + let mut invalid = valid.clone(); + invalid["taskGrantStatus"]["casework"][field] = value; + assert!(config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&invalid).unwrap().into_bytes() + ) + .is_err()); + } + let mut duplicate = valid.clone(); + duplicate["taskGrantStatus"]["other"] = duplicate["taskGrantStatus"]["casework"].clone(); + assert!(config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&duplicate).unwrap().into_bytes() + ) + .is_err()); + let mut oversized = valid.clone(); + for index in 1..9 { + let mut binding = valid["taskGrantStatus"]["casework"].clone(); + binding["sourceIssuer"] = json!(format!("https://casework-{index}.example")); + oversized["taskGrantStatus"][format!("casework-{index}")] = binding; + } + assert!(config::clients( + "dev-clients.yaml", + &serde_norway::to_string(&oversized).unwrap().into_bytes() + ) + .is_err()); +} + const CLIENTS_HEADER: &str = "apiVersion: id.registrystack.org/formats/breg/dev-clients/v1alpha1\nkind: BRegDevClients\n"; diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index cb19907381..50bceb1bcd 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -603,6 +603,7 @@ fn start_issuer( .token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: authority_server.clone(), assertion_scope: "casework:grants:assert".into(), + ordinary_resource_permissions: Vec::new(), }); let evidence_client = description .machine_clients @@ -612,6 +613,7 @@ fn start_issuer( evidence_client.token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: authority_server, assertion_scope: "casework:grants:assert".into(), + ordinary_resource_permissions: Vec::new(), }); // These static sentinels make the test prove that the exchange copied the // authority-signed values. A token built from registered client attributes diff --git a/crates/registry-caseworkctl/src/dev/config.rs b/crates/registry-caseworkctl/src/dev/config.rs index c668ac6500..e7a02f955d 100644 --- a/crates/registry-caseworkctl/src/dev/config.rs +++ b/crates/registry-caseworkctl/src/dev/config.rs @@ -218,7 +218,11 @@ pub(super) fn borrow_client( let registered = clients["clients"] .as_array() .and_then(|entries| entries.iter().find(|entry| entry["id"] == id)) - .with_context(|| format!("shared issuer owner has no registration for {id}"))?; + .ok_or_else(|| { + super::DevFailure::registration(anyhow::anyhow!( + "shared issuer owner has no registration for {id}" + )) + })?; let owner_resource = clients["issuer"]["clientResources"][id] .as_str() .map(str::to_owned) @@ -232,7 +236,10 @@ pub(super) fn borrow_client( || registered["claims"] != *claims || owner_resource != resource { - bail!("shared issuer registration differs from Casework client {id}"); + return Err(super::DevFailure::registration(anyhow::anyhow!( + "shared issuer registration differs from Casework client {id}" + )) + .into()); } // An exchange client may present any assertion authority the shared issuer // trusts, and this session states a pairing only for the task exchange @@ -245,7 +252,10 @@ pub(super) fn borrow_client( .is_some_and(|ids| ids.iter().any(|entry| entry == id)) != task_exchange { - bail!("shared issuer owner and Casework disagree on exchange client {id}"); + return Err(super::DevFailure::registration(anyhow::anyhow!( + "shared issuer owner and Casework disagree on exchange client {id}" + )) + .into()); } let source = owner_root.join("credentials").join(id); let client_id = Zeroizing::new(private::read(&source.join("client-id"), super::MAX_BYTES)?); @@ -255,7 +265,10 @@ pub(super) fn borrow_client( )?); let public = private::read(&source.join("public.jwk"), 4096)?; if client_id.as_slice() != id.as_bytes() { - bail!("shared issuer client ID differs from {id}"); + return Err(super::DevFailure::registration(anyhow::anyhow!( + "shared issuer client ID differs from {id}" + )) + .into()); } let jwk: Value = serde_json::from_slice(&key)?; let public_jwk: Value = serde_json::from_slice(&public)?; @@ -765,7 +778,13 @@ pub(super) fn require_stable_borrowed_principals(policy: &CaseworkProject) -> Re .iter() .any(|profile| profile.principal_claim == "sub") { - bail!("the shared BREG issuer requires explicit stable principal claims; principalClaim sub resolves to the owner's subject, not the Casework local subject"); + return Err(super::DevFailure::BorrowedPrincipalConfiguration { + source: anyhow::anyhow!( + "principalClaim sub resolves to the owner's subject, not the Casework local subject" + ) + .into(), + } + .into()); } Ok(()) } diff --git a/crates/registry-caseworkctl/src/dev/integrations.rs b/crates/registry-caseworkctl/src/dev/integrations.rs index 255c2ee2b0..4b09be2efd 100644 --- a/crates/registry-caseworkctl/src/dev/integrations.rs +++ b/crates/registry-caseworkctl/src/dev/integrations.rs @@ -443,7 +443,10 @@ impl Integrations { .map(|instance| local::agent_id(instance, &template.client)) .unwrap_or_else(|| config::principal(&template.client)); if template.agent.subject != expected { - bail!("task template agent must use its exact local issuer identity"); + return Err(super::DevFailure::integration( + "task template agent must use its exact local issuer identity", + ) + .into()); } } for binding in self.sources.values() { @@ -459,7 +462,7 @@ impl Integrations { }) }); if reader.is_none() { - bail!("source reader references must select the same generated service client with exactly the binding resource and scopes"); + return Err(super::DevFailure::integration("source reader references must select the same generated service client with exactly the binding resource and scopes").into()); } if binding.token_endpoint != format!("{}/oauth2/token", state.issuer_origin()) || binding.client_assertion_audience.as_deref() @@ -470,7 +473,7 @@ impl Integrations { .as_ref() .is_none_or(|scopes| scopes.is_empty()) { - bail!("source bindings must explicitly use the session issuer token endpoint/audience, integrations.resource and reader scopes; configure BREG to admit that resource with independent human profiles/scopes/clients"); + return Err(super::DevFailure::integration("source bindings must explicitly use the session issuer token endpoint/audience, integrations.resource and reader scopes; configure BREG to admit that resource with independent human profiles/scopes/clients").into()); } } if let Some(authority) = &self.task_authority { @@ -534,7 +537,7 @@ impl Integrations { }) }); if !registered { - bail!("shared issuer owner must pre-register the exact Casework task authority connection"); + return Err(super::DevFailure::SharedTaskAuthority { source: anyhow::anyhow!("shared issuer owner must pre-register the exact Casework task authority connection").into() }.into()); } } if !self.browser_clients.is_empty() { @@ -679,6 +682,7 @@ impl Integrations { token_exchange: client.task_exchange.then(|| TokenExchangeClient { assertion_resource_server_id: server, assertion_scope: "casework:grants:assert".into(), + ordinary_resource_permissions: Vec::new(), }), }); } @@ -765,13 +769,31 @@ pub(super) fn validate_bindings(root: &Path, project: &Path) -> Result<()> { let secrets = registry_casework::secret_resolver(&config)?; let policy = crate::project::load_and_check_policy(project)?; for source in &policy.sources { - config.sources.get(&source.id).ok_or_else(||anyhow::anyhow!("a declared source binding is missing"))? + config + .sources + .get(&source.id) + .ok_or_else(|| anyhow::anyhow!("a declared source binding is missing"))? .build_adapter(source, project, &secrets) - .map_err(|_| anyhow::anyhow!("local source binding is invalid; check its exact BREG event source, reader profile, source description and credential references before starting services"))?; + .map_err(source_binding_failure)?; } Ok(()) } +/// Only a typed invalid adapter contract is an authoring refusal. Operational +/// adapter failures keep their type for the owning CLI classifier. +pub(super) fn source_binding_failure( + error: registry_casework_core::SourceAdapterError, +) -> anyhow::Error { + if error == registry_casework_core::SourceAdapterError::Invalid { + super::DevFailure::SourceBindingConfiguration { + source: Box::new(error), + } + .into() + } else { + error.into() + } +} + /// Report one service or browser client ID: its grammar, the reserved /// issuer ID, and a repeat of any client ID before it. fn client_id<'a>( diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 831dce7f55..f96f50e61c 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -3534,6 +3534,26 @@ fn retained_audit(state: &State) -> Result<()> { /// hold absolute paths or a supervisor cause, and are never reported. #[derive(Debug)] pub(crate) enum DevFailure { + /// A known authored integration contract refused before service launch. + IntegrationConfiguration { + source: Box, + }, + /// The owner's declared client identity does not match this workload. + SharedClientRegistration { + source: Box, + }, + /// The shared issuer has not paired this task authority with its clients. + SharedTaskAuthority { + source: Box, + }, + /// A configured source cannot satisfy the maintained adapter contract. + SourceBindingConfiguration { + source: Box, + }, + /// Borrowed local subjects cannot stand in for stable Casework principals. + BorrowedPrincipalConfiguration { + source: Box, + }, /// A loopback port the session needs is bound by another process. PortOccupied { port: u16, source: std::io::Error }, /// The authored inputs changed while the session retains records or @@ -3559,6 +3579,29 @@ pub(crate) enum DevFailure { const AUDIT_DIRECTORY: &str = ".casework/dev/audit"; impl DevFailure { + pub(super) fn integration(reason: &'static str) -> Self { + Self::IntegrationConfiguration { + source: anyhow::anyhow!(reason).into(), + } + } + + pub(super) fn registration(source: anyhow::Error) -> Self { + Self::SharedClientRegistration { + source: source.into(), + } + } + + pub(crate) fn is_configuration_refusal(&self) -> bool { + matches!( + self, + Self::IntegrationConfiguration { .. } + | Self::SharedClientRegistration { .. } + | Self::SharedTaskAuthority { .. } + | Self::SourceBindingConfiguration { .. } + | Self::BorrowedPrincipalConfiguration { .. } + ) + } + /// Classify an audit destination refusal. Invalid data in the retained /// stream is the refusal the shared writer gives a file it did not write. fn audit(error: impl std::error::Error + Send + Sync + 'static) -> Self { @@ -3583,6 +3626,36 @@ impl DevFailure { /// fixed text and non-secret parameters. pub(crate) fn diagnostic(&self) -> (&'static str, String, String, String) { match self { + Self::IntegrationConfiguration { .. } => ( + "caseworkctl.dev.integrations-invalid", + "dev-clients.yaml:/integrations".to_owned(), + "The local source or task integration does not match the authored Casework policy and session.".to_owned(), + "Check the declared sources, reader credential references, resources, scopes, issuer endpoint and task templates in dev-clients.yaml against casework.yaml, then retry.".to_owned(), + ), + Self::SharedClientRegistration { .. } => ( + "caseworkctl.dev.shared-client-mismatch", + "dev-clients.yaml:/clients".to_owned(), + "A Casework teaching or service client does not match its shared issuer registration.".to_owned(), + "Compare clients and integrations.serviceClients with the BReg issuer owner's dev-clients.yaml: client IDs, scopes, explicit actor and other claims, resource and task-exchange registration must match exactly. Correct the declarations before retrying; do not replace retained keys.".to_owned(), + ), + Self::SharedTaskAuthority { .. } => ( + "caseworkctl.dev.shared-task-authority-mismatch", + "dev-clients.yaml:/integrations/taskAuthority".to_owned(), + "The shared issuer has not registered the exact Casework task authority and client pairing.".to_owned(), + "Match the owner's exchangeIssuers entry to integrations.taskAuthority: issuer, public JWKS listener, institutional_grant mapping and exact taskExchange client list, then retry.".to_owned(), + ), + Self::SourceBindingConfiguration { .. } => ( + "caseworkctl.dev.source-binding-invalid", + "dev-clients.yaml:/integrations/sources".to_owned(), + "A declared source binding cannot construct its maintained adapter.".to_owned(), + "Check the exact BReg event source, reader profile, imported source description and reader credential references against casework.yaml and the source runtime, then retry.".to_owned(), + ), + Self::BorrowedPrincipalConfiguration { .. } => ( + "caseworkctl.dev.borrowed-principal-invalid", + "casework.yaml:/accessProfiles/principalClaim".to_owned(), + "A borrowed issuer session requires explicit stable Casework principal claims.".to_owned(), + "Use a stable principalClaim such as registry_principal in every Casework access profile and explicitly register its matching claim for each client in both projects' dev-clients.yaml. Do not use sub: it identifies the BReg owner's subject, not the Casework local principal.".to_owned(), + ), Self::PortOccupied { port, .. } => ( "caseworkctl.dev.port-occupied", format!("dev:/ports/{port}"), @@ -3629,6 +3702,11 @@ impl DevFailure { impl std::fmt::Display for DevFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::IntegrationConfiguration { .. } => f.write_str("local integration configuration does not match the policy or session"), + Self::SharedClientRegistration { .. } => f.write_str("shared issuer registration differs from the Casework client"), + Self::SharedTaskAuthority { .. } => f.write_str("shared issuer owner must pre-register the exact Casework task authority connection"), + Self::SourceBindingConfiguration { .. } => f.write_str("local source binding does not satisfy the maintained adapter contract"), + Self::BorrowedPrincipalConfiguration { .. } => f.write_str("the shared BREG issuer requires explicit stable Casework principal claims"), Self::PortOccupied { .. } => f.write_str( "a requested local port is already occupied; stop its owner or choose other ports", ), @@ -3655,6 +3733,11 @@ impl std::fmt::Display for DevFailure { impl std::error::Error for DevFailure { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { + Self::IntegrationConfiguration { source } + | Self::SharedClientRegistration { source } + | Self::SharedTaskAuthority { source } + | Self::SourceBindingConfiguration { source } + | Self::BorrowedPrincipalConfiguration { source } => Some(source.as_ref()), Self::PortOccupied { source, .. } => Some(source), Self::AuditFormatUnsupported { source } | Self::AuditUnavailable { source } => { Some(source.as_ref()) diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index aa717f167f..ec04ecdf5e 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -1050,9 +1050,17 @@ fn borrowed_source_mode_is_explicit_pinned_and_refuses_session_qualified_subject &source, &BTreeMap::new(), ) - .unwrap_err() - .to_string(); - assert!(refusal.contains("principalClaim sub"), "{refusal}"); + .unwrap_err(); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &refusal); + assert_eq!(exit, crate::DOMAIN_REFUSAL_EXIT); + assert_eq!( + diagnostic["code"], + "caseworkctl.dev.borrowed-principal-invalid" + ); + assert_eq!( + diagnostic["path"], + "casework.yaml:/accessProfiles/principalClaim" + ); } #[test] @@ -3510,6 +3518,15 @@ fn explicit_local_integrations_render_only_governed_authority_and_bind_the_sourc assert_eq!(operator["sources"]["source"]["resource"], state.audience()); assert_eq!(description.exchange_issuers.len(), 1); let mut wrong = integrations.clone(); + wrong.resource = "private-preflight-canary".into(); + let findings = wrong.validate(&clients, &policy).unwrap_err(); + let finding = findings + .iter() + .find(|finding| finding.code == "casework.dev-clients.invalid-resource") + .expect("the resource refusal is field-addressed"); + assert_eq!(finding.pointer, "/integrations/resource"); + assert!(!format!("{findings:?}").contains("private-preflight-canary")); + let mut wrong = integrations.clone(); wrong.sources.get_mut("source").unwrap().resource = Some("urn:other".into()); assert!(wrong.validate_session(&state, &policy).is_err()); let mut wrong = integrations.clone(); @@ -3692,16 +3709,24 @@ fn borrowed_casework_client_requires_exact_owner_claims_scopes_and_resource() { private::read(&target.join("assertion-key.jwk"), MAX_BYTES).unwrap(), private::read(&source.join("assertion-key.jwk"), MAX_BYTES).unwrap() ); - assert!(config::borrow_client( + let error = config::borrow_client( &project.join("other"), &state, "staff", &scopes, - &json!({"registry_actor_kind":"service"}), + &json!({"registry_actor_kind":"service", "private":"private-preflight-canary"}), &resource, - false + false, ) - .is_err()); + .unwrap_err(); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &error); + assert_eq!(exit, crate::DOMAIN_REFUSAL_EXIT); + assert_eq!(diagnostic["code"], "caseworkctl.dev.shared-client-mismatch"); + assert!(diagnostic["suggestedAction"] + .as_str() + .unwrap() + .contains("explicit actor")); + assert!(!diagnostic.to_string().contains("private-preflight-canary")); } #[test] @@ -3762,9 +3787,11 @@ fn a_borrowed_client_the_owner_registered_for_exchange_must_declare_it() { &resource, false, ) - .unwrap_err() - .to_string(); - assert!(refusal.contains("exchange client"), "{refusal}"); + .unwrap_err(); + assert!(format!("{refusal:#}").contains("exchange client")); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &refusal); + assert_eq!(exit, crate::DOMAIN_REFUSAL_EXIT); + assert_eq!(diagnostic["code"], "caseworkctl.dev.shared-client-mismatch"); let declared = project.join("declared"); private::directory(&declared).unwrap(); @@ -3872,9 +3899,17 @@ fn task_template_subject_follows_the_actual_local_issuer_owner() { sub_profile.access_profiles[0].principal_claim = "sub".to_owned(); let refusal = integrations .validate_session(&borrowed, &sub_profile) - .unwrap_err() - .to_string(); - assert!(refusal.contains("principalClaim sub"), "{refusal}"); + .unwrap_err(); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &refusal); + assert_eq!(exit, crate::DOMAIN_REFUSAL_EXIT); + assert_eq!( + diagnostic["code"], + "caseworkctl.dev.borrowed-principal-invalid" + ); + assert_eq!( + diagnostic["path"], + "casework.yaml:/accessProfiles/principalClaim" + ); let standalone = session(&project); assert!(integrations.validate_session(&standalone, &policy).is_err()); @@ -4014,8 +4049,7 @@ fn a_borrowed_task_authority_connection_pairs_the_task_exchange_clients() { .unwrap(); let refusal = integrations .prepare(&root, &state, None, &policy) - .unwrap_err() - .to_string(); + .unwrap_err(); // The exact pairing passes this check and stops at the borrowed client // registration the owner has not written, which is the next check. let expected = if paired == json!(["task-agent"]) { @@ -4023,7 +4057,19 @@ fn a_borrowed_task_authority_connection_pairs_the_task_exchange_clients() { } else { "shared issuer owner must pre-register the exact Casework task authority connection" }; - assert!(refusal.contains(expected), "{paired}: {refusal}"); + assert!( + format!("{refusal:#}").contains(expected), + "{paired}: {refusal}" + ); + let (_, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &refusal); + assert_eq!( + diagnostic["code"], + if paired == json!(["task-agent"]) { + "caseworkctl.dev.shared-client-mismatch" + } else { + "caseworkctl.dev.shared-task-authority-mismatch" + } + ); } } @@ -5010,3 +5056,46 @@ fn a_port_bind_refused_for_another_reason_is_not_reported_as_occupied() { "caseworkctl.dev.port-occupied" ); } + +#[test] +fn borrowed_principals_and_invalid_source_bindings_have_specific_safe_diagnostics() { + let workspace = crate::canonical_tempdir(); + let project = standalone(workspace.path()); + let mut policy = crate::project::load_and_check_policy(&project).unwrap(); + for profile in &mut policy.access_profiles { + profile.principal_claim = "registry_principal".into(); + } + config::require_stable_borrowed_principals(&policy).unwrap(); + policy.access_profiles[0].principal_claim = "sub".into(); + let error = config::require_stable_borrowed_principals(&policy).unwrap_err(); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &error); + assert_eq!(exit, crate::DOMAIN_REFUSAL_EXIT); + assert_eq!( + diagnostic["code"], + "caseworkctl.dev.borrowed-principal-invalid" + ); + assert_eq!( + diagnostic["path"], + "casework.yaml:/accessProfiles/principalClaim" + ); + assert!(diagnostic["suggestedAction"] + .as_str() + .unwrap() + .contains("Do not use sub")); + + let error = + integrations::source_binding_failure(registry_casework_core::SourceAdapterError::Invalid); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &error); + assert_eq!(exit, crate::DOMAIN_REFUSAL_EXIT); + assert_eq!(diagnostic["code"], "caseworkctl.dev.source-binding-invalid"); + assert_eq!(diagnostic["path"], "dev-clients.yaml:/integrations/sources"); + let error = integrations::source_binding_failure( + registry_casework_core::SourceAdapterError::Unavailable, + ); + assert!(error + .downcast_ref::() + .is_some()); + let (exit, diagnostic) = crate::classify_failure(crate::CommandKind::Operational, &error); + assert_eq!(exit, crate::OPERATIONAL_FAILURE_EXIT); + assert_eq!(diagnostic["code"], "caseworkctl.operational-failure"); +} diff --git a/crates/registry-caseworkctl/src/lib.rs b/crates/registry-caseworkctl/src/lib.rs index 7662ed95cd..b4fe7e37b8 100644 --- a/crates/registry-caseworkctl/src/lib.rs +++ b/crates/registry-caseworkctl/src/lib.rs @@ -722,7 +722,11 @@ fn classify_failure(kind: CommandKind, error: &anyhow::Error) -> (u8, Value) { { let (code, path, message, action) = failure.diagnostic(); return ( - OPERATIONAL_FAILURE_EXIT, + if failure.is_configuration_refusal() { + DOMAIN_REFUSAL_EXIT + } else { + OPERATIONAL_FAILURE_EXIT + }, json!({ "severity":"error", "code":code, "artifact":"dev-session", "path":path, "message":message, "suggestedAction":action @@ -2440,6 +2444,61 @@ mod tests { ); } + #[test] + fn early_dev_configuration_refusals_are_actionable_and_hide_private_causes() { + for (failure, code) in [ + ( + dev::DevFailure::IntegrationConfiguration { + source: anyhow::anyhow!("private-preflight-canary").into(), + }, + "caseworkctl.dev.integrations-invalid", + ), + ( + dev::DevFailure::SharedClientRegistration { + source: anyhow::anyhow!("private-preflight-canary").into(), + }, + "caseworkctl.dev.shared-client-mismatch", + ), + ( + dev::DevFailure::SharedTaskAuthority { + source: anyhow::anyhow!("private-preflight-canary").into(), + }, + "caseworkctl.dev.shared-task-authority-mismatch", + ), + ( + dev::DevFailure::SourceBindingConfiguration { + source: anyhow::anyhow!("private-preflight-canary").into(), + }, + "caseworkctl.dev.source-binding-invalid", + ), + ( + dev::DevFailure::BorrowedPrincipalConfiguration { + source: anyhow::anyhow!("private-preflight-canary").into(), + }, + "caseworkctl.dev.borrowed-principal-invalid", + ), + ] { + let error = anyhow::Error::new(failure).context("private-preflight-canary"); + assert!( + format!("{error:#}").contains("private-preflight-canary"), + "private cause retained" + ); + let (exit, diagnostic) = classify_failure(CommandKind::Operational, &error); + assert_eq!(exit, DOMAIN_REFUSAL_EXIT); + assert_eq!(diagnostic["code"], code); + assert_eq!(diagnostic["artifact"], "dev-session"); + let path = diagnostic["path"].as_str().unwrap(); + assert!(path.starts_with("dev-clients.yaml:/") || path.starts_with("casework.yaml:/")); + assert!(!diagnostic["suggestedAction"].as_str().unwrap().is_empty()); + assert!(!diagnostic.to_string().contains("private-preflight-canary")); + } + let error = anyhow::anyhow!("private-network-canary"); + let (exit, diagnostic) = classify_failure(CommandKind::Operational, &error); + assert_eq!(exit, OPERATIONAL_FAILURE_EXIT); + assert_eq!(diagnostic["code"], "caseworkctl.operational-failure"); + assert!(!diagnostic.to_string().contains("private-network-canary")); + } + #[test] fn operator_refusals_keep_safe_recovery_reasons_without_exposing_store_failures() { let pending = anyhow::Error::new(StoreError::AttemptPending) diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index d298b431a4..a9f6e5f8f5 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -2317,6 +2317,7 @@ fn start_stock_token_issuer_with_authorities(authority_issuers: &[&str]) -> Stoc token_exchange: Some(TokenExchangeClient { assertion_resource_server_id: TASK_AUTHORITY_RESOURCE_ID.to_owned(), assertion_scope: TASK_BOOTSTRAP_SCOPE.to_owned(), + ordinary_resource_permissions: Vec::new(), }), }); for (authority_issuer, (id, name)) in authority_issuers.iter().zip(AUTHORITY_CONNECTIONS) { diff --git a/crates/registry-thunderid-tooling/README.md b/crates/registry-thunderid-tooling/README.md index 38f0d38f26..d949a4edfa 100644 --- a/crates/registry-thunderid-tooling/README.md +++ b/crates/registry-thunderid-tooling/README.md @@ -54,8 +54,17 @@ resources. A machine client's `token_exchange: Some(TokenExchangeClient { ... })` enables `client_credentials` and RFC 8693 token exchange on the same registered key. -Every role assigned to that client must contain only its configured authority -assertion lookup permission. Client credentials uses `clientConfig`; exchange +By default, every role assigned to an institutional exchange client must contain +only its configured authority assertion lookup permission. A tooling owner may +explicitly set `ordinary_resource_permissions` to grant exact registered scopes +on up to seven other resources, with at most 32 distinct scopes per resource. +Every assigned permission must match the bootstrap permission or that explicit +allowlist. Unknown resources/scopes, wildcards, missing bootstrap permissions +and bootstrap-resource expansion are refused. The empty allowlist preserves +bootstrap-only validation. BReg dev derives this opt-in only from authored +machine-client `grants`; it changes no default token request or exported scope. +Ordinary tokens carry no task grant and cannot authorize a guarded mutation. +Client credentials uses `clientConfig`; exchange uses `userConfig`, whose closed allowlist carries the signed task fields, including JSON identity selectors, product-specific bounds and numeric expiry. Static schemas and client attributes cannot contain `registry_grant_*`, diff --git a/crates/registry-thunderid-tooling/examples/contextual-exchange.rs b/crates/registry-thunderid-tooling/examples/contextual-exchange.rs index 917c08953c..70a7965d97 100644 --- a/crates/registry-thunderid-tooling/examples/contextual-exchange.rs +++ b/crates/registry-thunderid-tooling/examples/contextual-exchange.rs @@ -219,11 +219,13 @@ fn description( TokenExchangeClient { assertion_resource_server_id: AUTHORITY_ID.into(), assertion_scope: BOOTSTRAP_SCOPE.into(), + ordinary_resource_permissions: Vec::new(), } } else { TokenExchangeClient { assertion_resource_server_id: "0197aaaa-0000-7000-8000-0000000000b1".into(), assertion_scope: TARGET_SCOPE.into(), + ordinary_resource_permissions: Vec::new(), } }), } diff --git a/crates/registry-thunderid-tooling/src/description.rs b/crates/registry-thunderid-tooling/src/description.rs index 4b50dd7813..975de1d709 100644 --- a/crates/registry-thunderid-tooling/src/description.rs +++ b/crates/registry-thunderid-tooling/src/description.rs @@ -156,6 +156,10 @@ pub struct MachineClient { pub struct TokenExchangeClient { pub assertion_resource_server_id: String, pub assertion_scope: String, + /// Explicit ordinary permissions on other registered resources. Empty + /// retains institutional bootstrap-only role validation. These permissions + /// add no signed grant attributes or exchange authority. + pub ordinary_resource_permissions: Vec<(String, Vec)>, } /// One external authority, rendered as a native exchange-only OIDC connection. @@ -288,6 +292,30 @@ fn valid_permission(value: &str) -> bool { registry_platform_httputil::valid_scope_token(value) } +fn registered_permission(server: &ResourceServer, scope: &str) -> bool { + server.resources.iter().any(|resource| { + let mut path = vec![resource.handle.as_str()]; + let mut parent = resource.parent.as_deref(); + while let Some(handle) = parent { + if path.len() >= server.resources.len() { + return false; + } + let Some(ancestor) = server.resources.iter().find(|entry| entry.handle == handle) + else { + return false; + }; + path.push(ancestor.handle.as_str()); + parent = ancestor.parent.as_deref(); + } + path.reverse(); + let prefix = path.join(":"); + resource + .actions + .iter() + .any(|action| scope == format!("{prefix}:{}", action.handle)) + }) +} + fn private_file_name(path: &std::path::Path) -> bool { !path.as_os_str().is_empty() && !path.is_absolute() @@ -588,22 +616,55 @@ impl IssuerDescription { .flat_map(|role| &role.permissions) .collect(); let first_party = first_party_clients.contains(&client.client_id); - let exact_bootstrap = !permissions.is_empty() + let mut ordinary_servers = BTreeSet::new(); + if exchange.ordinary_resource_permissions.len() > 7 + || exchange + .ordinary_resource_permissions + .iter() + .any(|(id, scopes)| { + id == &exchange.assertion_resource_server_id + || !ordinary_servers.insert(id) + || scopes.is_empty() + || scopes.len() > 32 + || scopes.iter().collect::>().len() != scopes.len() + || self + .resource_servers + .iter() + .find(|server| &server.id == id) + .is_none_or(|server| { + scopes.iter().any(|scope| { + !valid_permission(scope) + || scope.contains('*') + || !registered_permission(server, scope) + }) + }) + }) + { + return refuse("ordinary exchange-client permissions require distinct non-bootstrap registered resources and exact bounded scopes"); + } + let bootstrap_present = permissions.iter().any(|(server, scopes)| { + server == &exchange.assertion_resource_server_id + && scopes.contains(&exchange.assertion_scope) + }); + let bounded_institutional_permissions = bootstrap_present && permissions.iter().all(|(server, scopes)| { - server == &exchange.assertion_resource_server_id - && !scopes.is_empty() - && scopes - .iter() - .all(|scope| scope == &exchange.assertion_scope) + !scopes.is_empty() + && scopes.iter().all(|scope| { + server == &exchange.assertion_resource_server_id + && scope == &exchange.assertion_scope + || exchange.ordinary_resource_permissions.iter().any( + |(id, allowed)| id == server && allowed.contains(scope), + ) + }) }); let authored_first_party_permission = first_party && permissions.iter().any(|(server, scopes)| { server == &exchange.assertion_resource_server_id && scopes.contains(&exchange.assertion_scope) }); - if !exact_bootstrap && !authored_first_party_permission { + if !bounded_institutional_permissions && !authored_first_party_permission { return refuse( - "exchange client permissions must contain the first-party bound or equal the institutional bootstrap bound", + "exchange client permissions must contain the first-party bound or match the institutional bootstrap and explicit ordinary bounds", ); } } @@ -798,6 +859,7 @@ mod tests { description.machine_clients[0].token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: description.resource_servers[0].id.clone(), assertion_scope: "evidence:invoke".into(), + ordinary_resource_permissions: Vec::new(), }); description } @@ -1027,6 +1089,93 @@ mod tests { assert!(description.validate().is_err()); } + fn ordinary_permission_description() -> IssuerDescription { + let mut description = exchange_description(); + crate::local::declare_resource( + &mut description, + "urn:synthetic:ordinary", + &["records:read".into(), "records:write".into()], + ) + .unwrap(); + let server = description.resource_servers.last().unwrap().id.clone(); + description.machine_clients[0] + .token_exchange + .as_mut() + .unwrap() + .ordinary_resource_permissions = vec![(server.clone(), vec!["records:read".into()])]; + description.roles[0] + .permissions + .push((server, vec!["records:read".into()])); + description + } + + #[test] + fn institutional_exchange_allows_only_explicit_exact_ordinary_permissions() { + let description = ordinary_permission_description(); + assert!(description.validate().is_ok()); + let mut without_opt_in = description.clone(); + without_opt_in.machine_clients[0] + .token_exchange + .as_mut() + .unwrap() + .ordinary_resource_permissions + .clear(); + assert!(without_opt_in.validate().is_err()); + let mut extra = description.clone(); + extra.roles[0].permissions[1].1.push("records:write".into()); + assert!(extra.validate().is_err()); + let mut extra_role = description.roles[0].clone(); + extra_role.id = "0197aaaa-0000-7000-8000-0000000000c2".into(); + extra_role.permissions = vec![( + description.resource_servers.last().unwrap().id.clone(), + vec!["records:write".into()], + )]; + let mut other_role = description.clone(); + other_role.roles.push(extra_role); + assert!(other_role.validate().is_err()); + let mut expanded_bootstrap = description.clone(); + expanded_bootstrap.roles[0].permissions[0] + .1 + .push("evidence:write".into()); + assert!(expanded_bootstrap.validate().is_err()); + let mut absent = description; + absent.roles[0].permissions.remove(0); + assert!(absent.validate().is_err()); + } + + #[test] + fn ordinary_allowlists_refuse_unknown_bootstrap_wildcard_duplicate_and_unbounded_grants() { + let base = ordinary_permission_description(); + let ordinary = base.resource_servers.last().unwrap().id.clone(); + let bootstrap = base.machine_clients[0] + .token_exchange + .as_ref() + .unwrap() + .assertion_resource_server_id + .clone(); + for grants in [ + vec![( + "0197aaaa-0000-7000-8000-0000000000ff".into(), + vec!["records:read".into()], + )], + vec![(ordinary.clone(), vec!["records:undeclared".into()])], + vec![(ordinary.clone(), vec!["records:*".into()])], + vec![(bootstrap, vec!["evidence:invoke".into()])], + vec![(ordinary.clone(), vec![])], + vec![(ordinary.clone(), vec!["records:read".into(); 2])], + vec![(ordinary.clone(), vec!["records:read".into()]); 8], + vec![(ordinary.clone(), vec!["records:read".into(); 33])], + ] { + let mut description = base.clone(); + description.machine_clients[0] + .token_exchange + .as_mut() + .unwrap() + .ordinary_resource_permissions = grants; + assert!(description.validate().is_err()); + } + } + #[test] fn first_party_exchange_preserves_authored_client_permissions() { let mut description = exchange_description(); diff --git a/crates/registry-thunderid-tooling/src/render.rs b/crates/registry-thunderid-tooling/src/render.rs index b2c6cd86c3..a2d90f8acc 100644 --- a/crates/registry-thunderid-tooling/src/render.rs +++ b/crates/registry-thunderid-tooling/src/render.rs @@ -780,95 +780,133 @@ mod tests { #[test] fn exchange_uses_native_user_config_and_unconditional_verified_issuer_mapping() { - use crate::description::{ExchangeIssuer, TokenExchangeClient, GRANT_ATTRIBUTES}; - let mut description = crate::testing::synthetic_description(); - description.compatibility_clients.clear(); - description.state_root = std::env::temp_dir().join(format!( - "registry-exchange-render-{}", - crate::container::random_urlsafe(16).unwrap() - )); - fs::create_dir(&description.state_root).unwrap(); - fs::set_permissions(&description.state_root, fs::Permissions::from_mode(0o700)).unwrap(); - description.exchange_issuers.push(ExchangeIssuer { - id: "0197aaaa-0000-7000-8000-0000000000d1".into(), - name: "Authority".into(), - issuer: "https://authority.example".into(), - jwks_endpoint: "https://authority.example/jwks".into(), - mapping: ExchangeMapping::InstitutionalGrant, - clients: vec![], - token_attributes: Default::default(), - }); - description.machine_clients[0].token_exchange = Some(TokenExchangeClient { - assertion_resource_server_id: description.resource_servers[0].id.clone(), - assertion_scope: "evidence:invoke".into(), - }); - let rendered = render(&description).unwrap(); - let agent = serde_yaml_parse( - &fs::read_to_string( - rendered - .bootstrap_dir - .join("agents/0197aaaa-0000-7000-8000-0000000000a1.yaml"), + for ordinary in [false, true] { + use crate::description::{ExchangeIssuer, TokenExchangeClient, GRANT_ATTRIBUTES}; + let mut description = crate::testing::synthetic_description(); + description.compatibility_clients.clear(); + description.state_root = std::env::temp_dir().join(format!( + "registry-exchange-render-{}", + crate::container::random_urlsafe(16).unwrap() + )); + fs::create_dir(&description.state_root).unwrap(); + fs::set_permissions(&description.state_root, fs::Permissions::from_mode(0o700)) + .unwrap(); + description.exchange_issuers.push(ExchangeIssuer { + id: "0197aaaa-0000-7000-8000-0000000000d1".into(), + name: "Authority".into(), + issuer: "https://authority.example".into(), + jwks_endpoint: "https://authority.example/jwks".into(), + mapping: ExchangeMapping::InstitutionalGrant, + clients: vec![], + token_attributes: Default::default(), + }); + description.machine_clients[0].token_exchange = Some(TokenExchangeClient { + assertion_resource_server_id: description.resource_servers[0].id.clone(), + assertion_scope: "evidence:invoke".into(), + ordinary_resource_permissions: Vec::new(), + }); + if ordinary { + crate::local::declare_resource( + &mut description, + "urn:synthetic:ordinary", + &["records:read".into()], + ) + .unwrap(); + let server = description.resource_servers.last().unwrap().id.clone(); + description.roles[0] + .permissions + .push((server.clone(), vec!["records:read".into()])); + description.machine_clients[0] + .token_exchange + .as_mut() + .unwrap() + .ordinary_resource_permissions = vec![(server, vec!["records:read".into()])]; + } + let rendered = render(&description).unwrap(); + let role: Value = serde_yaml_parse( + &fs::read_to_string( + rendered + .resources_dir + .join(format!("roles/{}.yaml", description.roles[0].id)), + ) + .unwrap(), ) - .unwrap(), - ) - .unwrap(); - let config = &agent["inboundAuthConfig"][0]["config"]; - assert_eq!( - config["grantTypes"], - json!([ - "client_credentials", - "urn:ietf:params:oauth:grant-type:token-exchange" - ]) - ); - // An exchange client admits the provenance claim beside the grant and - // requester attributes, so a resource server reading its token can tell - // which assertion authority signed the subject token it came from. - assert_eq!( - config["token"]["accessToken"]["userConfig"]["attributes"], - json!(GRANT_ATTRIBUTES + .unwrap(); + let expected: Vec = description.roles[0] + .permissions .iter() - .copied() - .chain(INSTITUTIONAL_REQUESTER_ATTRIBUTES) - .chain(["synthetic_tag", ASSERTION_ISSUER_CLAIM]) - .collect::>()) - ); - assert_eq!( - config["token"]["accessToken"]["clientConfig"]["attributes"], - json!(["synthetic_tag"]) - ); - let connection = serde_yaml_parse( - &fs::read_to_string( - rendered - .resources_dir - .join("connections/0197aaaa-0000-7000-8000-0000000000d1.yaml"), + .map(|(server, scopes)| json!({"resourceServerId":server,"permissions":scopes})) + .collect(); + assert_eq!(role["permissions"], json!(expected)); + + let agent = serde_yaml_parse( + &fs::read_to_string( + rendered + .bootstrap_dir + .join("agents/0197aaaa-0000-7000-8000-0000000000a1.yaml"), + ) + .unwrap(), ) - .unwrap(), - ) - .unwrap(); - assert_eq!(connection["resource_type"], "connection"); - assert_eq!(connection["tokenExchangeEnabled"], true); - assert_eq!( - connection["attributeConfiguration"]["user_type_resolution"], - json!({"default":"registry-exchange"}) - ); - assert_eq!( - connection["attributeConfiguration"]["user_type_attribute_mappings"][0]["attributes"], - json!([ - {"external_attribute":"iss","local_attribute":ASSERTION_ISSUER_CLAIM}, - {"external_attribute":"iss","local_attribute":"registry_grant_source_issuer"}, - {"external_attribute":"evidence_audience","local_attribute":"evidence_audience"}, - {"external_attribute":"evidence_tags","local_attribute":"evidence_tags"}, - ]) - ); - let schema: Value = serde_yaml_parse(&rendered.agent_type_document).unwrap(); - assert_eq!(schema["schema"]["evidence_audience"]["type"], "string"); - assert_eq!(schema["schema"]["evidence_tags"]["type"], "array"); - assert_eq!(schema["schema"]["evidence_tags"]["items"]["type"], "string"); - assert!( - render(&description).is_err(), - "a second render must not overwrite session resources" - ); - fs::remove_dir_all(&description.state_root).unwrap(); + .unwrap(); + let config = &agent["inboundAuthConfig"][0]["config"]; + assert_eq!( + config["grantTypes"], + json!([ + "client_credentials", + "urn:ietf:params:oauth:grant-type:token-exchange" + ]) + ); + // An exchange client admits the provenance claim beside the grant and + // requester attributes, so a resource server reading its token can tell + // which assertion authority signed the subject token it came from. + assert_eq!( + config["token"]["accessToken"]["userConfig"]["attributes"], + json!(GRANT_ATTRIBUTES + .iter() + .copied() + .chain(INSTITUTIONAL_REQUESTER_ATTRIBUTES) + .chain(["synthetic_tag", ASSERTION_ISSUER_CLAIM]) + .collect::>()) + ); + assert_eq!( + config["token"]["accessToken"]["clientConfig"]["attributes"], + json!(["synthetic_tag"]) + ); + let connection = serde_yaml_parse( + &fs::read_to_string( + rendered + .resources_dir + .join("connections/0197aaaa-0000-7000-8000-0000000000d1.yaml"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(connection["resource_type"], "connection"); + assert_eq!(connection["tokenExchangeEnabled"], true); + assert_eq!( + connection["attributeConfiguration"]["user_type_resolution"], + json!({"default":"registry-exchange"}) + ); + assert_eq!( + connection["attributeConfiguration"]["user_type_attribute_mappings"][0] + ["attributes"], + json!([ + {"external_attribute":"iss","local_attribute":ASSERTION_ISSUER_CLAIM}, + {"external_attribute":"iss","local_attribute":"registry_grant_source_issuer"}, + {"external_attribute":"evidence_audience","local_attribute":"evidence_audience"}, + {"external_attribute":"evidence_tags","local_attribute":"evidence_tags"}, + ]) + ); + let schema: Value = serde_yaml_parse(&rendered.agent_type_document).unwrap(); + assert_eq!(schema["schema"]["evidence_audience"]["type"], "string"); + assert_eq!(schema["schema"]["evidence_tags"]["type"], "array"); + assert_eq!(schema["schema"]["evidence_tags"]["items"]["type"], "string"); + assert!( + render(&description).is_err(), + "a second render must not overwrite session resources" + ); + fs::remove_dir_all(&description.state_root).unwrap(); + } } #[test] @@ -899,6 +937,7 @@ mod tests { description.machine_clients[0].token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: description.resource_servers[0].id.clone(), assertion_scope: "evidence:invoke".into(), + ordinary_resource_permissions: Vec::new(), }); let rendered = render(&description).unwrap(); let connection: Value = serde_yaml_parse( diff --git a/products/breg/DEV.md b/products/breg/DEV.md index ed6798c728..1d76c3685b 100644 --- a/products/breg/DEV.md +++ b/products/breg/DEV.md @@ -84,6 +84,8 @@ runtime configuration and private credential file references. Keys and access tokens never appear in these reports. Use `bregctl dev export-client` to obtain the client ID, assertion key, issuer and token endpoint handoff; an OAuth client can obtain a fresh token with those fixed values. Keep token output owner-only. +For a client mapped through `issuer.clientResources`, the export's `resource` +and `audience` name that client's registered audience. ## Observe local events @@ -270,6 +272,13 @@ use another client for the same profile by naming that exact step with profile-mismatched, duplicate, or ambiguous bindings are refused before any service starts. +For a native journey using a `sub` profile, declare +`claims: {principal: "$localClientSubject", ...}`. This explicit fixture marker +resolves to the selected local client's registered subject only in the private +captured project. Exact `testBindings` select claim variants as usual. A borrowed +issuer uses its owner's registration identity. Literal expected principals stay +unchanged; the marker refuses under another principal claim or another field. + Set `accessProfiles: []` for a machine client that carries only scopes or claims for another product, such as Casework. The empty list gives that client no BReg access-profile binding. It is registered with the local issuer but omitted @@ -492,19 +501,61 @@ retained session follow the normal source-change refusal and recovery rules. ## Compose one local issuer +For a project with task-grant profiles, declare each Casework status service +before the first start. The source issuer must match the profiles' `taskGrant` +issuer. The status client has only the status scope, no BReg profile, and no +`allowBregAccess`: + +```yaml +clients: + - id: breg-status + accessProfiles: [] + scopes: [casework:grants:status] + claims: {registry_actor_kind: service} +taskGrantStatus: + casework: + sourceIssuer: https://casework.local.example + baseUrl: http://127.0.0.1:8092/ + client: breg-status +``` + +Merge these entries into the existing versioned clients file. Up to eight +bindings are supported, with unique source issuers and exact numeric loopback +HTTP endpoints. The generated runtime uses the session issuer for token +acquisition and client assertion audience, copies the client's assertion key +into an owner-only secret file, and selects that client's declared +`issuer.clientResources` audience or the session's default resource. Register +the same client and resource in Casework's `taskAuthority.statusClients`. +This binds the existing [task-grant status contract](TASK_GRANTS.md); it grants +no access to source records and does not replace the governed task profile. + One BREG dev session can own the issuer registrations for several local products. Its `dev-clients.yaml` declares the complete inventory before that issuer starts. Add other audiences and the exact client role mapping under `issuer`; the default BREG audience is the `resource` reported by the owner -session. Extra resources carry no client permission until a client is mapped. +session. Extra resources carry no client permission until a client is mapped +or receives an explicit resource grant. ```yaml +clients: + - id: task-agent + accessProfiles: [] + scopes: [casework:grants:assert] + claims: {registry_actor_kind: service} + grants: + - audience: urn:registrystack:scheduling:local:appointments + scopes: [scheduling:read] issuer: resources: - audience: urn:registrystack:evidence:local:gateway scopes: [evidence:invoke] + - audience: urn:registrystack:casework:local:inbox + scopes: [casework:grants:assert] + - audience: urn:registrystack:scheduling:local:appointments + scopes: [scheduling:read] clientResources: evidence-reader: urn:registrystack:evidence:local:gateway + task-agent: urn:registrystack:casework:local:inbox exchangeIssuers: - id: casework issuer: https://casework.example.test @@ -555,6 +606,22 @@ another resource does not. The app still needs a token with the governed profile's actual `scope` and principal/purpose claims to call BREG. +Machine clients may also declare `grants` with the same audience/scopes format. +Up to seven distinct audience grants are accepted, each with 1..32 distinct +scopes declared by that resource. Omit `audience` for the owner's default BREG +resource. Grants merge into that client's existing issuer role without adding +duplicate resource permissions. They do not change its primary `scopes`, default +token requests, exported `resource`, exchange bootstrap scope, or BREG admission. +For example, the task agent above may request an ordinary `scheduling:read` +token at the Scheduling resource using the same client and subject; committing +an appointment still requires Scheduling's independently verified task grant. +Unknown audiences, undeclared scopes and borrowed-client grant declarations +are refused. Author these grants on the issuer owner before first start. +For institutional exchange clients, extra ordinary grants may name only other +resources; the primary resource retains its single exact bootstrap permission. +The issuer tooling records those explicit extra permissions separately from +the bootstrap bound, without creating task-grant claims or widening exchange. + Start the owner first. Another BREG project can run `bregctl dev start ./registry-two --issuer-project ./issuer-owner` when the owner pre-registered that project's client IDs, exact scopes, and claims. The @@ -565,7 +632,8 @@ owner is refused before the borrower starts. Declare additional issuer resources, exchange connections and clients, browser applications, and synthetic users on the owner. A borrower may list only `issuer.browserClients` from that issuer inventory; owner-only declarations in -the borrower's clients file are refused before startup. +the borrower's clients file, including machine-client `grants`, are refused +before startup. A borrower answers on the owner's BREG audience, so it applies the owner's exchange-connection pairings: an exchanged token is refused unless the presenting client is registered against the assertion authority that signed it, diff --git a/products/breg/examples/formats/dev-session/.breg/dev/source-transition.json b/products/breg/examples/formats/dev-session/.breg/dev/source-transition.json index c4e85706a9..28ff683c2a 100644 --- a/products/breg/examples/formats/dev-session/.breg/dev/source-transition.json +++ b/products/breg/examples/formats/dev-session/.breg/dev/source-transition.json @@ -1 +1 @@ -{"apiVersion":"id.registrystack.org/formats/breg/dev-source-transition/v1alpha1","kind":"BRegDevSourceTransition","priorDigest":"5555555555555555555555555555555555555555555555555555555555555555","targetDigest":"9999999999999999999999999999999999999999999999999999999999999999","sequence":2,"originals":{"/home/operator/my-registry/registry.yaml":"apiVersion: id.registrystack.org/formats/breg/project/v1alpha1\n"},"replacements":{"/home/operator/my-registry/registry.yaml":"apiVersion: id.registrystack.org/formats/breg/project/v1alpha1\n"},"client":{"id":"source","accessProfiles":["evidence-source"],"scopes":["registry:evidence:lookup"],"claims":{"registry_principal":"evidence-source","registry_purpose":"evidence-source-read"},"testBindings":[]},"clients":{"apiVersion":"id.registrystack.org/formats/breg/dev-clients/v1alpha1","kind":"BRegDevClients","clients":[{"id":"operator","accessProfiles":["operator"],"scopes":["registry:generic:operate"],"claims":{"registry_principal":"generic-registry-operator","registry_purpose":"registry-operations"},"testBindings":[]},{"id":"source","accessProfiles":["evidence-source"],"scopes":["registry:evidence:lookup"],"claims":{"registry_principal":"evidence-source","registry_purpose":"evidence-source-read"},"testBindings":[]}],"seed":[],"issuer":{"resources":[],"exchangeIssuers":[],"interactiveApplications":[],"browserClients":[],"syntheticUsers":[],"clientResources":{},"exchangeClients":[]},"eventDestinations":{},"evidenceProviders":{},"reviewAuthorities":{},"reviewExecutors":{}},"prepared":{"apiVersion":"id.registrystack.org/formats/breg/dev-prepared-source/v1alpha1","kind":"BRegDevPreparedSource","requestDigest":"8888888888888888888888888888888888888888888888888888888888888888","report":"{\"client\":\"source\",\"command\":\"dev prepare-source\",\"entity\":\"record\",\"ok\":true,\"project\":\"/home/operator/my-registry\",\"requiresRestart\":true,\"status\":\"prepared\"}"}} +{"apiVersion":"id.registrystack.org/formats/breg/dev-source-transition/v1alpha1","kind":"BRegDevSourceTransition","priorDigest":"5555555555555555555555555555555555555555555555555555555555555555","targetDigest":"9999999999999999999999999999999999999999999999999999999999999999","sequence":2,"originals":{"/home/operator/my-registry/registry.yaml":"apiVersion: id.registrystack.org/formats/breg/project/v1alpha1\n"},"replacements":{"/home/operator/my-registry/registry.yaml":"apiVersion: id.registrystack.org/formats/breg/project/v1alpha1\n"},"client":{"id":"source","accessProfiles":["evidence-source"],"scopes":["registry:evidence:lookup"],"claims":{"registry_principal":"evidence-source","registry_purpose":"evidence-source-read"},"testBindings":[]},"clients":{"apiVersion":"id.registrystack.org/formats/breg/dev-clients/v1alpha1","kind":"BRegDevClients","clients":[{"id":"operator","accessProfiles":["operator"],"scopes":["registry:generic:operate"],"claims":{"registry_principal":"generic-registry-operator","registry_purpose":"registry-operations"},"testBindings":[]},{"id":"source","accessProfiles":["evidence-source"],"scopes":["registry:evidence:lookup"],"claims":{"registry_principal":"evidence-source","registry_purpose":"evidence-source-read"},"testBindings":[]}],"seed":[],"issuer":{"resources":[],"exchangeIssuers":[],"interactiveApplications":[],"browserClients":[],"syntheticUsers":[],"clientResources":{},"exchangeClients":[]},"eventDestinations":{},"evidenceProviders":{},"reviewAuthorities":{},"reviewExecutors":{},"taskGrantStatus":{}},"prepared":{"apiVersion":"id.registrystack.org/formats/breg/dev-prepared-source/v1alpha1","kind":"BRegDevPreparedSource","requestDigest":"8888888888888888888888888888888888888888888888888888888888888888","report":"{\"client\":\"source\",\"command\":\"dev prepare-source\",\"entity\":\"record\",\"ok\":true,\"project\":\"/home/operator/my-registry\",\"requiresRestart\":true,\"status\":\"prepared\"}"}} diff --git a/products/breg/generated/tools/dev-clients.v1alpha1.schema.json b/products/breg/generated/tools/dev-clients.v1alpha1.schema.json index cdf5ec71a1..a4dccf290a 100644 --- a/products/breg/generated/tools/dev-clients.v1alpha1.schema.json +++ b/products/breg/generated/tools/dev-clients.v1alpha1.schema.json @@ -113,6 +113,13 @@ "type": "object", "x-registry-passthrough": "Claim values are written into the development token as given." }, + "grants": { + "description": "Additional explicit permissions on declared resource audiences. These\ndo not change default token scopes or the exchange bootstrap scope.", + "items": { + "$ref": "#/$defs/LocalPermissionGrant" + }, + "type": "array" + }, "id": { "$ref": "#/$defs/LocalId" }, @@ -475,6 +482,26 @@ ], "type": "object" }, + "LocalTaskGrantStatus": { + "additionalProperties": false, + "properties": { + "baseUrl": { + "$ref": "#/$defs/Url" + }, + "client": { + "$ref": "#/$defs/LocalId" + }, + "sourceIssuer": { + "$ref": "#/$defs/ExternalId" + } + }, + "required": [ + "sourceIssuer", + "baseUrl", + "client" + ], + "type": "object" + }, "SecretProvidersConfig": { "additionalProperties": false, "anyOf": [ @@ -668,6 +695,17 @@ "$ref": "#/$defs/Seed" }, "type": "array" + }, + "taskGrantStatus": { + "additionalProperties": { + "$ref": "#/$defs/LocalTaskGrantStatus" + }, + "default": {}, + "description": "Exact Casework status services for compiled task-grant profiles.", + "propertyNames": { + "$ref": "#/$defs/LocalId" + }, + "type": "object" } }, "required": [ diff --git a/products/casework/DEV-SOURCES.md b/products/casework/DEV-SOURCES.md index 081507b226..08b176cb0a 100644 --- a/products/casework/DEV-SOURCES.md +++ b/products/casework/DEV-SOURCES.md @@ -132,6 +132,21 @@ present, and its resource servers refuse a token exchanged from any other. Casework copies those exact client pairs into its private state, keeps its own task signing key, and neither starts nor stops the owner's issuer. An owner session change or unavailable issuer is refused before restart. +Borrowed sessions require a stable `principalClaim`, such as +`registry_principal`, in every Casework access profile. Explicitly register that +claim for each client in both projects. `sub` identifies the BREG owner's +subject and cannot stand in for Casework's local directory principal. + +Known integration and shared-registration mismatches report a fixed diagnostic +category and the clients-file section to correct, even before private session +logs exist. Compare the owner's explicit actor claims as well as the client ID, +resource, scopes and exchange registration. These configuration refusals exit 1; +filesystem and network failures retain their operational failure category. +Reports omit the private cause and rejected values. A borrowed `sub` profile +reports `caseworkctl.dev.borrowed-principal-invalid` at +`casework.yaml:/accessProfiles/principalClaim`; an invalid source adapter reports +`caseworkctl.dev.source-binding-invalid` at +`dev-clients.yaml:/integrations/sources`. Each includes a fixed correction action. `browserClients` is an optional explicit list of auth-code application IDs admitted to Casework's local runtime. Each must be registered by the shared owner for this exact resource; a browser app mapped to another resource is diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 8fdee1f3f9..ed62b64376 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -3005,7 +3005,7 @@ }, "artifact": { "path": "products/breg/generated/tools/dev-clients.v1alpha1.schema.json", - "sha256": "84801024e444b8c47daaef3c39bdd0afe2e95cc11e11665b107001e18cafd70d", + "sha256": "822fb2aceae89790b9c688523ac731c8f6a4371d746a753d2589fff2161eea02", "mediaType": "application/schema+json" } }, From 214872e7d021573b9c5e1929890399473931bbf0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 13 Oct 2026 00:48:18 +0700 Subject: [PATCH 09/11] ci(casework): run the task-grant invalidation suite on every Casework change The suite proves that a committed loss of directory membership, holder, or proposal permanently invalidates a task grant while transient writes and atomic replacement do not, and that item erasure removes the grant records. It runs beside the assignment and routing suites against the same PostgreSQL service, and the gates inventory pins the command. Closes #1936 Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 4 ++-- products/casework/README.md | 5 +++-- release/scripts/check-gates-inventory.py | 4 ++-- release/scripts/test_check_gates_inventory.py | 6 +++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f5e344ba4..2244e31a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -849,11 +849,11 @@ jobs: docker exec "$POSTGRES_CONTAINER_ID" createdb -U casework casework_source_retention docker exec "$POSTGRES_CONTAINER_ID" createdb -U casework casework_review docker exec "$POSTGRES_CONTAINER_ID" createdb -U casework casework_review_migration - - name: Verify assignment and source routing + - name: Verify assignment, source routing, and task-grant invalidation env: CASEWORK_ASSIGNMENT_TEST_DATABASE_URL: postgresql://casework:casework_test@localhost:${{ job.services.postgres.ports['5432'] }}/casework_review CASEWORK_ROUTING_TEST_DATABASE_URL: postgresql://casework:casework_test@localhost:${{ job.services.postgres.ports['5432'] }}/casework_review - run: cargo test --locked -p registry-casework --features postgres-test --test assignment_postgres --test routing_postgres + run: cargo test --locked -p registry-casework --features postgres-test --test assignment_postgres --test routing_postgres --test task_grant_invalidation - name: Verify persisted source clocks, restart, and recompute # An inexact name filter that selects nothing still exits 0, so the # step names the test exactly and fails unless it reports one pass. diff --git a/products/casework/README.md b/products/casework/README.md index a1b17bda30..7d918316a9 100644 --- a/products/casework/README.md +++ b/products/casework/README.md @@ -836,7 +836,7 @@ The following operator-supplied variables select the maintained suites: | `CASEWORK_CLOCK_TEST_DATABASE_URL` | `--lib clocks::tests::source_clocks_survive_restart_and_preserve_subject_budget` | Its own: the test resets `public` | | `CASEWORK_REVIEW_TEST_DATABASE_URL` | `--test review_postgres --test review_http --test review_payment_fixture_postgres` | May be shared: each fixture creates a unique schema | | `CASEWORK_REVIEW_MIGRATION_TEST_DATABASE_URL` | `--test review_migration_postgres` | May be shared: the fixture creates a unique schema | -| `CASEWORK_ASSIGNMENT_TEST_DATABASE_URL` | `--test assignment_postgres` | May be shared: the fixture creates a unique schema | +| `CASEWORK_ASSIGNMENT_TEST_DATABASE_URL` | `--test assignment_postgres --test task_grant_invalidation` | May be shared: each fixture creates a unique schema | | `CASEWORK_ROUTING_TEST_DATABASE_URL` | `--test routing_postgres` | May be shared: the fixture creates a unique schema | | `CASEWORK_INBOX_TEST_DATABASE_URL` | `--test inbox_ordering_postgres` | May be shared: the fixture creates a unique schema | | `CASEWORK_ACTIVATION_TEST_DATABASE_URL` | `--test activation_postgres` | May be shared: each test creates a unique schema; the split-role tests also create and drop their own login roles, so the URL's role must be allowed to create roles | @@ -870,7 +870,8 @@ cargo test -p registry-casework --features postgres-test --lib --locked \ -- --exact clocks::tests::source_clocks_survive_restart_and_preserve_subject_budget cargo test -p registry-casework --features postgres-test --locked \ --test review_postgres --test review_http --test review_payment_fixture_postgres \ - --test review_migration_postgres --test assignment_postgres --test routing_postgres + --test review_migration_postgres --test assignment_postgres --test routing_postgres \ + --test task_grant_invalidation cargo test -p registry-casework --features postgres-test --test inbox_ordering_postgres --locked cargo test -p registry-casework --features postgres-test --test activation_postgres --locked ``` diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 6a7d190b84..1766f43f2e 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -320,8 +320,8 @@ "cargo test --locked -p registry-casework --features postgres-test --test service_visibility", ), ( - "Casework assignment and source routing suites", - "cargo test --locked -p registry-casework --features postgres-test --test assignment_postgres --test routing_postgres", + "Casework assignment, source routing, and task-grant invalidation suites", + "cargo test --locked -p registry-casework --features postgres-test --test assignment_postgres --test routing_postgres --test task_grant_invalidation", ), ( "Casework persisted source clock selection", diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index c62f9450fb..48cb4610c4 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -860,9 +860,9 @@ def test_missing_casework_workflow_gates_are_reported(self) -> None: "Casework caller visibility suite", ), ( - "cargo test --locked -p registry-casework --features postgres-test --test assignment_postgres --test routing_postgres", - "true # Casework assignment and routing suites disabled", - "Casework assignment and source routing suites", + "cargo test --locked -p registry-casework --features postgres-test --test assignment_postgres --test routing_postgres --test task_grant_invalidation", + "true # Casework assignment, routing, and task-grant suites disabled", + "Casework assignment, source routing, and task-grant invalidation suites", ), ( "-- --exact clocks::tests::source_clocks_survive_restart_and_preserve_subject_budget", From cc51e9412858b1791cce4a34816d81eb9a0d2868 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 13 Oct 2026 00:48:18 +0700 Subject: [PATCH 10/11] fix(dev): name the registry each dev session pulls from in its help An operator writes an egress allowlist from the help, so the bregctl and caseworkctl dev start help and the messagingctl dev help now name the exact mirror references the session pulls, and tests hold each help to the supervisor's own constant. Signed-off-by: Jeremi Joslin --- crates/registry-bregctl/src/dev/mod.rs | 5 +++-- crates/registry-bregctl/src/dev/tests.rs | 7 ++++--- crates/registry-caseworkctl/src/dev/mod.rs | 3 ++- crates/registry-caseworkctl/src/dev/tests.rs | 20 +++++++++++++++++++ crates/registry-cli-docs/catalog.json | 8 ++++---- crates/registry-messagingctl/src/dev/mod.rs | 6 ++---- crates/registry-messagingctl/src/dev/tests.rs | 16 +++++++++++++++ crates/registry-messagingctl/src/lib.rs | 8 ++++++++ products/breg/DEV.md | 12 ++++++----- release/OPERATIONS.md | 7 ++++--- 10 files changed, 70 insertions(+), 22 deletions(-) diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index c02b301c78..8eb7db0cb2 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -125,8 +125,9 @@ enum DevAction { /// A resident supervisor owns this project's PostgreSQL and ThunderID /// containers plus its Base Registry Engine (BReg) child. The database runs /// the pinned image - /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, - /// or, when the compiled schema requires PostGIS, + /// public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, + /// the Docker Official Images mirror of postgres:17.11 at that digest, or, + /// when the compiled schema requires PostGIS, the Docker Hub image /// postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6, /// which the supervisor pulls on the first start. Each supervised /// prerequisite command may run for 120 seconds, and the database and each diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index c6c5369d7f..9bcc92437b 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -2391,8 +2391,9 @@ fn generated_postgres_leaf_verifies_against_its_distinct_ca() { } /// The pinned image and the two deadlines are facts an operator checks before -/// a first start. Hold the owning document, the command's own help text and -/// the supervisor's canonical identity equal so they cannot drift apart. +/// a first start, and an egress allowlist is written from the image reference. +/// Hold the owning document, the command's own help text and the reference the +/// supervisor pulls equal so they cannot drift apart. #[test] fn the_documented_image_and_deadlines_are_the_supervisors_own() { let document = fs::read_to_string( @@ -2418,7 +2419,7 @@ fn the_documented_image_and_deadlines_are_the_supervisors_own() { "the owning document names the pull source" ); for fact in [ - CANONICAL_IMAGE.to_owned(), + IMAGE.to_owned(), SPATIAL_IMAGE.to_owned(), format!("{} seconds", CHILD_DEADLINE.as_secs()), format!("{} seconds", READY_DEADLINE.as_secs()), diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index f96f50e61c..591d128a19 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -160,7 +160,8 @@ enum DevAction { /// Casework child. Standalone sessions also own a pinned issuer container; /// --source-project sessions use the running Base Registry Engine (BReg) session's stock issuer. The database runs the pinned /// image - /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, + /// public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, + /// the Docker Official Images mirror of postgres:17.11 at that digest, /// which the supervisor pulls on the first start. Each supervised /// prerequisite command may run for 120 seconds, and the database and each /// started service have 45 seconds to answer as ready. A start that passes diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index ec04ecdf5e..4be89556d7 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -1753,6 +1753,26 @@ fn retained_database_inspection_accepts_only_verified_image_aliases() { } } +/// An egress allowlist is written from the start help, so the help names the +/// exact reference the supervisor pulls. +#[test] +fn dev_start_help_names_the_image_reference_it_pulls() { + let cli = ::command(); + let help = cli + .get_subcommands() + .find(|command| command.get_name() == "dev") + .expect("caseworkctl publishes dev") + .get_subcommands() + .find(|command| command.get_name() == "start") + .and_then(|command| command.get_long_about()) + .expect("dev start describes its own supervision") + .to_string(); + assert!( + help.contains(IMAGE), + "caseworkctl dev start help omits {IMAGE}" + ); +} + #[test] fn a_volume_without_the_owner_label_is_never_removed() { let root = crate::canonical_tempdir(); diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index b867b0c422..419af9d1ad 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -1247,7 +1247,7 @@ "name": "start", "invocation": "bregctl dev start", "about": "Start or reuse the project's retained local database and services", - "long_about": "Start or reuse the project's retained local database and services. A resident supervisor owns this project's PostgreSQL and ThunderID containers plus its Base Registry Engine (BReg) child. The database runs the pinned image postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, or, when the compiled schema requires PostGIS, postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6, which the supervisor pulls on the first start. Each supervised prerequisite command may run for 120 seconds, and the database and each started service have 45 seconds to answer as ready. A start that passes a deadline fails, stops what it acquired, and keeps its owner-only diagnostics in the project's private .breg/dev/logs directory. Catchable termination signals trigger owned cleanup, but an uncatchable outside kill can leave surviving service owners that need inspection before restart. products/breg/DEV.md documents this lifecycle in 'Native local BReg lifecycle' and its recovery in 'Retained state and recovery'.", + "long_about": "Start or reuse the project's retained local database and services. A resident supervisor owns this project's PostgreSQL and ThunderID containers plus its Base Registry Engine (BReg) child. The database runs the pinned image public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, the Docker Official Images mirror of postgres:17.11 at that digest, or, when the compiled schema requires PostGIS, the Docker Hub image postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6, which the supervisor pulls on the first start. Each supervised prerequisite command may run for 120 seconds, and the database and each started service have 45 seconds to answer as ready. A start that passes a deadline fails, stops what it acquired, and keeps its owner-only diagnostics in the project's private .breg/dev/logs directory. Catchable termination signals trigger owned cleanup, but an uncatchable outside kill can leave surviving service owners that need inspection before restart. products/breg/DEV.md documents this lifecycle in 'Native local BReg lifecycle' and its recovery in 'Retained state and recovery'.", "usage": "bregctl dev start [OPTIONS] [PROJECT]", "arguments": [ { @@ -6430,7 +6430,7 @@ "name": "start", "invocation": "caseworkctl dev start", "about": "Start or reuse the project's retained local database and services", - "long_about": "Start or reuse the project's retained local database and services. A resident supervisor owns this project's PostgreSQL container and Casework child. Standalone sessions also own a pinned issuer container; --source-project sessions use the running Base Registry Engine (BReg) session's stock issuer. The database runs the pinned image postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, which the supervisor pulls on the first start. Each supervised prerequisite command may run for 120 seconds, and the database and each started service have 45 seconds to answer as ready. A start that passes a deadline fails, stops what it acquired, and keeps its owner-only diagnostics in the project's private .casework/dev/logs directory.", + "long_about": "Start or reuse the project's retained local database and services. A resident supervisor owns this project's PostgreSQL container and Casework child. Standalone sessions also own a pinned issuer container; --source-project sessions use the running Base Registry Engine (BReg) session's stock issuer. The database runs the pinned image public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, the Docker Official Images mirror of postgres:17.11 at that digest, which the supervisor pulls on the first start. Each supervised prerequisite command may run for 120 seconds, and the database and each started service have 45 seconds to answer as ready. A start that passes a deadline fails, stops what it acquired, and keeps its owner-only diagnostics in the project's private .casework/dev/logs directory.", "usage": "caseworkctl dev start [OPTIONS] [PROJECT]", "arguments": [ { @@ -13580,7 +13580,7 @@ "name": "dev", "invocation": "messagingctl dev", "about": "Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP gateway in Docker, until Ctrl-C", - "long_about": null, + "long_about": "Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP gateway in Docker, until Ctrl-C. The session pulls and runs the pinned images public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 and ghcr.io/axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d, the Docker Official Images and upstream GHCR mirrors of postgres:17.11 and axllent/mailpit:v1.31.2 at those digests. Each publishes on 127.0.0.1 only, and the session removes both when it stops.", "usage": "messagingctl dev [OPTIONS] [PROJECT]\n messagingctl dev ", "arguments": [ { @@ -13645,7 +13645,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], diff --git a/crates/registry-messagingctl/src/dev/mod.rs b/crates/registry-messagingctl/src/dev/mod.rs index 2890c05ccc..e5691a7bc3 100644 --- a/crates/registry-messagingctl/src/dev/mod.rs +++ b/crates/registry-messagingctl/src/dev/mod.rs @@ -88,10 +88,8 @@ struct TokenArgs { /// Start a session in the foreground and stop it on Ctrl-C or SIGTERM. /// /// The session needs Docker. It pulls and runs the pinned images -/// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 -/// and -/// axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d, -/// each publishing on 127.0.0.1 only, and removes both when it stops. +/// `docker::POSTGRES_IMAGE` and `docker::MAILPIT_IMAGE`, each publishing on +/// 127.0.0.1 only, and removes both when it stops. #[derive(Debug, Args)] struct StartArgs { /// The package directory to run, as `messagingctl init` writes it. diff --git a/crates/registry-messagingctl/src/dev/tests.rs b/crates/registry-messagingctl/src/dev/tests.rs index 22a49c100e..f26ad3bcf1 100644 --- a/crates/registry-messagingctl/src/dev/tests.rs +++ b/crates/registry-messagingctl/src/dev/tests.rs @@ -89,6 +89,22 @@ fn the_generated_configuration_is_one_the_runtime_accepts() { assert!(document.contains("development-loopback")); } +/// An egress allowlist is written from the dev help, so the help names the +/// exact references the session pulls. +#[test] +fn dev_help_names_the_image_references_it_pulls() { + let cli = ::command(); + let help = cli + .get_subcommands() + .find(|command| command.get_name() == "dev") + .and_then(|command| command.get_long_about()) + .expect("messagingctl dev describes its session") + .to_string(); + for image in [docker::POSTGRES_IMAGE, docker::MAILPIT_IMAGE] { + assert!(help.contains(image), "messagingctl dev help omits {image}"); + } +} + #[test] fn every_session_file_is_owner_only() { let (_temporary, project, root, _loaded) = session(); diff --git a/crates/registry-messagingctl/src/lib.rs b/crates/registry-messagingctl/src/lib.rs index 3fcbcf469d..3a33c2786b 100644 --- a/crates/registry-messagingctl/src/lib.rs +++ b/crates/registry-messagingctl/src/lib.rs @@ -121,6 +121,14 @@ enum Command { Retention(RetentionCommand), /// Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP /// gateway in Docker, until Ctrl-C. + /// + /// The session pulls and runs the pinned images + /// public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + /// and + /// ghcr.io/axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d, + /// the Docker Official Images and upstream GHCR mirrors of postgres:17.11 and + /// axllent/mailpit:v1.31.2 at those digests. Each publishes on 127.0.0.1 + /// only, and the session removes both when it stops. Dev(dev::DevArgs), } diff --git a/products/breg/DEV.md b/products/breg/DEV.md index 1d76c3685b..251254d8c8 100644 --- a/products/breg/DEV.md +++ b/products/breg/DEV.md @@ -39,11 +39,13 @@ original ports and clients-file location. Conflicting ports are refused. The database runs the pinned image `public.ecr.aws/docker/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675`, or `postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6` -when the compiled schema requires PostGIS. Public help names the canonical -`postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675` -identity; the implementation pulls the byte-identical Docker Official Images -ECR mirror above. Retained owned containers accept either exact PostgreSQL -reference at that digest. The selection is retained with the +when the compiled schema requires PostGIS. The first is the Docker Official +Images mirror of +`postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675`, +byte-identical at that digest; the PostGIS image comes from Docker Hub. The +`dev start` help names both references, so an egress allowlist can admit +`public.ecr.aws` and, for spatial schemas, Docker Hub. Retained owned +containers accept either exact PostgreSQL reference at that digest. The selection is retained with the owned database. Spatial setup grants the migration role permission to SET the no-login bbox owner; the runtime role never receives that membership. Each supervised diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index cf80c226da..4a925a47e7 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -323,9 +323,10 @@ published through `public.ecr.aws/docker/library/postgres`. Debian recipe roots use `public.ecr.aws/docker/library/debian`, and Mailpit uses its upstream `ghcr.io/axllent/mailpit` mirror. Each reference retains the exact upstream manifest digest; the registry endpoint changes, not the selected image bytes. -Public CLI help retains the canonical `postgres:17.11@sha256:…` and -`axllent/mailpit:v1.31.2@sha256:…` identities; the implementations pull the -corresponding mirror URI at the same full digest. +The `dev start` help of `bregctl` and `caseworkctl` and the `dev` help of +`messagingctl` name the exact mirror references they pull. A `bregctl` +session whose schema requires PostGIS still pulls `postgis/postgis` from +Docker Hub. [Docker Official Images on ECR Public](https://aws.amazon.com/blogs/containers/docker-official-images-now-available-on-amazon-elastic-container-registry-public/) and [Mailpit's Docker installation guide](https://mailpit.axllent.org/docs/install/docker/) From 1356242060c04d213e2efc2b90a70d03bca3ab17 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 11 Oct 2026 12:24:46 +0700 Subject: [PATCH 11/11] docs(release): record the task-grant seams in the product changelogs Adopters upgrading need the deferred grants, the shared status transport, the Scheduling offline bound, the receipt routes, the stricter BReg status configuration, and the changed client and caseworkctl refusals in one place, with a migration for each breaking change. The changelogs also name the mirror registries each dev session now pulls from, for egress allowlists, and the receipt problem code the Scheduling and Messaging Rust clients gain. Signed-off-by: Jeremi Joslin --- products/breg/CHANGELOG.md | 49 +++++++++++++++++++++++ products/casework/CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++ products/messaging/CHANGELOG.md | 19 +++++++++ products/platform/CHANGELOG.md | 21 ++++++++++ products/scheduling/CHANGELOG.md | 42 ++++++++++++++++++++ 5 files changed, 198 insertions(+) diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index d64aadb8a7..29952ff255 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -86,6 +86,55 @@ mapping, so no project that started before is refused now. If affected, give every entity profile and action grant, including those a module contributes, one `principalClaim` that is neither the scope claim nor the purpose claim. +- BREAKING: the task-grant status check before a governed write, a later + approval, or an application reports a 401 or 403 from the Casework status + endpoint as an outage, so the request answers `service.unavailable` (503) + where it answered `precondition.failed` (412): such an answer refuses the + engine's own status credential, not the grant. A 404 still refuses the + grant with `precondition.failed`. The check runs through the shared + platform transport, which identifies itself with the user agent + `registry-task-status` (was `registry-breg-task-status`) and bounds the + whole attempt at ten seconds; the engine's own five-second bound on the + check still ends it first. Migration: alert on `service.unavailable` for a + misconfigured or revoked status client, and match the new user agent in + Casework access logs. +- BREAKING: the runtime checks each `taskGrantStatus` entry when it reads + the file, and refuses an invalid one as + `breg.runtime.invalid-task-grant-status` at `/taskGrantStatus`, where only + activation refused it, as `breg.runtime.invalid-binding`. The members are + typed: `tokenEndpoint` is a URL; `clientId`, `clientAssertionAudience`, + and `caseworkResource` are external identifiers (1 to 512 characters, no + control characters), and `caseworkResource` is an absolute resource URI. + `sourceIssuer` accepts an absolute URN issuer as well as a URL, since it + is compared and never fetched. Migration: none for an entry the runtime + already activated; a script that matched `breg.runtime.invalid-binding` + for a malformed entry matches the new code. +- A `taskGrantStatus` entry takes the optional members `statusScope` and + `statusPath`. Omitted, the engine requests the `casework:grants:status` + scope and calls `/v1/task-grants/{grantId}/status` under `baseUrl`, as + before. Set them only for a task authority that answers under another + scope or route: `statusScope` is one scope token, and `statusPath` starts + with `/` and holds exactly one whole `{grantId}` segment. +- A native `bregctl dev` fixture journey under a `sub` access profile may + write `claims: {principal: "$localClientSubject"}`. The marker resolves to + the selected local client's registered subject in the private captured + project only; the authored journey file is unchanged. The marker is refused + under any other principal claim and anywhere else in a journey. +- `bregctl dev` binds the Casework task-grant status services a + `dev-clients.yaml` declares under `taskGrantStatus`, at most eight, each + with its own status-only client on a numeric loopback endpoint, into the + generated runtime. A machine client may declare `grants`, at most seven + audiences each with 1 to 32 scopes that resource declares, to request + ordinary tokens at other issuer resources; the grants change neither its + primary scopes, its exported resource, nor its BReg admission. See + `products/breg/DEV.md`. +- `bregctl dev start` pulls its PostgreSQL image from the Docker Official + Images mirror `public.ecr.aws/docker/library/postgres` at the same + digest, and its help names that reference. A session whose schema + requires PostGIS still pulls `postgis/postgis` from Docker Hub. An egress + allowlist for local development must admit `public.ecr.aws`. A retained + session container on the Docker Hub `postgres:17.11` reference at that + digest is still accepted. ## v0.40.0 - 2026-10-11 diff --git a/products/casework/CHANGELOG.md b/products/casework/CHANGELOG.md index b8e96db3e4..5868030936 100644 --- a/products/casework/CHANGELOG.md +++ b/products/casework/CHANGELOG.md @@ -60,6 +60,73 @@ `principalClaim`, then repeat `source add`. The local human reviewer bridge is unchanged: a reviewer profile still authenticates its principal through `registry_principal`. +- Task templates may opt into `authorizationMode: deferred` for delayed + work, with a `lifetimeSeconds` of at most 604800 (seven days). An omitted + mode stays `immediate`, with its 900-second ceiling. The preview, the + grant view, the Rust client (`TaskAuthorizationMode`), and the Node.js and + Python clients (`authorizationMode`) carry the mode, which they omit for an + immediate grant; approval still names only the template id and version. A + deferred review grant pins the task's assignment generation, so a release, + reassignment, queue move, absence-cover change, decision, or completion + ends it and a later reclaim does not revive it; a draft revision does not. + Deferred grants cannot carry Evidence bounds: `caseworkctl check` reports a + deferred template with bounds of type `evidence` as + `casework.task-template.deferred-evidence`, the project schema refuses it, + and the task authority signs no assertion for such a grant. A deferred + grant reaches the Base Registry Engine, which checks fresh grant status + before it writes, or a Scheduling deployment that configures + `taskGrantStatus`. See `products/casework/TASK_GRANTS.md`. +- Migration 0022 replaces the check constraints `casework_task_grants_check` + and `casework_review_task_grants_check` with + `casework_task_grants_authorization_window` and + `casework_review_task_grants_authorization_window`, which bound a grant's + expiry by its template's mode: 900 seconds immediate, 604800 seconds + deferred. It adds `casework_review_tasks.assignment_generation` and the + `BEFORE UPDATE` trigger `casework_review_assignment_generation`, which + increments it when the state, queue, holder, assignment owner, assignment + kind, or absence ids of a review task change. The migration scans the + three tables under an exclusive lock to validate the constraints and + rewrites none of them. The trigger then runs on every update of a review + task, draft revisions included: it writes no extra row version and adds + eight bytes to each row. +- BREAKING: `caseworkctl dev` reports five configuration refusals by their + own codes and exits with status 1, where it reported each as + `caseworkctl.operational-failure` and exited with status 3: + `caseworkctl.dev.integrations-invalid` at + `dev-clients.yaml:/integrations`, `caseworkctl.dev.shared-client-mismatch` + at `dev-clients.yaml:/clients`, + `caseworkctl.dev.shared-task-authority-mismatch` at + `dev-clients.yaml:/integrations/taskAuthority`, + `caseworkctl.dev.source-binding-invalid` at + `dev-clients.yaml:/integrations/sources`, and + `caseworkctl.dev.borrowed-principal-invalid` at + `casework.yaml:/accessProfiles/principalClaim`. Each suggests the + correction and keeps the underlying cause out of the report. Migration: a + script that read exit status 3 for these refusals reads status 1 and the + code. +- BREAKING: `caseworkctl dev` refuses a session that borrows another + project's issuer when a Casework access profile uses `principalClaim: sub`, + as `caseworkctl.dev.borrowed-principal-invalid`: in a borrowed session + `sub` names the issuer owner's subject, not the Casework principal. + Migration: use a stable claim such as `registry_principal` in every access + profile and register it for each client in both projects' + `dev-clients.yaml`. +- BREAKING: `CaseworkTaskAssertionSource` in the Rust client, which + `registry-stack-client` re-exports, reports a Casework 4xx answer to an + assertion request other than 408 and 429 as an invalid credential + (`TokenError::Invalid`, kind `invalid-credential`) with fixed text, where + it reported every failure as `unavailable`. An expired, revoked, or absent + grant is such a refusal, even when its problem body is malformed. A 408, + 429, or 5xx answer stays `unavailable`, a transport failure keeps its + transport kind, and the source still sends the assertion request once. + Migration: treat `invalid-credential` as a refusal that needs a new + approval, and stop retrying it. +- `caseworkctl dev start` pulls its PostgreSQL image from the Docker + Official Images mirror `public.ecr.aws/docker/library/postgres` at the + same digest, and its help names that reference. An egress allowlist for + local development must admit `public.ecr.aws`. A retained session + container on the Docker Hub `postgres:17.11` reference at that digest is + still accepted. ## v0.40.0 - 2026-10-11 diff --git a/products/messaging/CHANGELOG.md b/products/messaging/CHANGELOG.md index 8d814f0f68..4cb897405a 100644 --- a/products/messaging/CHANGELOG.md +++ b/products/messaging/CHANGELOG.md @@ -12,6 +12,25 @@ carries the Registry Messaging client, requires Node.js 24.11.0 or later: its `engines.node` is `>=24.11.0` (was `>=22.12.0`). Node.js 22 is no longer supported. Migration: run Node.js 24.11.0 or later. +- `POST /v1/messages/receipt` returns the retained original success receipt + for the caller's verified issuer and configured principal, the original + `Idempotency-Key`, and the original request body, under current ordinary + read authority. It never renders, submits, or sends, and it neither spends + nor requires the submission rate budget, so it never answers `429`. A + missing, expired, or changed receipt answers `receipt.unresolved` (409), + which is not evidence that no effect occurred. The Rust (`message_receipt`), + Node.js (`messageReceipt`), and Python (`message_receipt`) clients carry the + route. +- BREAKING (Rust API): `registry_messaging_core::ProblemCode`, which + `registry-messaging-client` re-exports and its errors carry, gains + `ReceiptUnresolved`. The enum is not `#[non_exhaustive]`, so an exhaustive + `match` on it no longer compiles. Only Rust code that matches on the enum + is affected. Migration: add a `ReceiptUnresolved` arm or a wildcard arm. +- `messagingctl dev` pulls PostgreSQL from + `public.ecr.aws/docker/library/postgres` and Mailpit from + `ghcr.io/axllent/mailpit`, each at the digest it pinned before, and its + help names both references. An egress allowlist for local development must + admit `public.ecr.aws` and `ghcr.io`. ## v0.40.0 - 2026-10-11 diff --git a/products/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index 48b44e19b1..a08b4d6bbf 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +- `registry-platform-oidc` gains a `task_grant` module holding the exact + task-grant status transport the Base Registry Engine and Scheduling share: + `TaskGrantStatusEndpoint`, `TaskGrantStatusRegistry` (with an offline + `validate_configuration`, `activate`, and `check`), + `TaskGrantStatusClient`, `TaskGrantStatusBinding`, and `TaskGrantError`. + A check obtains a private-key JWT credential for one status scope, calls + the authority's status route, and accepts only a 200 strict JSON answer of + at most 64 KiB that says the grant is active and repeats its exact + binding. A 404 refuses the grant; any other status, a wrong content type, + or an outage is `Unavailable`. The whole attempt is bounded at ten seconds + (five per request, three to connect) and identifies itself with the user + agent `registry-task-status`. `registry-platform-config` gains the shared + `TaskGrantStatusConfig` block, one `taskGrantStatus` entry with the keys + the Base Registry Engine and Scheduling read, defaulting to Casework's + status route and scope; it maps onto `TaskGrantStatusEndpoint` with `From`. +- `registry-platform-httputil` exposes `validate_requested_scopes`, the + scope-list check `PrivateKeyJwt` applies, and + `PrivateKeyJwtConfig::check_identity`, its client identity, assertion + audience, and token endpoint check, so a configuration can be validated + without a key, a certificate, or network access. + ## v0.40.0 - 2026-10-11 - BREAKING: `registry-platform-httputil` writes four error words in diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index c575becf31..49f0f34029 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -44,6 +44,48 @@ `config::RETIRED_PACKAGE_MANIFEST_FILE`, and `StoreError::EarlierRelease`. `schedulingctl plan` runs its read-only publication check only when no schema version is pending; a database v0.40.0 activated has none. +- The runtime file must state a top-level `taskGrantStatus`, which decides + how Scheduling learns whether a task grant is still current. A list of one + to 32 Casework task authorities, each named by the exact `sourceIssuer` its + grants carry, makes every capacity commitment and idempotent replay first + obtain a fresh exact status answer from the grant's authority, with no + fallback: an inactive, mismatched, unlisted, or incompletely bound grant + answers `operation.not-authorized` (403), even with less than fifteen + minutes left, and an authority or credential outage answers + `service.unavailable`. The whole check is bounded at ten seconds and ends + before the capacity transaction opens. `statusScope` and `statusPath` are + optional and default to Casework's `casework:grants:status` scope and + `/v1/task-grants/{grantId}/status` route. The keyword + `unchecked-final-fifteen-minutes` instead accepts only a grant with at most + 900 seconds left at request entry and performs no revocation check; a grant + with more answers `operation.not-authorized` (403) and is audited as + refused. An empty list is refused, naming both choices. See + `products/scheduling/RUNTIME-CONFIG.md`. +- BREAKING: a runtime file without `taskGrantStatus` is refused at startup + with `scheduling.runtime.missing-task-grant-status`. v0.40.0 had no such + member and accepted every unexpired task grant. A Casework immediate grant + never lasts longer than fifteen minutes, so the offline path refuses only a + deferred grant or a longer grant from another authority. After it acquires + its locks, the capacity transaction rechecks the execution token's expiry + as well as the grant's, on either path. Migration: list the task + authorities under `taskGrantStatus`, or write + `taskGrantStatus: unchecked-final-fifteen-minutes`, which accepts only a + grant with at most 900 seconds left at request entry and performs no + revocation check. +- `POST /v1/appointments/receipt` returns the retained original success + receipt for the caller's verified issuer and subject, the original + `Idempotency-Key`, and the original request body, under ordinary read + authentication. It never creates, replays, or claims capacity, and it can + observe work after the task grant has ended. A missing, expired, changed, + or refused receipt answers `receipt.unresolved` (409), which is not + evidence that no effect occurred. The Rust (`appointment_receipt`), + Node.js (`appointmentReceipt`), and Python (`appointment_receipt`) clients + carry the route. +- BREAKING (Rust API): `registry_scheduling_core::ProblemCode`, which + `registry-scheduling-client` errors carry, gains `ReceiptUnresolved`. The + enum is not `#[non_exhaustive]`, so an exhaustive `match` on it no longer + compiles. Only Rust code that matches on the enum is affected. Migration: + add a `ReceiptUnresolved` arm or a wildcard arm. ## v0.40.0 - 2026-10-11